Compare commits

..
Author SHA1 Message Date
enricobuehler 6ca7e4a7ec docs: the one-command install stops hiding on the hub page
ci / bun-nix (pull_request) Successful in 27s
ci / docs-drift (pull_request) Successful in 1m33s
ci / docs-site (pull_request) Successful in 1m53s
ci / web (pull_request) Successful in 1m55s
ci / rust-arm64 (pull_request) Successful in 2m27s
ci / rust (pull_request) Successful in 5m57s
The guided installer covers apt, dnf, pacman and sysext boxes, but only the
install hub mentioned it — a reader who lands on /docs/ubuntu from a search
never learns the whole page has a one-liner. Put the same block above step 1
on every page the script actually detects, and leave NixOS, SteamOS and
Windows alone: the script refuses those and points at their pages.

Also list Omarchy on the hub cards and in the quickstart table. It was added
to the sidebar with #428 but never to the two pages that route people there.
2026-08-28 22:47:21 +02:00
enricobuehler 4e392a7666 Merge pull request 'The installer can move a host between channels, not just onto one' (#432) from worktree-installer-channel-switch into main
ci / bun-nix (push) Successful in 25s
apple / swift (push) Successful in 2m5s
ci / docs-drift (push) Successful in 39s
ci / docs-site (push) Successful in 56s
ci / web (push) Successful in 1m1s
deb / build-publish-gamescope (push) Successful in 27s
ci / rust-arm64 (push) Successful in 1m25s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 21s
decky / build-publish (push) Successful in 56s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 30s
deb / build-publish-client-arm64 (push) Successful in 1m21s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 13s
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/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 12s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 56s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m0s
deb / build-publish (push) Successful in 3m46s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m24s
installer-smoke / smoke (arch) (push) Successful in 1m29s
installer-smoke / smoke (fedora-44) (push) Successful in 1m32s
deb / build-publish-host (push) Successful in 6m36s
docker / builders-arm64cross (push) Successful in 11s
installer-smoke / smoke (debian-13) (push) Failing after 3m25s
docker / deploy-docs (push) Successful in 37s
deb / smoke-install (push) Successful in 3m18s
ci / rust (push) Successful in 8m49s
arch / build-publish (push) Successful in 10m42s
android / android (push) Successful in 11m8s
apple / distribute (push) Successful in 11m36s
apple / screenshots (push) Successful in 10m17s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m19s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m59s
Reviewed-on: #432
2026-08-28 20:18:59 +00:00
enricobuehler 37902ebac8 Merge pull request 'The retry loop stops eating the restore that re-lights the desk' (#430) from worktree-hyprland-exclusive-restore-strand into main
android / android (push) Canceled after 39s
arch / build-publish (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 55s
ci / docs-site (push) Canceled after 9s
ci / web (push) Canceled after 52s
ci / bun-nix (push) Canceled after 0s
ci / docs-drift (push) Canceled after 0s
deb / build-publish (push) Canceled after 6s
deb / build-publish-host (push) Canceled after 8s
deb / build-publish-gamescope (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
deb / smoke-install (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 13s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 13s
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 21s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 40s
windows-host / package (push) Successful in 18m6s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 41s
Reviewed-on: #430
2026-08-28 20:18:13 +00:00
enricobuehler 2e4561d572 The installer can move a host between channels, not just onto one
ci / docs-drift (pull_request) Successful in 28s
ci / bun-nix (pull_request) Successful in 22s
ci / docs-site (pull_request) Successful in 51s
ci / web (pull_request) Successful in 58s
apple / swift (pull_request) Successful in 2m10s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
installer-smoke / smoke (arch) (pull_request) Successful in 1m15s
installer-smoke / smoke (debian-13) (pull_request) Successful in 1m48s
installer-smoke / smoke (fedora-44) (pull_request) Successful in 3m1s
ci / rust (pull_request) Successful in 7m20s
ci / rust-arm64 (pull_request) Failing after 12m9s
android / android (pull_request) Failing after 12m23s
`--channel canary` already worked, but only on a box with nothing installed:
the guided installer skips its whole install block once the three binaries are
on PATH, so re-running it with `--channel` on an existing host printed
"already installed" and changed nothing at all. Switching was a docs page of
`sed` one-liners you ran by hand.

Now `--channel` means the same thing on a fresh box and an installed one, and
it means it in both directions. A switch is a repo rewrite plus a re-resolve
that is allowed to go DOWN — canary is always a minor ahead of stable by
construction, so canary→stable is a downgrade and every package manager
refuses one unless told: apt gets explicit version pins from `madison`,
pacman `-Sy` then `-S` (never `-Syu`, which looks at the lower stable version
and does nothing), dnf a `distro-sync` behind the `install`, and the sysext
script already moved both ways on its own per-feed rollback floor.

Three things that were quietly wrong fall out of doing this properly:

- pacman appended `[punktfunk-canary]` while leaving `[punktfunk]` in place,
  so a canary install left both repos enabled and let repo order decide which
  build you got. The switch drops whichever section is there first, reusing
  --uninstall's own delete.
- `--channel` defaults to stable, so lifting the early-exit would have let a
  bare re-run — to fix a group, to open a firewall port — silently drag a
  canary box back a channel. With no explicit `--channel` the script now
  follows whatever the box is on. The gate below is built around this case.
- A switch moves every punktfunk package on the box, not the three the script
  installs, or a hand-installed punktfunk-gamescope is stranded on the channel
  the machine just left. That set comes from --uninstall's existing query.

Verification, since a downgrade path touches packages people already have:
check-docs-drift gains gate 8, six --dry-run cases over the four families with
the repo config and the installed binaries faked (`PUNKTFUNK_INSTALL_ETC`
joins `PUNKTFUNK_INSTALL_OS_RELEASE` as a testing override). The case that
matters is a canary box missing a package and re-run bare — mutating the
follow-the-box rule turns it red, which is how it was checked. installer-smoke
adds the round trip against the real registries, where the version has to move
and then come back; the textual gate proves the commands are emitted, only
that one proves the downgrade happens.
2026-08-28 22:08:23 +02:00
enricobuehler aa8c5d0e0e The retry loop stops eating the restore that re-lights the desk
ci / web (pull_request) Successful in 55s
ci / docs-site (pull_request) Successful in 1m1s
ci / bun-nix (pull_request) Successful in 22s
ci / docs-drift (pull_request) Successful in 25s
ci / rust-arm64 (pull_request) Successful in 2m11s
android / android (pull_request) Successful in 5m30s
ci / rust (pull_request) Successful in 7m48s
`topology: exclusive` on Hyprland left the operator's heads disabled after a
failed session, recoverable only by a hand-run `hyprctl reload`. The cause is
neither a failing reload nor the registry: it is one line in `apply_topology`.

The host opens the vdisplay backend ONCE and lends it to
`build_pipeline_with_retry` for up to eight attempts, so `create` — and with it
`apply_topology` — runs repeatedly against one instance's `pending_restore`
slot. Attempt 1 disables the heads and prepares the restore; attempts 2..n then
correctly find nothing left to disable (attempt 1 already darkened everything)
and prepare `None`. The slot was assigned unconditionally, so attempt 2 dropped
attempt 1's closure on the floor. When the build finally failed and the backend
dropped, its `Drop` had nothing to run — which is exactly why the field journal
shows `disabled=[...]`, ten `nothing to disable`, and no `restore_heads` line of
either polarity.

`stash_topology_restore` makes the slot first-wins. First is also the RIGHT
list, not merely the surviving one: attempt 1 looked at the desk while it was
still lit, so its set is every head that was on; a later attempt can only see a
subset.

Two corrections to what the code claimed about itself, since they are what made
this read as a registry bug. The Hyprland and sway `pending_restore` docs said
the registry picks the restore up and `Drop` is a mere backstop. It does not:
both backends carry a portal fd, so `registry::acquire` returns them as
pass-through at the `remote_fd.is_some()` check, several statements ABOVE its
`take_topology_restore()` call. Nothing ever lifts these into a display group,
and `Drop` is the only thing that runs them. The per-group hand-off machinery
is real, tested and correct — it just serves KWin, not these two.

That leaves a genuine per-session restore on the wlr-family backends (two
concurrent exclusive sessions: the first to end re-enables the heads under the
second), which is now written down at the field rather than contradicted there.
Closing it needs group bookkeeping the pass-through path does not have, so it
stays #284's call, not a drive-by restructure.

KWin gets the same guard although its slot is drained by the registry after
every create, so it cannot strand today — one of three identical sites left
unguarded is how this comes back.

Four tests in `backend.rs` cover the failure shape, the one that matters being
eight attempts with only the first having heads to disable. Verified by backing
the guard out: it fails with "the retry loop stranded the restore — the desk
stays dark".
2026-08-28 21:29:57 +02:00
enricobuehler 78723f381e Merge pull request 'The advert names its address, and the client stops rolling dice on the rest' (#429) from worktree-mdns-primary-addr into main
apple / swift (push) Successful in 2m13s
ci / bun-nix (push) Successful in 39s
ci / docs-drift (push) Successful in 40s
ci / docs-site (push) Successful in 1m2s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 23s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 22s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 18s
deb / build-publish-client-arm64 (push) Successful in 1m46s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m23s
ci / web (push) Successful in 1m23s
ci / rust-arm64 (push) Successful in 2m33s
deb / build-publish-host (push) Failing after 3m5s
deb / build-publish-gamescope (push) Successful in 56s
flatpak / build-publish (push) Successful in 4m53s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 19s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 19s
deb / build-publish (push) Successful in 6m7s
android / android (push) Successful in 9m21s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 23s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 17s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 9m52s
arch / build-publish (push) Successful in 10m4s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m24s
apple / distribute (push) Successful in 11m51s
docker / deploy-docs (push) Successful in 34s
ci / rust (push) Successful in 11m31s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Failing after 13m15s
docker / builders-arm64cross (push) Skipped
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 5m7s
deb / smoke-install (push) Failing after 9m18s
apple / screenshots (push) Successful in 10m28s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 25m22s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 25m38s
windows-host / package (push) Successful in 17m27s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 21s
Reviewed-on: #429
2026-08-28 19:10:15 +00:00
enricobuehler 46e617f7e6 Merge pull request 'Omarchy becomes a first-class host, and the host grows a control surface' (#428) from worktree-omarchy-impl into main
ci / bun-nix (push) Successful in 39s
ci / docs-site (push) Successful in 57s
ci / web (push) Successful in 1m7s
ci / docs-drift (push) Successful in 56s
ci / rust-arm64 (push) Successful in 1m59s
android / android (push) Canceled after 2m43s
deb / build-publish-gamescope (push) Successful in 48s
arch / build-publish (push) Canceled after 3m3s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 22s
ci / rust (push) Canceled after 3m16s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 22s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
deb / build-publish (push) Canceled after 2m31s
deb / build-publish-host (push) Canceled after 2m26s
deb / build-publish-client-arm64 (push) Canceled after 0s
deb / smoke-install (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m23s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 15s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 14s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 13s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 2s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 5s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 39s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 36s
installer-smoke / smoke (fedora-44) (push) Failing after 1m5s
windows-host / package (push) Canceled after 5m5s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
installer-smoke / smoke (debian-13) (push) Successful in 2m0s
installer-smoke / smoke (arch) (push) Successful in 55s
Reviewed-on: #428
2026-08-28 19:07:32 +00:00
enricobuehler 9c64cc6099 The ticket parser proves its own segments exist
ci / bun-nix (pull_request) Successful in 34s
ci / docs-drift (pull_request) Successful in 35s
ci / web (pull_request) Successful in 1m1s
ci / docs-site (pull_request) Successful in 1m0s
ci / rust-arm64 (pull_request) Successful in 1m49s
installer-smoke / smoke (arch) (pull_request) Successful in 1m9s
installer-smoke / smoke (fedora-44) (pull_request) Successful in 1m30s
installer-smoke / smoke (debian-13) (pull_request) Successful in 1m55s
android / android (pull_request) Successful in 6m6s
ci / rust (pull_request) Successful in 7m12s
`tsc --noEmit` under `noUncheckedIndexedAccess`: destructuring `ticket.split(".")` yields
`string | undefined` per element, and `parts.length !== 3` does not narrow a plain array — so five
type errors on the regex tests and the compare. An explicit `!ts || !nonce || !mac` guard proves it
to the compiler AND rejects the empty segments a `"1..2"` ticket would otherwise reach the regexes
with, which is what the removed `parts.some(p => p.length === 0)` was doing less directly.

Caught by CI's `web` job, not by me: I ran `bun test` and biome and stopped there. The typecheck
needs `node_modules`, which a fresh worktree has none of, and I took the two h3 import failures as
"pre-existing" without noticing that the same missing install was hiding the type check entirely.
With the deps in place the full web suite is 36/36 and `tsc --noEmit` exits 0.
2026-08-28 20:46:33 +02:00
enricobuehler 33914867d1 The advert names its address, and the client stops rolling dice on the rest
ci / bun-nix (pull_request) Successful in 26s
ci / web (pull_request) Successful in 52s
ci / docs-drift (pull_request) Successful in 57s
ci / docs-site (pull_request) Successful in 1m13s
ci / rust-arm64 (pull_request) Successful in 2m7s
apple / swift (pull_request) Successful in 2m17s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 6m45s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 7m1s
ci / rust (pull_request) Successful in 8m8s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 8m16s
A field client streamed over the host's ZeroTier address while both
machines shared a LAN. Neither end chose that: the host registers
exactly one address (its routed primary), but the OS's own mDNS
responder also answers A queries for <host>.local. per interface with
that interface's address, so the resolved set a client folds is a union
polluted by every overlay network whose multicast reaches it - and both
clients picked with HashSet::iter().next(): arbitrary, and re-rolled on
every re-announce.

punktfunk_core::discovery now makes the pick deterministic, shared by
the desktop and Android clients. Ranking, best wins: longest common
prefix with any of this machine's unicast addresses (on-link beats
routed, which settles LAN-vs-overlay in BOTH directions - a client that
can only reach the host through the overlay has no interface for the
host's LAN address to match), then the address the host itself declared,
then prefix against our default-route source, then the numerically
lowest. The host declares which address its advert is FOR in a new
advisory TXT key (addr); it never overrides reachability, only settles
a multi-NIC host's tie, and old hosts without it still resolve
deterministically.
2026-08-28 20:34:34 +02:00
enricobuehler 38d2b0e454 The console follows the desktop's theme instead of ignoring it
ci / docs-drift (pull_request) Successful in 26s
ci / bun-nix (pull_request) Successful in 28s
ci / docs-site (pull_request) Successful in 53s
ci / web (pull_request) Failing after 57s
installer-smoke / smoke (arch) (pull_request) Successful in 38s
installer-smoke / smoke (fedora-44) (pull_request) Failing after 39s
ci / rust-arm64 (pull_request) Successful in 2m49s
installer-smoke / smoke (debian-13) (pull_request) Successful in 5m32s
android / android (pull_request) Successful in 8m11s
ci / rust (pull_request) Successful in 10m2s
WP7 shipped a template and no consumer. `punktfunk.json.tpl` rendered fine — Omarchy globs
`~/.config/omarchy/themed/*.tpl` and writes the result to
`~/.local/state/omarchy/current/theme/` on every `omarchy-theme-set`, and the keys the template
asks for (`mode`, `accent`, `background`, `foreground`) all exist in a theme's `colors.toml`,
verified against tokyo-night on the box. Nothing read it, so nothing changed, which is exactly what
"the theme is not applied" looks like from the outside.

Now: the server reads the rendered file and `ui-config` carries it; the root applies `mode` as the
`.dark` class the whole stylesheet already keys off, and `accent` as the brand variable
`--primary`, `--accent` and `--ring` all derive from. One value re-tints the buttons, the active
nav and the focus rings together.

BOTH brand variables, not just `--pf-brand`. The light palette derives `--primary` from it, but
`.dark` derives `--primary` from `--pf-brand-light`, and the console ships pinned dark — so setting
only the first would have re-tinted light mode and done nothing whatsoever in the mode people
actually see.

The accent reaches a style attribute, so the reader validates it as a colour rather than trusting
the file: hex and the functional notations Omarchy themes use, nothing else. That is not
theoretical tidiness — an UNRENDERED template contains the literal `{{ accent }}`, and a theme
switch can be caught mid-write. Both are "no theme" here, and no theme means the console's own
violet, which is also what SSR paints and what shows for the moment before the fetch resolves.
Every failure is the fallback; none is an error page.

Read per request rather than cached, because `omarchy-theme-set` rewrites the file whenever the
user switches and a console that only looked at startup would be wrong until it restarted.

Verified on the box: the template renders (`"mode":"dark","accent":"#7aa2f7"`), and the reader
parses that exact file. 14 tests, biome clean.

⚠ Like the handoff, the console half needs a `punktfunk-web` build to reach a box — it ships as a
prebuilt Nitro `.output` and the Omarchy testbox has no bun — so this is unit-tested and
file-verified but has not painted a pixel on glass.
2026-08-28 20:25:11 +02:00
enricobuehler 30c31ed31b Opening the console from your own launcher no longer asks who you are
Three things an Omarchy user meets in the first minute, all of which were wrong.

The login (the handoff)
-----------------------
Asking for a password to open your own console from your own app menu buys nothing — but the
obvious fix, dropping the login, is not available: the console binds **0.0.0.0:47992** so it can be
reached from a phone, and its admin surface is pairing, unpair and session control. Trusting the
network would hand that to the LAN.

So trust what actually distinguishes the operator: the **management token**, a 0600 file inside the
0700 config dir. `punktfunk-host ctl console-url` mints a ticket signed with it; the console
verifies with the copy it already holds and seals the ordinary session cookie. Whoever can read
that file can already drive the whole admin API — it is the credential the console's own proxy
presents — so skipping a password they could simply read widens nothing, and a visitor without a
ticket still meets the login page.

`<unix-seconds>.<nonce>.<HMAC-SHA256>` over `pf-console-handoff:v1:ts:nonce`. 60 s TTL, single use,
symmetric window (a ticket from the future is as wrong as an old one), one 401 for every rejection
so probing learns nothing. No new host route and no shared state: both sides already hold the key.
The decision lives in `util/handoff` rather than the route so it is testable without an h3 event —
and the test suite includes a vector minted by the real Rust host and cross-checked against
python's `hmac`, so a drift in the message format fails here instead of six weeks later in the
field.

The launcher entry
------------------
`Icon=` was empty, so the entry drew nothing. Two causes: the host package never shipped an app
icon (only the CLIENT package installed `io.unom.Punktfunk.svg`), and `omarchy-webapp-install`
derives an icon name with `${ref%.*}` + slugify, which turns a dotted theme name into `io-unom` —
resolving to nothing. The host package now ships the scalable mark, and setup writes `Icon=` and
`Exec=` itself after letting their tool create the entry.

The Omarchy menu
----------------
Super+Space had only the webapp. There is now a Punktfunk submenu — open console, pair a device,
devices waiting, paired devices, stop the session, status, restart — with `when` guards so a row
the box cannot honour does not show.

D7 avoided writing the user's `omarchy-menu.jsonc` because it is a single document where one parse
error silently drops EVERY row they own. That risk is real, so the merge earns its place: work on a
copy, insert between markers so a re-run replaces rather than stacks, validate the result as JSONC
AND confirm our rows actually landed, and only then move it into place. A file that does not parse
to begin with is left completely alone — it is not ours to repair. `remove` takes the block back
out and restores their file byte for byte.

Two bugs the self-check caught before the box did: `awk -v` refuses a newline inside a `-v`
assignment, so the rows silently never landed while the function reported success (hence the merge
now verifies its own output, not just that the file still parses); and `MENU_FILE` was a `readonly`
computed at LOAD time, so it ignored an `XDG_CONFIG_HOME` set afterwards and edited the real config
of the machine running the test. Paths resolve at call time now, like `write_hooks` already did.

Gates: fmt, clippy -D warnings, 13 ctl tests, 7 handoff tests, 13 shell self-checks, docs-drift,
shellcheck. On the box: the ticket verifies against an independent HMAC, two tickets in the same
second differ, and the launcher entry carries the right Exec and Icon.

⚠ The console half needs a `punktfunk-web` build to reach a box — it ships as a prebuilt Nitro
`.output`, and there is no bun on the Omarchy testbox — so the route itself is unit-tested but has
not served a request on glass.
2026-08-28 20:14:00 +02:00
enricobuehler 739d63fc04 The picker is handed back when the host exits, not between casts
Restoring `custom_picker_binary` at the end of every cast meant the NEXT session found the config
changed, rewrote it, and restarted xdg-desktop-portal-hyprland — and a ScreenCast bound across an
xdph restart never delivers a buffer. The portal runtime caches its D-Bus connection
process-globally (`portal_thread`'s own note), so the restart orphans the cached connection and the
handshake then succeeds against a session nothing is alive to serve. That is the failure our own
error text already names: "a stream bound during a compositor (re)start that will never deliver".

So the restore moves to the host's shutdown path, through the `restore_takeover_now` hook the host
already calls on SIGTERM — no cast is live there, so the xdph restart it triggers costs nothing.

Leaving the shim installed between sessions is safe precisely because it DELEGATES: with no
selection pending it execs the picker that was configured before us, so an ordinary browser share
behaves exactly as it did. That is what D6 actually asks for — the user's screen sharing keeps
working — and it is what lets the takeover be idempotent instead of churning the file. A host that
is SIGKILLed still leaves the shim, and `punktfunk-omarchy remove` puts the original back from the
marker.

Found while chasing a black screen on Omarchy that turned out to have a different root cause (the
VM had two GPUs and the portal allocated on the wrong one), so this fixes a real defect that was
not the one being hunted — every session after the first would have restarted xdph underneath
itself regardless of the GPU topology.
2026-08-28 20:14:00 +02:00
enricobuehler c7d270b466 The streamed head can be focused on a Lua box, which is what makes it produce frames
#283's focus fix has never reached a Lua-configured Hyprland. The old note here said why and then
stopped: `hyprctl dispatch focusmonitor <name>` is parsed as `hl.dispatch(focusmonitor <name>)`
and dies with `')' expected near '<name>'`, and `hl.dsp.focusmonitor` does not exist. Both true.
The name was simply wrong.

Measured on Omarchy 4.0.1 / Hyprland 0.56.2, the spelling is

    hl.dsp.focus({ monitor = "<name>" })

and the compositor volunteers it — ask with any other key and it answers "hl.focus: unrecognized
arguments. Expected one of: direction, monitor, window, urgent_or_last, last". Same two-era shape
as `dpms_one`: try classic, then Lua, report both if neither lands. Each spelling is rejected by
the other's parser, which is what makes running them blind safe.

This is not the cosmetic window-placement issue it was filed as. A headless output nothing has
focused stays empty; an empty output produces no damage; no damage means no PipeWire frames — and
capture then misses its first-frame deadline:

    pipeline build failed — retrying attempt=4 max=8
    error=first frame: no PipeWire frame within 10s (node 85): format negotiated but no buffers
    arrived — the compositor produced no frames

i.e. a black screen on the client, on every Omarchy box.

It is only HALF the black screen seen on glass, and the other half is NOT fixed here. The same
session had already run `topology: exclusive`, which logged

    `topology: exclusive` — the streamed output is now the desk disabled=["Virtual-1","HDMI-A-1"]

twice, and then never restored them: `restore_heads` logs on success AND on failure, and neither
line appears anywhere in the journal, so it was never called on the pipeline-build failure path.
The ten retries that followed all reported "had nothing to disable" — correctly, the heads were
already off — so once the first attempt fails the desk stays dark and every later attempt fails
for the same reason. `hyprctl reload` is the recovery. That belongs to #284's in-flight work
rather than here, so it is recorded with its evidence instead of half-fixed in a file another
effort is editing.
2026-08-28 20:14:00 +02:00
enricobuehler b8720e5e4a The Omarchy box says otherwise: five things the plan got wrong, measured
WP0 + S2 run against a real Omarchy 4.0.1 box (Hyprland 0.56.2, Quickshell 0.3.1, RTX 5070 Ti).
Everything below is a measurement, not a re-reading of the docs.

Fixed, each one a first-run defect
----------------------------------
1. **The install line could not work.** Omarchy ships a libalpm PreTransaction hook
   (00-omarchy-update-guard) that aborts any transaction whose pacman invocation carries BOTH
   -S and -u, to funnel upgrades through `omarchy update`. So the Arch line this branch shipped
   for Omarchy — `pacman -Syu <pkgs>` — dies with "Woah partner..." and installs NOTHING. Seen
   in /var/log/pacman.log, then confirmed the other way: `pacman -Sy` + `pacman -S <pkgs>`
   installs all three cleanly. platforms.json, the docs page and install.sh now split the two,
   and plain Arch keeps its full -Syu (a partial upgrade against a ROLLING repo is what breaks
   those boxes; Omarchy's frozen snapshot mirror is exactly why it does not break here).

2. **The idle guard never restored anything.** `omarchy-toggle-idle status` prints JSON, not a
   keyword — and the payload is a trap: its tooltip names the action the button WOULD take, so
   the string "Stay Awake" appears precisely when stay-awake is OFF. The old case-glob matched
   neither that nor the words it was looking for, so every arm fell through to "do nothing" and
   the box stayed awake after every session. Now reads `"enabled":true`. Verified both ways on
   the real tool: a user's own stay-awake survives a stream, one we set is undone.

3. **`status` lied twice.** It reported "no punktfunk rules" seconds after adding six of them
   (a failed `sudo -n` read was being treated as proof of absence), and reported xdph missing on
   a box that was capturing through it — `pgrep -x` can never match `xdg-desktop-portal-hyprland`
   because Linux truncates comm to 15 characters, and pgrep says so on a stderr the check threw
   away. Now: never claim absence from a failed read, and ask systemd about the portal.

4. **D3's premise is only half true.** Omarchy ships TWO session entries. On `hyprland-uwsm`
   graphical-session.target starts; on plain `hyprland` it never does — measured, and Omarchy's
   OWN units that want it (omarchy-crash-watch, omarchy-sleep-lock) sit enabled-and-dead there
   too. The drop-in is additive so autostart still works either way, but "restarts with your
   session" was a promise we could not keep. setup and status now detect and say which it is,
   and the docs explain when switching sessions is worth it (rarely: the host re-derives the
   live compositor per connect).

5. **`ctl watch` leaked a stream per dead consumer.** A watcher only ever READS, so when the
   widget died its end of our stdout pipe closed unnoticed: six orphans accumulated across three
   shell restarts on a host with no events at all. The SSE keep-alive now surfaces as a
   `ctl.heartbeat` line, which gives us a write to fail on, and a failed write ends the process.
   Verified with its control: dies ~13s after its reader goes, survives 50s while the reader lives.

Confirmed as designed
---------------------
* **D6 end to end.** The box's xdph.conf is exactly the shape the fix targets. A real capture
  (xdph → PipeWire 1920x1080 → cuda-import → nvenc) installed the shim, the shim picked up
  `hyprland-preview-share-picker` as its fallback automatically, the restore fired on teardown,
  and the file came back byte-identical with no marker left. The landmine is defused.
* **The pin correction.** The mgmt port serves 306d95… = native-cert.pem; cert.pem is 34a4cb…,
  a different certificate entirely. Pinning cert.pem as the plan said would have exited 4 on
  every healthy host. All four exit codes exercised on glass — 4 by pointing ctl at the host's
  own legacy cert, which is a valid certificate that simply is not the one mgmt serves.
* **R1 and R3 closed.** Our soname depends (libavcodec.so=63-64 …) match the frozen snapshot
  mirror's ffmpeg 9.0.1 exactly, and /etc/pacman.conf is owned by pacman, not omarchy-settings,
  so our repo stanza is not clobbered. R4 closed by (1); R7 closed — enforce_permissions is off
  as shipped.
* The tree builds on Omarchy in 8m16s against the snapshot's toolchain (rustc 1.98).

Gates: fmt, clippy -D warnings, 13 ctl tests, docs-drift, shellcheck, 8 shell self-checks.
2026-08-28 20:14:00 +02:00
enricobuehler 32a89fd8ec Two checks for the two failures that report nothing at all
Diagnostics catalog rows for the console's troubleshooting page (design §3.M, WP1).

`hyprland_permissions` — Hyprland 0.49+ can enforce per-application permissions, and when
it does, screencopy and virtual input are denied SILENTLY: black frames and dead input,
with no error from the host and none from the compositor. Every other failure on that path
announces itself; this one is the reason the row exists. Warning rather than Critical on
purpose — enforcement being ON does not mean we are denied, a granted host streams fine,
and no probe outside the compositor can tell the two apart. Not Omarchy-specific: it
applies to every Hyprland box, and Omarchy is merely the distro most likely to turn it on.

`omarchy_updates` — on Omarchy the console's apply button is deliberately absent (D5).
Without a row saying where updates come from instead, "my update button is missing" is an
unanswerable support question, asked in the one place we could have answered it.

Both ids are registered in the documented-ids test, so a rename is caught in review rather
than in a bug report about a check that suddenly renders in English.

Host suite: 678 pass. The one red — gamestream::stream::tests::sender_delivers_batches —
is the documented EINTR flake in this qemu container: it fails identically with these
changes stashed, it lives in a file this branch never touches, and the panic is literally
"Interrupted system call".
2026-08-28 20:14:00 +02:00
enricobuehler d9cb3ce701 The host grows a control surface, and Omarchy stops being a distro we merely tolerate
Implements the Omarchy integration plan (punktfunk-planning design/omarchy-integration.md
+ omarchy-integration-implementation-plan.md): the ctl surface M-C1..C4, design WP1-WP7,
and the D6 xdph landmine. WP0 (the measurement battery) and WP11 (upstreaming) are not
here — the first needs an Omarchy box, the second needs an explicit go.

`punktfunk-host ctl` (WP8 / M-C1..C4)
------------------------------------
A subcommand, not a second binary. The entry-point analysis is in the plan's §3; the
short version is that a new binary touches every Linux artifact we ship to buy nothing,
while `main.rs` already dispatches a dozen verbs and in-crate means no second declaration
of the mgmt types to drift.

15 verbs over the existing admin lane — pairing arm/pending/approve/deny, the Moonlight
PIN, both planes' device lists with rename/unpair/access presets, session stop/end-game,
and `watch`, which bridges the SSE stream to line-JSON on stdout with `Last-Event-ID`
resume and a synthetic `ctl.resync` line after a `dropped` frame. `--json` on every verb
returns a versioned envelope; the human tables are not parsed by anything we ship.

The security model is the plan's §§1-2, and the load-bearing part is **pin before token**:
the agent pins the host's own leaf (via punktfunk-core's canonical `PinVerify`) and rustls
rejects a mismatch *during the handshake*, so ureq never gets as far as serialising an
`Authorization` header. That closes the local port-squat vector with zero server-side
change — `mgmt/auth.rs` is untouched by this whole surface. There is deliberately no
`--token` flag and no token env read: a credential in either is readable cross-uid through
/proc, which is what the 0700 config dir exists to prevent. ctl consumes the token the
host persists and never mints one (the `web-password` silent-adoption lesson, inverted).

Two corrections to the plan, both from reading the code rather than the doc:
 * the mgmt listener serves the NATIVE identity, so the pin is `native-cert.pem` then
   `cert.pem` — pinning `cert.pem` alone would exit 4 on a perfectly healthy host;
 * the pin is a SHA-256 fingerprint compare through the existing audited verifier, not a
   new bytes-compare. Same property, one less thing to review.

The test that matters is the negative: a squatter presenting a valid, well-formed,
wrong self-signed certificate gets exit 4 and receives ZERO application bytes.

The xdph collision (D6)
-----------------------
Omarchy ships its own `~/.config/hypr/xdph.conf` naming `hyprland-preview-share-picker` —
the picker every browser share on the box goes through — and our Hyprland backend takes
`custom_picker_binary` over on first connect. The merge half was already right (one key,
in place, with a backup); the way back did not exist, so a box that had ever streamed kept
our shim forever, including after a crash and after a reboot.

Two changes, and the first is the one that does not depend on getting teardown right: the
shim now DELEGATES — with no selection pending it execs whatever was configured before us,
so ordinary browser shares behave exactly as they did, during a session, after it, after a
kill and after a reboot that emptied $XDG_RUNTIME_DIR. Second, the edit records what it
replaced (a marker comment written in the same atomic write, so it survives everything a
sidecar state file would not), and the picker is handed back when the last cast ends.
`punktfunk-omarchy remove` restores it too, from the same marker.

The rest
--------
 * WP1 — `osinfo::is_omarchy()`; the console update tier is notify-only on Omarchy and
   names `omarchy update`. Enforced at the apply route, not merely reported: a direct POST
   on a box with the helper, the group and the sysupgrade opt-in would otherwise run
   `pacman -Syu` into their guard, or past it, skipping the snapper snapshot their rollback
   depends on.
 * WP2/WP3 — `punktfunk-omarchy setup|remove|status`, installed but never run by the
   package (packages do not open firewalls or edit user config behind an operator's back).
   LAN-scoped comment-tagged ufw rules over our existing app profiles, the session drop-in
   that Omarchy's uwsm actually starts, the console as a webapp rather than an edit to the
   user's single menu JSONC, and a Sunshine-coexistence check that says plainly which plane
   collides. PKGBUILD gains the hyprland/xdph optdepends that were owed before Omarchy.
 * WP4 — docs/omarchy.mdx, the ctl reference in host-cli.md, platforms.json (+ snapshot),
   the Hyprland/Omarchy rows in requirements.md, an honest "not verified on glass" row in
   the support matrix, and an install.sh branch that hands over to `punktfunk-omarchy setup`.
 * WP5/WP6/WP7 — sample hooks wired through hooks.json (never host.env: hooks are a list,
   and an operator's existing file is printed to rather than overwritten), an idle guard
   that snapshots and RESTORES the user's own stay-awake setting rather than assuming, and
   the theme template.

Gates: fmt, clippy -D warnings on punktfunk-host and pf-vdisplay's Linux half, 45 host
tests, 267 pf-vdisplay tests, check-docs-drift, shellcheck, and 8 shell self-checks
covering the awk picker restore and the hooks.json generator against the same fixtures
the Rust round-trip tests use.

Not verified on an Omarchy box — nothing activates without `punktfunk-omarchy setup`, so
a plain Arch box is unaffected either way.
2026-08-28 20:14:00 +02:00
enricobuehler d12d494f68 Merge pull request 'The client stops promising an HDR its video processor can't tone-map' (#427) from worktree-arc-pq-tonemap-probe into main
ci / web (push) Successful in 1m14s
ci / bun-nix (push) Successful in 1m18s
ci / docs-site (push) Successful in 1m27s
ci / docs-drift (push) Successful in 39s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
ci / rust-arm64 (push) Successful in 2m39s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 16s
deb / build-publish-gamescope (push) Successful in 51s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 17s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 15s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 18s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 19s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 21s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m20s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m28s
deb / build-publish-client-arm64 (push) Successful in 4m58s
ci / rust (push) Successful in 8m51s
docker / builders-arm64cross (push) Successful in 18s
arch / build-publish (push) Successful in 9m32s
android / android (push) Successful in 10m2s
docker / deploy-docs (push) Successful in 45s
deb / build-publish (push) Successful in 5m36s
flatpak / build-publish (push) Successful in 7m9s
deb / build-publish-host (push) Successful in 5m48s
deb / smoke-install (push) Successful in 3m13s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 13m56s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 5m5s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m25s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 8m52s
Reviewed-on: #427
2026-08-28 12:31:46 +00:00
enricobuehler abac2a6802 Merge pull request 'A truncated roundtrip is not an encoder ceiling, and the probe waits for video' (#426) from worktree-abr-phantom-ceiling into main
ci / web (push) Canceled after 33s
android / android (push) Canceled after 34s
arch / build-publish (push) Canceled after 32s
ci / bun-nix (push) Successful in 29s
ci / rust (push) Canceled after 34s
ci / rust-arm64 (push) Canceled after 34s
ci / docs-site (push) Canceled after 33s
ci / docs-drift (push) Canceled after 0s
deb / build-publish (push) Canceled after 8s
deb / build-publish-host (push) Canceled after 4s
deb / build-publish-gamescope (push) Canceled after 1s
deb / build-publish-client-arm64 (push) Canceled after 0s
deb / smoke-install (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 5s
apple / screenshots (push) Successful in 10m28s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 5s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 5s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 4s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 34s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
apple / swift (push) Successful in 2m12s
apple / distribute (push) Successful in 13m51s
windows-host / package (push) Successful in 17m58s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 17s
Reviewed-on: #426
2026-08-28 12:31:18 +00:00
enricobuehler 2b46b5c2da Merge pull request 'A deploy under an open tab stops taking the next navigation with it' (#424) from worktree-worktree-web-stale-assets into main
audit / pnpm-audit (push) Successful in 16s
audit / bun-audit (plugin-kit) (push) Successful in 20s
audit / bun-audit (sdk) (push) Successful in 20s
audit / bun-audit (web) (push) Successful in 21s
audit / docs-site-audit (push) Successful in 20s
audit / cargo-audit (push) Successful in 28s
ci / web (push) Successful in 54s
ci / rust-arm64 (push) Successful in 1m45s
ci / docs-site (push) Successful in 48s
ci / docs-drift (push) Successful in 23s
ci / bun-nix (push) Successful in 1m0s
audit / license-gate (push) Successful in 5m59s
audit / c-abi-asan (push) Successful in 6m24s
deb / build-publish-gamescope (push) Successful in 25s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 10s
audit / miri (push) Successful in 6m39s
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
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 19s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 18s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 9s
deb / build-publish (push) Successful in 5m20s
deb / build-publish-client-arm64 (push) Successful in 1m15s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 44s
arch / build-publish (push) Successful in 8m55s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 56s
docker / builders-arm64cross (push) Successful in 24s
ci / rust (push) Successful in 9m10s
docker / deploy-docs (push) Successful in 41s
deb / build-publish-host (push) Successful in 9m21s
deb / smoke-install (push) Successful in 2m30s
windows-host / package (push) Successful in 18m28s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 34s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 23m2s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 23m20s
nix / flake (push) Successful in 25m4s
Reviewed-on: #424
2026-08-28 11:11:44 +00:00
enricobuehler 224ed86fbd A truncated roundtrip is not an encoder ceiling, and the probe waits for video
ci / bun-nix (pull_request) Successful in 32s
ci / docs-drift (pull_request) Successful in 48s
ci / docs-site (pull_request) Successful in 1m15s
ci / rust-arm64 (pull_request) Successful in 1m46s
ci / web (pull_request) Successful in 1m47s
apple / swift (pull_request) Successful in 2m18s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 6m44s
android / android (pull_request) Successful in 7m23s
ci / rust (pull_request) Successful in 8m36s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 8m3s
Two field sessions on 0.32.0 (Windows client over ZeroTier, Android over
Wi-Fi) plus a local repro showed every Automatic session ratcheting
20 -> 2.3 Mbps on the first loss episode and never climbing back.

Phase 4 made the bitrate a wire budget: the encoder is handed
enc_kbps(budget) and the read-back maps through budget_kbps(). That
roundtrip deflates 1-2 kbps by design (the inverse never inflates), and
the applied < requested compare then recorded a phantom
encoder_ceiling_kbps on EVERY successful apply. The control task clamps
each later SetBitrate to it, so every climb was acked short at the
current rate, the client controller learned it as a host cap
(reprobe backoff 16 -> 32 windows), and each descent lowered the
"ceiling" further: a one-way ratchet to the floor. 0.31.4 was immune -
its read-back stayed in encoder units, so an in-range apply compared
equal. EncDerive::applied_budget_kbps now reads back in the request's
own truncated terms: only an apply short of what the request itself
derives to - a genuine driver clamp - records a ceiling. Regression
test proven against the backed-out fix.

The startup link-capacity probe fired on a flat 2 s timer, but host
bring-up can take 6-8 s (IDD display acquisition), so the 940/464 Mbps
burst landed exactly on the first IDR - black video for 5-11 s, the
decoder refusing AUs ("PPS 0 not seen"), 2101 audio underruns - and the
result missed the 6 s timeout anyway (bring-up plus the burst's own
queue drain and QUIC loss-recovery backoff: 8.6 s measured), so the
ceiling stayed negotiated: all disturbance, no measurement. The probe
now arms only once a frame has completed, and the timeout is 15 s so a
slow answer still teaches the ceiling.
2026-08-28 10:29:15 +02:00
enricobuehler a86edba4ca fix(client): don't advertise HDR a video processor can't tone-map — the green-monitor Arc report
ci / rust-arm64 (pull_request) Successful in 1m55s
ci / web (pull_request) Successful in 1m55s
ci / bun-nix (pull_request) Successful in 24s
ci / docs-drift (pull_request) Successful in 31s
ci / docs-site (pull_request) Successful in 2m5s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m17s
android / android (pull_request) Successful in 6m4s
ci / rust (pull_request) Successful in 8m12s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m41s
VIDEO_CAP_HDR invites a PQ stream, and on a Windows box with no HDR10
swapchain the D3D11VA hand-off shows it through the video processor's
PQ->sRGB tonemap — a driver capability nothing ever validated. The Blt
accepts the colorspaces either way and renders garbage where the
conversion is missing (host-side sibling: NVIDIA RGB->P010 'renders
green'). Field 2026-08-26: an Arc A370M client went green on every HDR
session while AV1 8-bit SDR at the same 2880x1620@120 streamed clean.

video_d3d11 gains pq_tonemap_supported() — one throwaway device +
enumerator asked CheckVideoProcessorFormatConversion for the exact pair
the SDR ring sets (P010 G2084 studio in, BGRA8 sRGB out). Only the
driver's definitive 'no' answers false; API failure keeps today's
behaviour. video::hdr_presentable() wraps it behind the short-circuits
that make it free everywhere it doesn't matter (!d3d11_import,
d3d11_hdr10 pass-through, non-Windows shader tonemap), and the session
binary ANDs the HDR setting with it exactly like the 4:4:4 promise —
loud warn when the switch is on and not honored. ten_bit_sdr stays
unprobed on purpose: 10-bit SDR is no tonemap.

Also: log_layout_once keys on decoder x layout instead of decoder alone,
so a mid-stream Reconfigure or SDR<->PQ flip re-logs the frame-vs-pool
dims — the fact a green-bar/smear report hinges on — for the shape that
failed, not just the one the session started at.

Verified: cargo fmt + dockerized Linux clippy -D warnings green for
pf-client-core + punktfunk-client-session; the cfg(windows) half needs
the Windows CI leg (not compilable from this box).
2026-08-28 09:02:59 +02:00
enricobuehler 23f53d46a7 chore(web): biome 2.5, and the findings it surfaced
ci / docs-drift (pull_request) Successful in 30s
ci / bun-nix (pull_request) Successful in 32s
ci / web (pull_request) Successful in 52s
ci / docs-site (pull_request) Successful in 1m46s
ci / rust-arm64 (pull_request) Successful in 3m4s
ci / rust (pull_request) Successful in 7m51s
nix / flake (pull_request) Successful in 7m43s
biome is not in ci.yml's web gate, so formatting and import order had drifted; `biome check
--write` accounts for most of this diff. `biome migrate` moved the deprecated
linter.rules.recommended to rules.preset. public/ and storybook-static/ are excluded — biome
was linting the favicon as if it were inline JSX and asking it for a <title>.

Three findings were real:

- Library lifted the whole entry list into state so the providers card could count owners.
  That consumer is gone: `entries` was written on every grid load and read by nothing. The
  state, the `onEntries` prop threading it up, and the effect firing it are all removed —
  including from LibraryGrid, where the callback had no callers left.
- `flatten` in SourceSettings spread the accumulator per `allOf` branch, rebuilding the object
  each time. Object.assign onto an accumulator that already starts as a fresh copy of the node
  is the same result without the O(n²).
- LogsCard's `newestVisible` dep already carried a comment begging the next person not to
  "fix" it — it is a trigger, not a read, and removing it silently breaks follow-mode. That
  warning is now a biome-ignore, so the tool enforces it instead of prose hoping to.

Build, tsc, 22 tests and biome all clean.
2026-08-28 02:23:40 +02:00
enricobuehler f693edda20 refactor(web,docs-site): take the stale-chunk recovery from @unom/ui instead of keeping our own
@unom/ui has exported `reloadOnStaleChunk` for exactly this the whole time and nothing had
ever imported it — the copy added a few commits ago was a fourth implementation of a solved
problem. Both routers now call the library.

0.10.0 grew the two things using it turned up: an optional `resolveTarget`, so the recovery
lands on the page the user was navigating to rather than reloading in place, and a try/catch
around the sessionStorage guard, because blocked storage throws on `getItem` and that throw
escaping the listener leaves the tab on exactly the dead page the helper exists to prevent.

The helper installs once, so the callback reads a module-level ref rather than closing over a
router: hydration builds a second router and discards the first, which is the same reason the
console keeps its QueryClient in one.

Deps: @unom/ui 0.10.0 and motion 13.1 (0.10.0 widened the peer, which is what had pinned us to
12), plus @unom/app-ui 0.3.0 for the docs site.

docs-site: build + tsc clean, and the deploy simulation still recovers onto /docs with the
library helper in place. web: build + tsc + 22 tests clean, bun.nix regenerated.
2026-08-28 02:23:25 +02:00
enricobuehler a3e073fd3a Merge pull request 'The host-audio switch reaches every client, not just the desktop ones' (#423) from worktree-keep-host-audio-parity into main
ci / docs-drift (push) Successful in 28s
ci / bun-nix (push) Successful in 31s
ci / docs-site (push) Successful in 1m8s
apple / swift (push) Successful in 2m10s
ci / web (push) Successful in 2m6s
deb / build-publish-gamescope (push) Successful in 52s
ci / rust-arm64 (push) Successful in 2m48s
deb / build-publish-client-arm64 (push) Successful in 1m18s
deb / build-publish (push) Successful in 6m34s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 7m30s
arch / build-publish (push) Successful in 9m7s
android / android (push) Successful in 9m28s
ci / rust (push) Successful in 9m43s
decky / build-publish (push) Successful in 55s
deb / build-publish-host (push) Successful in 8m51s
flatpak / build-publish (push) Successful in 4m45s
deb / smoke-install (push) Successful in 2m21s
apple / distribute (push) Successful in 11m15s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 9m33s
apple / screenshots (push) Successful in 10m0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m6s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 21m13s
windows-host / package (push) Successful in 14m54s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 31s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 18s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 50s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 1m14s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 16s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 15s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 15s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m1s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m37s
docker / deploy-docs (push) Successful in 39s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
docker / builders-arm64cross (push) Successful in 11s
2026-08-27 23:26:41 +00:00
enricobuehler 71454da082 chore(deps): bring the docs site and console up to Vite 8 and TypeScript 7
Everything in range moves to current — TanStack router/start/query, fumadocs, radix,
recharts, orval, storybook, biome, paraglide, playwright — and the majors that were
holding both apps a generation back go with them: Vite 7 to 8, @vitejs/plugin-react 5 to
6, vite-tsconfig-paths 5 to 6, @types/node 22 to 26, TypeScript 5.9 to 7. The marketing
site already ran the Vite 8 line against TanStack Start, so this closes a split the
monorepo was carrying for no reason.

Two of the console's majors were fixing a mismatch rather than chasing a number:
@unom/ui@0.9.2 asks for lucide-react ^1.17 and depends on tailwind-merge ^3.6, while the
console was pinned to lucide-react 0.469 and tailwind-merge 2.6.

motion is deliberately left on 12.43. 13.1 is available, but @unom/ui declares motion
^12.40 as a peer and inlines framer-motion 12.38, so moving the app alone would put two
motion runtimes in one tree — and a split motion context is exactly the failure that
neither a typecheck nor a build can see. It moves when @unom/ui does.

docs-site: build + tsc clean. web: build + tsc + 22 tests clean, bun.nix regenerated.
2026-08-28 01:24:21 +02:00
enricobuehler 263e173604 fix(web,docs-site): a deploy under an open tab stops killing the next navigation
Routes are code-split, so every build names its chunks by content hash and a deploy
replaces the whole `.output`. A tab opened before the deploy still holds the previous
build's HTML, so the first navigation asks for `/assets/*-<oldhash>.js` — which the new
server has never heard of. The dynamic import rejects, nothing catches it, and the page
goes blank. `defaultPreload: "intent"` widens the window: a hover is enough to trip it.

Vite raises `vite:preloadError` for exactly this, and its preload helper wraps both the
dependency preloads and the module import itself, so the event fires before the router
ever sees the rejection. A full page load is the entire fix — the fresh HTML names the
new chunks. Navigating to `latestLocation` rather than reloading in place means a click
that tripped this still lands on the page the user asked for.

Deliberately not `preventDefault()`: that suppresses Vite's rethrow and resolves the
import with `undefined`, handing the router a broken module on the way out. A
sessionStorage stamp keeps a genuinely broken deploy from spinning in a reload loop, and
its read is wrapped because storage can be blocked outright.

Verified on the built docs site under Playwright, serving 404s for the chunks an open tab
asks for: without the handler the page collapses to 32 characters and no <main>, with it
the tab reloads onto /docs and renders in full. Re-verified after the Vite 8 bump.
2026-08-28 01:24:05 +02:00
enricobuehler 93f0739160 The new cap constant is a wire bit, not a knob, so it joins the baseline
ci / bun-nix (pull_request) Successful in 1m1s
ci / docs-drift (pull_request) Successful in 54s
ci / docs-site (pull_request) Successful in 1m48s
ci / web (pull_request) Successful in 1m59s
apple / swift (pull_request) Successful in 2m9s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m48s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 4m0s
android / android (pull_request) Successful in 6m43s
ci / rust (pull_request) Successful in 9m4s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 7m56s
`check-docs-drift.sh` matches on the `PUNKTFUNK_*` shape, which cannot tell an
environment variable from a cbindgen-exported ABI constant — so exporting
`PUNKTFUNK_CLIENT_CAP_KEEP_HOST_AUDIO` read as a new undocumented knob and
turned the job red. Every sibling is already listed for exactly this reason:
the four other `CLIENT_CAP_*`, all eight `HOST_CAP_*`, all eight
`VIDEO_CAP_*`. This one goes beside them, in sort order.

It is not documentation debt being waved through. The bit's user-facing half
is the "Keep host audio playing" row, already written up in client-settings.md
and pointed at from the `PUNKTFUNK_AUDIO_OUTPUT_MODE` table; the embedder's
half is the doc comment cbindgen carries into the header. There is no
configuration surface here to document, because there is no variable.
2026-08-28 01:12:45 +02:00
enricobuehler 59bdef5230 The host-audio switch reaches every client, not just the desktop ones
ci / bun-nix (pull_request) Successful in 30s
ci / web (pull_request) Successful in 1m15s
ci / docs-drift (pull_request) Failing after 1m17s
ci / docs-site (pull_request) Successful in 1m44s
apple / swift (pull_request) Successful in 2m13s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m57s
android / android (pull_request) Canceled after 3m16s
ci / rust (pull_request) Canceled after 3m1s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Canceled after 3m13s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Canceled after 0s
`keep_host_audio` shipped in 0.32.0 wired end to end — the setting, the
`CLIENT_CAP_KEEP_HOST_AUDIO` ask, the host's capture policy — but only three
surfaces ever offered it: the Linux GTK client, the Windows client, and the
desktop console. Apple and Android carried no reference to it at all, so the
one place a phone or an Apple TV would look for "why did my PC go quiet" had
nothing to find. There is no shared settings schema; each client hand-mirrors
`trust::Settings`, and this row simply never got mirrored.

Apple gains the full tier-P treatment: a `keepHostAudio` defaults key, the
`EffectiveSettings` field, the `StreamProfile` overlay (so a per-host profile
can keep the study PC's headphones live while the TV profile mutes them), the
Audio-section row, and the bit itself OR'd into the connect's `clientCaps`
beside the presenter caps. Android gains the same across `Settings`,
`Profiles` — including the `KNOWN` key set, without which the key reads as
unknown carry-through — the settings row, the console JSON both ways, and a
new `keepHostAudio` argument threaded through `nativeConnect` into the JNI
caps expression.

`pf-console-ui` had parked `RowId::KeepHostAudio` in `desktop_only` with a
comment saying "until the Android session advertises the bit". It does now, so
the gate comes off and the order-sensitive split test loses its entry.

The C ABI never exported the constant: `client_caps` is a passthrough byte, so
nothing needed a version bump, but cbindgen had no rename entry and emitted a
bare `CLIENT_CAP_KEEP_HOST_AUDIO` into every embedder's namespace — the exact
pollution R21 exists to prevent. It now carries the `PUNKTFUNK_` prefix its
`AUDIO_HIRES` sibling has, plus the compile-time drift assert.

Captions are the Linux client's wording verbatim, and the row sits between
audio quality and the microphone on all four clients, because a setting that
reads differently per platform is the same bug in a smaller font. The docs
line claiming "Linux, Windows and the desktop console" now says everywhere.

Verified: swift build + 384 Apple tests; `:app:compileDebugKotlin`;
`:kit:cargoNdkClippy` over pf-console-ui and the Android native crate; and all
205 pf-console-ui tests on Linux in a container, the split test among them.
2026-08-28 00:52:15 +02:00
enricobuehler 1391de87d6 Merge pull request 'A prep entry stops naming a device, and the docs show it' (#422) from worktree-prep-mode-env-example into main
ci / bun-nix (push) Successful in 36s
ci / docs-drift (push) Successful in 35s
ci / web (push) Successful in 1m5s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 16s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 23s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 33s
ci / docs-site (push) Successful in 1m47s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 38s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 34s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 26s
ci / rust-arm64 (push) Successful in 2m24s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 35s
docker / builders-arm64cross (push) Successful in 18s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m4s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m19s
docker / deploy-docs (push) Successful in 45s
ci / rust (push) Successful in 8m13s
Reviewed-on: #422
2026-08-27 22:50:03 +00:00
enricobuehler 3b0e880b4c docs(automation): a worked example for the prep mode env
ci / docs-drift (pull_request) Successful in 28s
ci / bun-nix (pull_request) Successful in 30s
ci / docs-site (pull_request) Successful in 1m3s
ci / web (pull_request) Successful in 1m5s
ci / rust-arm64 (pull_request) Successful in 1m58s
ci / rust (pull_request) Successful in 7m36s
v0.32.0 gave per-app prep do/undo steps PF_STREAM_WIDTH/_HEIGHT/_REFRESH/_HDR
(hooks::prep_mode_env, both serving planes), but the docs' only example was a
one-liner. Add the entry+script pair that shows the point: the prep entry stops
naming a device and reads the negotiated mode instead - an FPS cap at the
client's own refresh, an HDR arm taken only when the session negotiated it, and
the raster for whatever wants pixels.

Written so the traps are visible rather than explained: undo sees the values its
do saw, HDR is 1/0 (the marker file's spelling) not PF_EVENT_*'s true/false, the
app identity is PF_APP_ID or PF_APP_TITLE by plane, and set -u makes an older
host fail the step loudly instead of capping at 'fps_limit='.

Verified: the fenced script extracted from the doc and run under sh - do writes
the cap and takes the HDR arm, undo restores and reverses it, an SDR session
skips HDR, an empty identity falls through to the literal, and an unset
environment exits non-zero.
2026-08-28 00:45:17 +02:00
enricobuehler 7a6b930867 Merge pull request 'The 0.32.0 security wording says what actually happened' (#421) from worktree-release-notes-severity into main
ci / web (push) Successful in 1m7s
ci / docs-drift (push) Successful in 26s
ci / rust-arm64 (push) Successful in 2m33s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 14s
ci / docs-site (push) Successful in 2m42s
ci / bun-nix (push) Successful in 2m1s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 24s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 41s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 15s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 16s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 26s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 16s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 36s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 41s
docker / builders-arm64cross (push) Successful in 35s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m39s
docker / deploy-docs (push) Successful in 1m11s
ci / rust (push) Successful in 7m46s
Reviewed-on: #421
2026-08-27 21:24:50 +00:00
enricobuehler ababeafce3 docs(release): scope the 0.32.0 security wording to what it actually was
ci / bun-nix (pull_request) Successful in 36s
ci / docs-site (pull_request) Successful in 1m9s
ci / web (pull_request) Successful in 1m44s
ci / rust-arm64 (pull_request) Successful in 2m14s
ci / docs-drift (pull_request) Successful in 2m15s
ci / rust (pull_request) Successful in 8m42s
Two overstatements in the v0.32.0 notes, both flagged on reading the published
release.

**"anyone who could open your web console page"** was simply wrong, and it is the
sentence the Discord embed leads with. Reaching the pairing routes required an
ALREADY-AUTHENTICATED console session — it was privilege escalation inside the
console (those routes alone among the trust-root actions never re-asked for the
console password), not something an unauthenticated peer on the network could
touch. The notes now say so plainly, and say why it still mattered: pairing is
what grants launch, so a console sign-in became the ability to start a process on
the host.

**"58 security issues"** invites the reading that there were 58 exploitable
defects. There were 58 findings, and the long tail is minor hardening and
build-infrastructure tightening. Both documents now lead with that proportion
instead of the raw count.

Same correction applied to CHANGELOG.md in three places: the section intro, the
`Breaking changes` entry, and the security-review heading.

Nothing about the fix itself changed — only the description of its severity. The
tag does not need re-pointing: `announce.yml` checks out the ref it is dispatched
on rather than the tag, and `apply_release_notes` re-syncs the body from
`docs/releases/<tag>.md` there, so this lands in the announcement. The live
release body is PATCHed to match in the same pass.

Gates: the ISO 24495 auditor is back to its 2 standing findings (NVIDIA and AMD,
company names deliberately left unexpanded); check-docs-links.sh and
check-docs-drift.sh both exit 0.
2026-08-27 23:22:27 +02:00
enricobuehler ded8586123 Merge pull request '0.32.0 — the bitrate becomes the wire budget, and the connect ladder ends' (#420) from worktree-release-0320-prep into main
audit / docs-site-audit (push) Successful in 24s
audit / bun-audit (plugin-kit) (push) Successful in 30s
audit / bun-audit (sdk) (push) Successful in 26s
audit / pnpm-audit (push) Successful in 22s
audit / bun-audit (web) (push) Successful in 38s
audit / cargo-audit (push) Successful in 48s
ci / rust-arm64 (push) Successful in 3m15s
audit / license-gate (push) Successful in 6m1s
audit / c-abi-asan (push) Successful in 6m5s
ci / web (push) Successful in 2m2s
ci / bun-nix (push) Successful in 25s
ci / docs-drift (push) Successful in 35s
ci / docs-site (push) Successful in 1m5s
audit / miri (push) Successful in 8m16s
ci / rust (push) Successful in 9m50s
nix / flake (push) Successful in 21m12s
deb / build-publish-gamescope (push) Successful in 38s
android-screenshots / screenshots (push) Successful in 1m39s
decky / build-publish (push) Successful in 56s
deb / build-publish-client-arm64 (push) Successful in 1m47s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 17s
apple / swift (push) Successful in 2m10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 16s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 19s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 15s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 17s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 19s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 28s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m1s
deb / build-publish (push) Successful in 3m54s
deb / build-publish-host (push) Successful in 4m52s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m45s
sbom / sbom (push) Successful in 40s
docker / builders-arm64cross (push) Successful in 15s
docker / deploy-docs (push) Successful in 34s
linux-client-screenshots / screenshots (push) Successful in 5m24s
deb / smoke-install (push) Successful in 2m58s
flatpak / build-publish (push) Successful in 7m39s
arch / build-publish (push) Successful in 11m18s
android / android (push) Successful in 12m4s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 8m29s
web-screenshots / screenshots (push) Successful in 6m15s
apple / distribute (push) Successful in 13m17s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m57s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m56s
apple / screenshots (push) Successful in 9m48s
windows-host / package (push) Successful in 12m49s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Successful in 1m3s
Reviewed-on: #420
2026-08-27 20:29:11 +00:00
enricobuehler 07af2f9e97 docs(changelog): record the nested-cargo doctest breakage in the CI section
ci / bun-nix (pull_request) Successful in 24s
ci / docs-drift (pull_request) Successful in 33s
ci / docs-site (pull_request) Successful in 1m11s
apple / swift (pull_request) Successful in 2m14s
ci / web (pull_request) Successful in 2m15s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m39s
android / android (pull_request) Successful in 7m31s
nix / flake (pull_request) Successful in 7m32s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 10m36s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 4m12s
ci / rust (pull_request) Successful in 18m45s
2026-08-27 20:55:41 +02:00
enricobuehler 018c00e94b fix(core/c-abi): the harness builds into its own target dir, and stops breaking later doctests
`ci.yml`'s `cargo test --workspace --locked` has been failing at the very last
step for weeks, on a crate the diff never touched:

    Doc-tests pf_capture
    error[E0463]: can't find crate for `pf_frame`
      --> crates/pf-capture/src/lib.rs:11:5

`pf-frame` is an unconditional dependency of `pf-capture`, the rustdoc command
line carries `--extern pf_frame=<path>`, and Format, both Clippy legs and Build
had all compiled `pf-capture` seconds earlier. The path simply no longer existed
by the time rustdoc opened it.

`tests/c_abi.rs::ensure_staticlib` is what removed it. It shells out to a NESTED
`cargo build -p punktfunk-core --features quic` while the OUTER `cargo test` is
mid-run. That resolves features for one package instead of the workspace union
the outer run resolved, so cargo rebuilds punktfunk-core's subgraph under
different metadata into the SHARED target directory — and the outer run's
pending units, which name `target/<profile>/deps/*.rlib` by explicit `--extern`
path, are left pointing at artifacts that have been replaced. Doctests run last,
so they are what falls over.

The old comment asserted the opposite, and that is the bug in one line:

    // The outer cargo's build lock is released during test execution, so this is safe.

The released lock is why the nested build RUNS. It was never why it is safe.

Fix: build into `target/c-abi-harness/` and read the staticlib from there. The
nested build can no longer perturb the outer one, and the harness keeps doing
exactly what it did. Unconditional `--features quic` stays — that is `9c164aa6`'s
fix for a featureless `.a` being silently reused, and it is orthogonal.

Reproduced and verified on Ubuntu 26.04 (the CI base — 24.04's PipeWire is too
old to compile pf-capture's lib test), one variable at a time:

  cargo test -p punktfunk-core -p pf-capture --locked --no-fail-fast
    before                                 -> exit 101, E0463 on pf_frame
    before, --skip c_abi_harness_round_trips -> exit 0        (isolates the cause)
    after                                  -> exit 0, harness still PASSes
                                              (abi_version=26, 4 frames round-tripped)

Each from a wiped target dir. `cargo test -p punktfunk-core --test c_abi` also
passes on macOS, where the staticlib path moved.

Why it looked intermittent: whether the doctest step is reached at all depends on
what else in the workspace fails first, and `9c164aa6` made the nested build
unconditional — before that it ran only when the `.a` was missing.
2026-08-27 20:55:18 +02:00
enricobuehler 87cb431437 fix(capture/stall): the classification test's path overshot by one module
ci / bun-nix (pull_request) Successful in 30s
ci / docs-drift (pull_request) Successful in 39s
ci / web (pull_request) Successful in 1m11s
ci / rust-arm64 (pull_request) Successful in 1m32s
ci / docs-site (pull_request) Successful in 1m14s
apple / swift (pull_request) Successful in 2m9s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m1s
ci / rust (pull_request) Failing after 5m54s
android / android (pull_request) Successful in 7m57s
nix / flake (pull_request) Successful in 7m39s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m32s
`super::super::stall::classify` in `idd_push.rs`'s test module resolves to
`windows::stall`, which does not exist — `mod stall;` is declared at
`idd_push.rs:332`, so `stall` is a child of `idd_push`, and the test module is
its sibling. A nested `fn` does not add a module level for path resolution,
which is what the extra `super::` was reaching for. The `use
super::stall::{ProbeWindow, StallClass, StallVerdict}` two lines above, in the
same test fn, already had the depth right.

Windows-only and test-only, so it reached main behind a green Build: the error
appears solely under `--all-targets`, when the lib TEST target is compiled.
`package`'s "Clippy (host + tray, Windows)" step has failed on every push since
`11217341` landed it (#413), which is the same shape as #408 — a Windows break
the Mac and Linux dev loops cannot see. The commit that introduced it verified
the logic by extracting `stall.rs` into a native scratch crate, so this test was
never compiled for Windows before it merged.

Reproduced and fixed with `scripts/xcheck.sh`, which covers exactly this and
takes seconds on the Mac:

  scripts/xcheck.sh windows clippy   # broken: exit 101, error[E0433] at :2528
  scripts/xcheck.sh windows clippy   # fixed:  exit 0
  scripts/xcheck.sh linux   clippy   # exit 0

Rides in the 0.32.0 release branch because it blocks the Windows installer: the
`package` job fails before "Pack + sign installer", so a tag cut over this main
would publish a release with no Windows host attached.
2026-08-27 19:39:40 +02:00
enricobuehler 92de6a8ce3 chore(release): bump workspace version to 0.32.0
ci / bun-nix (pull_request) Successful in 35s
ci / docs-drift (pull_request) Successful in 39s
ci / web (pull_request) Successful in 1m15s
ci / docs-site (pull_request) Successful in 1m38s
apple / swift (pull_request) Successful in 2m12s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m59s
ci / rust-arm64 (pull_request) Successful in 4m11s
android / android (pull_request) Canceled after 6m3s
ci / rust (pull_request) Canceled after 6m10s
nix / flake (pull_request) Canceled after 5m51s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Canceled after 3m18s
90 commits since v0.31.4 (63 non-merge), cut at db0f4f1e.

**The C ABI steps 25 -> 26.** `punktfunk_connect_opts` closes the
`punktfunk_connect_ex*` ladder: eleven generations each added a field by minting
a new exported symbol plus a 20-something-parameter forwarding shim, and the
replacement takes every option in ONE size-prefixed `PunktfunkConnectOpts`.
Every `ex` keeps its symbol and behaviour, so this is an added surface, not a
changed one. Layout is locked padding-free on both pointer widths (96/68 B) by
const asserts AND by the C harness compiling the same sizes.

The minor bump was already right before the ABI moved: ABR Phase 4 redefines
every control-plane bitrate as the TOTAL WIRE BUDGET rather than the encoder
rate, deliberately without a capability handshake. `include/punktfunk_core.h`
documents the 10-bit SDR ask as "0.32" and says "every pre-0.32 client sets the
two bits together", so the tree had pre-decided the number.

Version table, re-derived at HEAD rather than read off the diff:
  * wire 2 -- unchanged. Three additive, ignorable additions: USER_FLAG_REPEAT,
    CLIENT_CAP_KEEP_HOST_AUDIO, and Welcome::host_caps2 (trailing, absent -> 0)
    carrying HOST_CAP2_REPEAT_MARK
  * C ABI 25 -> 26 (above)
  * crate dirs 27, 39 members, edition 2024, MSRV 1.85 -- unchanged
  * driver proto 6, gamepad channel 3, plugin index 1, host event schema 1,
    gamescope +pfhdr8, SDK 0.1.6 -- all unchanged; pf-driver-proto and
    punktfunk-host/src/events.rs show no diff at all
  * api/openapi.json 0.31.4 -> 0.32.0, content-identical (info.version is the
    whole diff); both copies re-stamped and byte-identical to each other
  * @punktfunk/plugin-kit stays 0.4.4 ON PURPOSE: plugin-kit/src/wire.ts changes
    exactly one line and it is a doc comment in the launch.kind table.
    `launcher_ui` is a bare string validated host-side, so nothing to republish.

The CHANGELOG's `heroic-console` entry was filed under v0.31.4, but ae13b29a
landed AFTER that tag -- so that frozen section described a change v0.31.4 does
not contain. Moved into the v0.32.0 section verbatim.

Seven behavioural breaking changes, none of them a version step, all listed in
CHANGELOG.md: the wire-budget redefinition; FLOOR_KBPS 5000 -> 2000; DSCP
defaults to AUTO (local peers only); GameStream video AND control encryption
default on (both graduated after their own on-glass pass on .173, and
`PUNKTFUNK_GS_ENCRYPT=video` is the new middle rung); PyroWave forces Automatic
bitrate; the console pairing routes move behind the console password (the
security review's critical finding -- a console session cookie alone reached
code execution); deep links auto-dial by stable record id only.

Release notes rewritten to the ISO 24495-1/-5 plain-language rules: sentences
under 30 words, paragraphs under 6 sentences, bold bullet lead-ins, an explicit
overview, a table of contents, and acronyms defined on first use. The plugin's
own text auditor reports 0 findings on the Play notes and 2 on the release
notes, both of which are the words NVIDIA and AMD -- company names, not
acronyms, deliberately left unexpanded.

`cargo audit` stays GREEN: h2 0.4.18 -> 0.4.19 closed RUSTSEC-2026-0258, left
open deliberately at the v0.31.1 cut. Only two allowed `unmaintained` warnings
remain (audiopus_sys, paste).

The Play whatsnew leads with the Android black screen, which is the one fault a
phone or TV user in this cycle could not work around.

Gates on the cut (the Mac): cargo fmt --all --check; cargo metadata --offline;
lock diff versions-only 36/36; cargo test -p punktfunk-core --lib --features
quic 503 passed / 0 failed; the C ABI harness PASSED (abi_version=26); the
regenerated header is not stale; check-docs-drift.sh; check-docs-links.sh; the
Play notes gate 412/500 chars and unique against 15 files; both openapi copies
cmp identical; repo voice scan clean; cargo audit green.
2026-08-27 19:28:32 +02:00
enricobuehler db0f4f1ef8 Merge pull request 'A decoder that refuses to start no longer takes the picture with it' (#419) from worktree-android-decode-start-fallback into main
ci / web (push) Successful in 2m3s
ci / rust-arm64 (push) Successful in 2m23s
ci / bun-nix (push) Successful in 31s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 18s
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 12s
ci / docs-site (push) Successful in 2m50s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 17s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 12s
ci / docs-drift (push) Successful in 2m5s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 35s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 26s
docker / builders-arm64cross (push) Successful in 12s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m31s
ci / rust (push) Failing after 8m10s
docker / deploy-docs (push) Successful in 47s
android / android (push) Successful in 11m38s
Reviewed-on: #419
2026-08-27 17:05:19 +00:00
enricobuehler 7c0faeebc8 Merge pull request 'A truncated access unit stops aborting the decoder, and a pad's OS identity stops being the client's own numbering' (#418) from worktree-avi-high-fixes into main
ci / bun-nix (push) Successful in 21s
ci / docs-drift (push) Successful in 1m15s
ci / rust-arm64 (push) Successful in 2m35s
deb / build-publish-gamescope (push) Successful in 1m11s
apple / swift (push) Successful in 2m10s
ci / web (push) Successful in 1m19s
deb / build-publish-client-arm64 (push) Successful in 2m10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 18s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 1m27s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 16s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 17s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 46s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m22s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m34s
android / android (push) Canceled after 8m20s
ci / rust (push) Canceled after 6m43s
ci / docs-site (push) Canceled after 7m43s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
arch / build-publish (push) Successful in 9m0s
flatpak / build-publish (push) Successful in 5m37s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 8m12s
deb / build-publish (push) Successful in 6m8s
deb / build-publish-host (push) Successful in 6m35s
apple / distribute (push) Successful in 11m49s
apple / screenshots (push) Successful in 9m54s
deb / smoke-install (push) Successful in 4m19s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m59s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 23m35s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 24m9s
windows-host / package (push) Failing after 7m14s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
Reviewed-on: #418
2026-08-27 16:56:39 +00:00
enricobuehler c3e3333c40 fix(client/android): keep ASC alive when the overlay allocation is what start chokes on
android / android (pull_request) Successful in 6m37s
ci / bun-nix (pull_request) Successful in 32s
ci / web (pull_request) Successful in 1m37s
ci / rust-arm64 (pull_request) Successful in 4m27s
ci / docs-drift (pull_request) Successful in 22s
ci / docs-site (pull_request) Successful in 1m26s
ci / rust (pull_request) Failing after 7m10s
Why `configure()` passes and `start()` dies with an AImageReader output on the Mi
TV Stick class of device: configure only records the window — start is where ACodec
dequeues (and thereby gralloc-allocates) every codec output buffer from it, with a
usage that is the OR of our reader's consumer bits and the vendor decoder's private
producer bits. Our reader asked for GPU_SAMPLED_IMAGE | COMPOSER_OVERLAY; the
Amlogic OMX component adds its contiguous-vdec-memory flags. On phones (Codec2,
64-bit, gralloc4) that triple is routine. On a 32-bit OMX-era BSP, an app-side
consumer demanding overlay-scanout + GPU-sampled + vdec-writable in a single
allocation is the exotic ask, and a refusal surfaces exactly as
`start failed: ErrorUnknown` after a clean configure.

So the ladder gets a middle rung that keeps ASC instead of abandoning it: retry
with a reader asking for GPU_SAMPLED_IMAGE alone — the SurfaceTexture shape every
TextureView/WebView video path exercises, the most universally allocatable there
is. SurfaceFlinger then GPU-composites the layer (one 1080p quad — noise), and
everything that makes ASC worth having survives: real latch times, real release
fences, the learned panel period. setBuffer has no overlay requirement, so the
only cost is losing the HWC direct-scanout optimization on devices that were never
going to grant it anyway.

Usage is the only reader axis worth a rung: READER_MAX_IMAGES is not a start-time
factor (consumer-side images allocate lazily during streaming), so a start failure
that survives the gpu-only rung genuinely needs the SurfaceView rungs behind it.

The ladder is now: ASC overlay → ASC gpu-only → SurfaceView (aggressive keys) →
SurfaceView (plain keys), deduped as before so a device that works pays nothing
and each log line names the exact configuration that won or was refused. The
"asc: backend up" line now carries the reader profile too.
2026-08-27 18:56:00 +02:00
enricobuehler 2d0a4270b4 Merge pull request 'The control channel stops reusing a nonce, and the media ports stop trusting whoever knocks first' (#417) from worktree-gamestream-security into main
ci / rust-arm64 (push) Successful in 2m28s
ci / web (push) Successful in 2m13s
ci / docs-site (push) Successful in 1m38s
ci / bun-nix (push) Successful in 30s
ci / docs-drift (push) Successful in 29s
deb / build-publish-gamescope (push) Successful in 52s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 14s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 29s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
deb / build-publish-client-arm64 (push) Successful in 2m1s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 23s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 22s
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
ci / rust (push) Failing after 7m36s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m8s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m40s
docker / builders-arm64cross (push) Successful in 19s
deb / build-publish (push) Successful in 6m10s
android / android (push) Canceled after 10m2s
arch / build-publish (push) Canceled after 1m19s
docker / deploy-docs (push) Successful in 44s
deb / build-publish-host (push) Canceled after 6m59s
deb / smoke-install (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 2m54s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 3m48s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-host / package (push) Failing after 9m21s
Reviewed-on: #417
2026-08-27 16:44:40 +00:00
enricobuehler 6ef7230ace fix(client/android): a decoder that refused to start took the picture with it
`configure()` succeeding says nothing about `start()` — start is where the codec
negotiates buffers with its output consumer and allocates them, so a decoder that
accepted the format can still refuse the surface it has to render into.

On a Xiaomi Mi TV Stick (Android 11, armeabi-v7a, OMX.amlogic.hevc.decoder.awesome2)
every session logged `start failed: ErrorUnknown` and the decode thread returned, so
not one access unit was ever fed while the pump kept receiving video. The frame queue
filled, the pump jumped to live once per FLUSH_COOLDOWN, and the host read that
perfect 2 s keyframe cadence as a client too slow to sustain the stream. Audio, input
and the library all kept working, so it presented as a permanent black screen with
sound — nine sessions of it in one log, and nothing on the host able to see why.

Give the async loop a bring-up ladder instead of one attempt. A codec that failed
start is in an error state and cannot be reconfigured, so each rung builds a fresh
one and sheds what a start can choke on, most-suspect first: the AImageReader the ASC
presenter renders into (READER_MAX_IMAGES full-resolution PRIVATE COMPOSER_OVERLAY
buffers, which the SurfaceView path does not allocate at all), then the aggressive
low-latency key set. Every downstream branch already keys off `asc.is_some()`, so a
fallen-back session simply runs the SurfaceView presenter that has always been the
API < 29 / ASC-init-failure fallback. Rung 0 is always exactly what the session asked
for, so a device that works pays nothing; the winning rung is logged, so the next
device that needs one names its own culprit instead of leaving us to guess.

The sync loop gets no ladder: it only runs with low-latency mode off, which is
already the conservative key set, and it renders straight into the SurfaceView — both
axes are already shed, and there is no simpler configuration to fall back to. It gets
the diagnosis in its error line instead, since the session stays up around the
failure there too.

Also lifts the HDR static-info fetch above the ladder so a retry never pays its
250 ms wait again, and extracts the async-notify callback registration, which each
rung's fresh codec now needs.
2026-08-27 18:44:37 +02:00
enricobuehler 29bfbcb950 Merge pull request 'A stored bundle is a success — Android stops reporting 201 as a failed log upload' (#416) from worktree-android-sendlogs-201 into main
android / android (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
ci / docs-drift (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
Reviewed-on: #416
2026-08-27 16:43:34 +00:00
enricobuehler f4f318b8f4 Merge pull request 'Five places where one fact lived twice now state it once (maintainability sweep)' (#415) from worktree-refactor-sweep into main
android / android (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
ci / docs-drift (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
arch / build-publish (push) Canceled after 30s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-gamescope (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
deb / smoke-install (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 10s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
apple / swift (push) Successful in 2m11s
flatpak / build-publish (push) Successful in 5m11s
apple / distribute (push) Canceled after 10m26s
apple / screenshots (push) Canceled after 0s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 3m11s
Reviewed-on: #415
2026-08-27 16:42:52 +00:00
enricobuehler ec621882f0 fix(android): a stored bundle is a success — 201 stops reading as a failure
ci / web (pull_request) Successful in 1m10s
ci / docs-site (pull_request) Successful in 1m13s
ci / bun-nix (pull_request) Successful in 1m15s
ci / docs-drift (pull_request) Successful in 1m16s
ci / rust-arm64 (pull_request) Successful in 1m33s
android / android (pull_request) Successful in 5m57s
ci / rust (pull_request) Successful in 9m56s
Field reports of "201" when sending logs to the host: the upload was working
the whole time, and the client was calling it an error.

`POST /api/v1/client-logs` answers **201 Created** — it is a route that STORES
a bundle, and it has said so since the feature landed (`mgmt/client_logs.rs`,
where CREATED is both the returned status and the documented one). The Android
uploader tested `resp.code == 200` and treated everything else as a failure, so
the user got "Couldn't send logs — host answered HTTP 201" while their bundle
was already sitting on the host's web console Logs page. Worse than a cosmetic
lie: it invites a retry, and the store keeps only 5 bundles per device, so a
few retries evict the reporter's own earlier logs.

The check now uses OkHttp's `isSuccessful` (200..299) rather than a
hand-written code, so there is no second list of numbers to fall out of sync
with the host.

One line covers both Android shells: since the send-logs work spread the action
to every UI, the Skia console and the touch home both route through
`SendLogs.toHost`, which is the only place the status is judged.

The other legs were already correct and are untouched: Apple matches
`case 200, 201` (`LibraryClient.swift`), and the desktop ureq path treats any
2xx as `Ok` — only 4xx/5xx become `Error::StatusCode`. A sweep of the remaining
strict-200 comparisons in the clients found only GET art/status fetches, where
200 really is the sole success.

Verified with `./gradlew :app:compileDebugKotlin` (task executed, not cached).
2026-08-27 18:33:44 +02:00
enricobuehler 86fbd8121b fix(host/input): a pad's OS identity is host-wide, not the client's wire index
ci / bun-nix (pull_request) Successful in 58s
ci / web (pull_request) Successful in 1m31s
ci / docs-drift (pull_request) Successful in 33s
apple / swift (pull_request) Successful in 2m11s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 2m30s
ci / rust-arm64 (pull_request) Successful in 3m11s
android / android (pull_request) Successful in 6m31s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m50s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m6s
ci / rust (pull_request) Successful in 21m38s
Every OS-level name a virtual pad needs is derived from a pad index and nothing
else: the `Global\pfxusb-boot-<i>` / `Global\pfds-boot-<i>` bootstrap mailboxes, the
`SwDeviceCreate` instance ids (`pf_xusb_<i>`, `pf_pad_<i>`, `pf_ds4_<i>`,
`pf_xbox_<i>`), and on Linux the DualSense pairing MAC, the Deck serial and the
Switch MAC — the last three documented as needing to be unique per pad, because
hid-playstation adopts the MAC as the HID `uniq` and SDL/Steam dedup by that serial.

The host serves up to DEFAULT_MAX_CONCURRENT sessions of the same desktop, each with
its own input thread and its own router, and every client numbers its first
controller wire pad 0. So two paired clients each holding a controller collide on all
of them. On Windows the second session's `Shm::create_named` sees ERROR_ALREADY_EXISTS
on all five retries and never gets a pad for the whole session — and the create-failure
hint tells the operator to restart the service, which would kill both sessions, when no
other process is involved at all. On Linux nothing errors: both mint the same DualSense
MAC and SDL merges the two pads into one controller.

The wire index is a session's own numbering and cannot be an OS identity. New
`pf_inject::pad_pool` makes the OS slot host-wide — claimed on a pad's first present
frame, released when it goes away, freed wholesale when the session drops — and `Pads`
translates once on the way in. Because only the NUMBER changes and not the name format,
the drivers (which read the index back out of `pszDeviceLocation`) need no change.

Slots are claimed lazily rather than as fixed per-session windows, so a single session
still reaches all MAX_PADS pads; two sessions share the range. An exhausted host now
declines with an honest line instead of retrying against a name it can never win.

Feedback reverses the same map: a backend tags rumble and rich HID output with the OS
slot it created the device under, so `Pads::pump` maps it back to the client's wire
index — otherwise the fix would have delivered one client's rumble to another's pad.
`HidOutput::pad`/`with_pad` keep that translation in one place, so a seventh variant
cannot silently forget it.

Verified: pf-inject 40 tests (8 new, covering the collision, single-session reach,
release, drop, exhaustion, mask and reverse-map), punktfunk-core 502 with `--features
quic`, and `cargo check -p punktfunk-host` clean on linux/amd64 in punktfunk-rust-ci —
punktfunk-host does not build on macOS, so that check is the only compile signal for
this file and it was not skipped.
2026-08-27 18:30:07 +02:00
enricobuehler 6092f80df2 The house DRM-node knob is parsed once, in the crate that owns GPU choice
ci / bun-nix (pull_request) Successful in 28s
ci / docs-drift (pull_request) Successful in 35s
ci / web (pull_request) Successful in 1m43s
ci / docs-site (pull_request) Successful in 1m42s
ci / rust-arm64 (pull_request) Successful in 1m48s
apple / swift (pull_request) Successful in 2m19s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 7m14s
ci / rust (pull_request) Failing after 7m0s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 6m55s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Failing after 8m25s
PUNKTFUNK_RENDER_NODE had three independent readers disagreeing on trim
and empty-string handling (pf-gpu: no trim; pyrowave: trim+filter; the
remote worker handshake: raw .ok(), so an empty or padded value crossed
the process boundary verbatim). pf_gpu::render_node_env() is now the one
parse; linux_render_node layers the console's manual GPU preference on
top, and the PyroWave sites keep reading the env-only form deliberately —
their device-selection oracle rules forbid the preference layer.
2026-08-27 18:30:07 +02:00
enricobuehler 92f80bdfde Bring-up and the compositor retarget derive the cursor-composite pair once
The two sites had drifted — the class of defect virtual_stream's size
invites. Bring-up keyed gamescope_composite on the compositor alone; the
capture-loss retarget read plan.gamescope_cursor, which also folds in the
gamescope_composites_cursor() capability (a gamescope that paints the
pointer into its own node). On such a node, bring-up planned a host
composite the XFixes reader never feeds. composite_plan() is now THE
derivation for both, settle_portal_cursor's discipline applied to the
pair session_plan.rs already documents as must-agree.
2026-08-27 18:29:58 +02:00
enricobuehler c53dd15313 The control task's 31 positional arguments become a named-field Task
Five mpsc senders and six receivers in a row, two of them both carrying
bare u32s (retarget_rx / gap_rx) — one silent transposition at the spawn
site away from a runtime puzzle. control::run now takes control::Task,
destructured at the top into the same-named locals, so the 500-line body
is byte-for-byte unchanged and the spawn site reads as named fields.
Drops one of the workspace's 127 too_many_arguments allows.
2026-08-27 18:29:44 +02:00
enricobuehler b61566e6c6 serverinfo stops advertising HEVC capacity it may not have
ci / web (pull_request) Successful in 1m18s
ci / bun-nix (pull_request) Successful in 48s
ci / docs-drift (pull_request) Successful in 30s
ci / rust-arm64 (pull_request) Successful in 2m13s
ci / docs-site (pull_request) Successful in 1m29s
android / android (pull_request) Successful in 6m0s
ci / rust (pull_request) Successful in 9m9s
`MaxLumaPixelsHEVC` was the constant `1869449984` in every document, including
the ones whose `ServerCodecModeSupport` had just dropped HEVC — a GPU-less host
encodes H.264 and nothing else, and said so in the mask while still quoting a
4K60 HEVC ceiling two lines above it. The code's own comment flagged the
contradiction and left it, on the grounds that Moonlight gates on the mask.

That reasoning is right, which is exactly why this is worth closing rather than
keeping: the field is harmless only for as long as every client keeps ignoring
it, and nothing on our side guarantees that. It now follows the mask, with `0` —
the field's own way of saying no HEVC capacity — when HEVC is not offered.

Gate: Linux fmt + clippy + 14/14 pacing + 96/96 gamestream; Windows `.133`
clippy on the full matrix + 14/14 + 95/95.
2026-08-27 18:26:20 +02:00
enricobuehler d3616aaef9 Four security questions get answers instead of follow-up notes
None of these change behaviour. All four were carrying comments that read as
unfinished work, and the work turned out to be establishing what is true.

**TLS accepts any client certificate — and should.** The verifier's comment
called pinning "a hardening follow-up". It is not one. A TLS handshake completes
before the request line is parsed, so "pin the post-pair routes, accept-any on
the pairing routes" cannot be expressed there at all — it would take a second
listener on a second port, and the protocol fixes the ports. Some HTTPS traffic
must come from unpaired peers anyway: `/serverinfo` answers `PairStatus=0`
precisely so a client can discover it needs to pair, and the management API goes
further and admits certless browsers. And deferring costs nothing: the
`CertificateVerify` signature IS checked, so a peer reaching a handler has proved
possession of its certificate's private key, and `peer_is_paired` pins the
SHA-256 of that same certificate before any state-changing work. Every route but
`/serverinfo` goes through it. Rejecting with an HTTP error rather than a TLS
alert is a difference in *when*, not in what is proven. The comment now says
that, including the thing that WOULD be a hole — accepting the certificate
without checking the signature, which is why the legacy-Moonlight fallback
re-verifies rather than waving old certificates through.

**The PIN's 4-digit space is not the exposure.** 10⁴ invites the brute-force
question, and the answer is that nothing can brute-force it: submission is the
bearer-authenticated management API and nowhere else, so a network peer has no
oracle to hammer, and a per-attempt cap would bound the operator's typos rather
than an attacker. A wrong PIN costs a fresh client handshake *and* a fresh
operator submission — not a loop anyone automates from the network. The real
residual is **capture**, not guessing: the PIN slot is bound to no particular
handshake, so a peer parked at the right moment can take the PIN typed for
someone else. That is already narrowed twice (an ambiguous submit is refused, an
unconsumed PIN expires) and its full fix is to key the gate by `uniqueid` —
which also needs the management API to name *which* device is asking, so the
operator answers a named prompt. Written down so the next reader audits the race
rather than re-deriving the brute-force answer.

**Audio's unauthenticated CBC cannot be negotiated away.** The instinct is to
reach for `SS_ENC_AUDIO` (0x04) as the authenticated answer. Per the sanctioned
wire reference, `SS_ENC_AUDIO` *selects exactly the mode we already implement*:
"if SS_ENC_AUDIO: AES-128-CBC encrypt the PKCS7-padded Opus frame", noted there
as "CBC, not GCM. No auth tag appended (unlike video/control GCM)", negotiated
through `x-nv-general.featureFlags` bit 0x20 rather than the `encryptionSupported`
mask. So GameStream has no authenticated audio mode to advertise: offering the
flag would change nothing on the wire, and adding a tag would be a private
extension no client can decode. The malleability is real and it is
protocol-inherent, in the same family as pairing over plain HTTP. A session that
needs authenticated audio needs the native plane, whose audio is AES-GCM. This
moves the item from "blocked on a layout we could not find" to "answered" — the
layout was not missing, the premise was.

**`encoderCscMode` was warning about something it could not act on.** The note
claimed Moonlight renders from the requested value rather than the bitstream VUI,
so any mismatch shifts colours. The wire reference does not say that — it lists
the key among those a host parses, and nothing more — and we emit an explicit,
correct VUI that a VUI-driven renderer would follow. Worse, the common case is
unactionable by construction: this session's own on-glass run shows a stock
client sending `csc=3` (Rec709 **full**) while negotiating HDR, and HDR10 *is*
BT.2020 PQ, so the request and the mode contradict each other and the mode wins.
The old code warned on every HDR session about that. It now says so at debug, and
keeps a warning only for SDR, where the request could in principle be met —
honestly labelled unverified.

Also recorded: honouring it is not "just plumbing". `videoFullRangeFlag` is
hardcoded to 0 in every encoder backend with the capture-side CSC fixed to match,
so an SDR client asking for full range needs a per-session colour request
threaded from RTSP through the capture CSC into each backend's VUI — code the
native plane shares and currently gets right. That is a real piece of work with a
real regression surface, and it should be scoped as one rather than as a logging
follow-up.

Gate: Linux fmt + clippy + 14/14 pacing + 96/96 gamestream; Windows `.133`
clippy on the full matrix + 14/14 + 95/95.
2026-08-27 18:18:17 +02:00
enricobuehler 9c164aa614 The connect ladder gets a last rung that never mints another symbol (ABI v26)
Eleven generations of punktfunk_connect_ex* each added a field by minting a
new exported symbol plus a 20-something-parameter forwarding shim (ex11 over
ex10: two fields). punktfunk_connect_opts takes every option in ONE
size-prefixed PunktfunkConnectOpts — PunktfunkConfig's struct_size guard,
with the growth direction added: an older, shorter caller gets its tail
defaulted to unspecified/auto instead of misread. Layout is locked
padding-free on both pointer widths (96/68 B) by const asserts AND by the C
harness compiling the same sizes, so an appended field can never land in
bytes an older sizeof already covered. Every ex keeps its symbol and
behaviour; the chain is closed in the embedding guide (whose ladder table
had stalled at ex7 — the maintenance tax on display).

Also: the cargo-test C harness now actually links on an Apple Silicon dev
box (-L/opt/homebrew/lib was missing) and always builds the staticlib WITH
quic — a featureless .a left by an earlier plain build was silently reused.
2026-08-27 18:16:10 +02:00
enricobuehler 559b185247 Three hand-copies of the same env knob parse once (encoder IR/LTR policy)
QSV's copies had already diverged twice in ways the file itself confesses
(the dropped trim() that left LTR enabled on Intel, the IR period knob that
silently did nothing) — and PUNKTFUNK_LTR_FORCE_AT=0 still behaved
differently per vendor. enc/policy.rs now parses each knob once, rfi.rs's
sibling; tuned per-vendor defaults (QSV 1/4 s vs AMF 1/2 s LTR cadence) and
API-bound clamps deliberately stay with their backend.
2026-08-27 18:15:57 +02:00
enricobuehler 5317c583ab fix(bitstream/av1): an over-declared OBU is a parse error, not a decode-thread panic
`obu_size` is a leb128 read straight out of the stream — bounded only by `u32::MAX`,
and tied to nothing about how many bytes are actually present. `read_obu` then built
the OBU with an unchecked `&data[start_offset..start_offset + obu_size]`, so any
access unit whose last OBU declared more payload than remained panicked with
`range end index .. out of range for slice of length ..`. That is a bounds check
rather than arithmetic, so it panics in release too, and it aborts whichever thread
is decoding.

It reaches every native AV1 rung: pf-vkdecode, pf-dxvadec and pf-vaadec are all
re-exports of `pf_bitstream::av1::Av1Planner`, whose `plan_au` hands raw access-unit
bytes straight to this function. `PUNKTFUNK_AU_FAULT=truncate` produces the shape,
and so does any AU delivered short over the wire.

This was a hole in an otherwise consistent posture, not a missing idea: `plan_au`
degrades every other malformation to `TruncatedAu`/`Parse`, and pf-vkdecode already
re-validates `obu.end > au.len()` a layer up. Bound it once, where all three rungs
route through. The checked end is reused for `bytes_used`, so the slice and the
advance can no longer disagree.

Both tests fail without the guard — the parser one reproduces the original panic
verbatim (`range end index 10600 out of range for slice of length 5293`).

Recorded as PROVENANCE.md deviation 14. Not filed upstream.
2026-08-27 18:06:31 +02:00
enricobuehler a79343784c Control encryption is on by default — a real client negotiated it unprompted
The previous commit shipped `SS_ENC_CONTROL_V2` dark, on the grounds that the
wire had not yet met a real client here. It has now, twice, on `.173` (Win11,
RTX 4090) against Moonlight on macOS at 2560x1440@240 HEVC Main10 HDR.

| Leg | Result |
|---|---|
| `control` (SUPPORTED, not requested) | **the client turns it on by itself** — video and audio flow, hardware decode |
| `require` (both bits REQUESTED) | negotiated and streamed the same way; the lever is not broken |

The line that settles it is the host's own scheme detector, which reports what
it actually locked onto rather than what was negotiated:

```
control: GCM scheme locked in
  scheme=Scheme { key_rev: false, nonce: V2 { seq_be: false, marker: [67, 67] },
                  tag_first: true, aad: None }
```

`[67, 67]` is `b"CC"` — the client→host V2 marker — after which the host sealed
its HDR-mode cue under the flipped `b"HC"` and the client acted on it. Both
directions on distinct nonces, on hardware. That is the defect from the previous
commit's note, observed closed rather than argued closed.

So `Supported` — the default — now carries both bits, exactly as WP7's video
encryption graduated after its own glass pass. What used to be the `control`
opt-in becomes `PUNKTFUNK_GS_ENCRYPT=video`, the way *out*: it keeps video
encryption and drops only the control offer. That granularity is worth its three
lines rather than being YAGNI — this plane serves a spread of client builds and
exactly one has been tested against the V2 offer, so the alternative rollback
(`=0`) would throw away video encryption to fix a control-channel problem.

**The deferral that turned out not to exist.** The worry was that control-v2
would drag encrypted RTSP in with it, forcing a `corever` threshold nobody has
the value for. Both legs kept RTSP plaintext — the client asked for
`rtsp://192.168.1.173:48010` and stayed there — which confirms the framing is
chosen by the launch URL scheme, not by the DESCRIBE flag. The sealed-RTSP
reader is in and unit-tested, but it has not been exercised on the wire; it will
be the day a client opens a connection with the type bit set, and it is written
to answer in kind when that happens.

**The other thing glass settled** is the previous commit's open question about
the ping payload's encoding. Both media planes logged `client endpoint learned
(ping payload verified)` on the first datagram, in both legs — so the payload
crosses in a form `ping_matches` accepts, the two-second fallback never fired,
and that check can be tightened from "prefer" to "require" whenever we want it.

Docs follow: `moonlight.md` no longer says the control channel "uses the older
GameStream scheme", which stopped being true for a stock client with this
commit, and `configuration.md` documents the new `video` value.

Gate: Linux fmt + clippy + 14/14 pacing + 96/96 gamestream; Windows `.133`
clippy on the full matrix + 14/14 + 95/95. One `send_pacing` failure on the
first Linux run was a timing flake under load (0.36 s vs the usual 0.06 s) and
passed 14/14 on re-run — the suite is known for it. `.173` was restored
afterwards: original binary back byte-for-byte by size and timestamp, no test
lines in `host.env`, service running, listeners up, and the build tree this
session created removed.
2026-08-27 18:06:16 +02:00
enricobuehler f4a2698711 The control stream stops sharing one nonce space with the client
The legacy GameStream control scheme builds its AES-GCM nonce out of the
sender's own sequence number and nothing else. There is no direction byte, so
the host's messages — rumble, the HDR-mode cue — and the client's input share a
single (key, nonce) space and collide the moment their two independent counters
cross. That is the one catastrophic GCM failure, and `control.rs` has carried a
note saying so, ending "the real fix is V2 control-encryption negotiation".

This is that fix, and most of it turned out to be already written. The scheme
detector has understood `NonceKind::V2` — `seq` in `[0..4]`, `b"CC"` at
`[10..12]` — since it was built, and `encrypt_control` already flips the marker
to `b"HC"` for host-originated messages. What was missing was the one thing that
makes a client choose that scheme: the advertisement. So the control plane needs
no code change at all here; it needs `SS_ENC_CONTROL_V2` in
`encryptionSupported`, and it detects the rest from the first packet that
authenticates.

Enabling V2 also lets the client seal RTSP itself, which is the part that was
deferred, and the reason it was deferred dissolves on inspection. The worry was
needing to know `corever` — the field that decides whether a session speaks
sealed RTSP — whose threshold the sanctioned reference names but does not value,
and guessing it would break every session it guessed wrong about. But the two
framings are **self-distinguishing**: a sealed message opens with
`typeAndLength`, whose MSB is `ENCRYPTED_MESSAGE_TYPE_BIT`, and a plaintext one
opens with an ASCII method name, always below 0x80. So there is nothing to
negotiate and no threshold to guess — the connection is read in whichever
framing it arrived in and answered in the same one, the same "detect it from the
wire" posture the control stream already takes. `corever` never has to be
consulted, which also means it never has to be guessed.

Two details worth their own lines:

**The host's RTSP sequence counter is process-global and never resets.**
GameStream RTSP is one message per TCP connection, so a per-connection counter
would restart at zero for every one of a session's seven messages and reuse
(key, nonce) six times over — reintroducing, in the fix, exactly the defect
being fixed. Same rule WP7 established for the video counter, and same reasoning.

**The frame's length field is bounded before it is believed.** It is
attacker-controlled and arrives before a single byte has authenticated, so it is
checked against the same budget the plaintext path uses rather than trusted
enough to reserve against.

Shipped dark, deliberately: `PUNKTFUNK_GS_ENCRYPT=control` opts in, the default
advertisement is unchanged, and `require` (the on-glass lever) now covers both
bits. This is the posture WP7 shipped video encryption in — dark until glass
confirmed a real client negotiated it — and control-v2 has not yet met a real
client here. Flipping the default is a one-line change once it has.

The two comments claiming we "advertise no encryption" are corrected while I am
here; they have been wrong since WP7 advertised `SS_ENC_VIDEO`, right in their
conclusion and wrong in their reason.

Gate: Linux container fmt + clippy `--all-targets -D warnings` + 14/14 pacing +
96/96 gamestream (93 before); Windows `.133` clippy on the full
`nvenc,amf-qsv,qsv` matrix + 14/14 + 95/95 (92 before). The three new tests cover
the receive path as well as the send path, and were confirmed non-vacuous by
mutating the nonce's direction byte to a constant — both directional assertions
fail, which is the security property doing its job.
2026-08-27 17:49:39 +02:00
enricobuehler eb8117b551 The media ports learn their endpoint from the client that can prove it
Both media planes learned where to send by taking the first UDP datagram that
arrived from the launch owner's address and never looking inside it. The ping
payload the protocol exists to carry — handed to the client in the SETUP
response, echoed back as its first datagram — was the fixed constant
`0011223344556677` for every session on every host, and nothing ever compared
it against what arrived. Source-IP binding was the whole guard, so anything
that could send from (or spoof) that address won the endpoint simply by being
first, and was handed the stream.

So the payload becomes a per-session secret: eight random bytes minted at
`/launch`, re-minted at `/resume`, advertised hex-encoded in SETUP, and checked
before an endpoint is adopted. Both planes now go through one
`learn_client_endpoint`, because they had drifted into two byte-identical loops
and a rule about who owns a stream should exist once.

Minting alone would have proved nothing, though, and this is the part worth
pausing on: SETUP was **unauthenticated** while its siblings ANNOUNCE and PLAY
were not. Any peer that could reach 48010 could simply ask for the payload the
media planes were about to verify, and walk the check. So SETUP is gated on
`authorized_launch` like the other two — a secret handed to whoever asks is not
a secret, and that hole, not the constant, was the reason the old check would
have been theatre.

The check **prefers** a matching datagram rather than **requiring** one, and
that is deliberate rather than timid. The sanctioned wire reference says the
client echoes the payload and that modern clients wrap it in an `SS_PING`
carrying a sequence number, but it gives neither that structure's layout nor
whether the payload crosses as the header's ASCII or as its decoded bytes.
`ping_matches` accepts every shape those unknowns allow — either encoding, with
trailing bytes — yet a hard gate resting on a layout nobody has confirmed would
black-screen every session it guessed wrong about, and compatibility is this
plane's entire reason to exist. An unverified datagram is therefore held as a
fallback, adopted only if two seconds pass with nothing better, and logged with
the bytes that did arrive. One real session settles the encoding; then the
fallback can go and this becomes the hard gate it wants to be.

What this closes: an off-path attacker who cannot read the plaintext RTSP
exchange can no longer guess its way onto a media port. What it does not close:
an on-path observer, who can still read the payload out of SETUP — that one
needs `SS_ENC_CONTROL_V2`, and the two changes are worth more together than
either is alone.

Constant-time comparison throughout (`crypto::ct_eq`), so the expected value
can't be probed a byte at a time.

Gate: Linux container fmt + clippy `--all-targets -D warnings` + 14/14 pacing +
93/93 gamestream (90 before). The three new tests were confirmed non-vacuous by
mutating `ping_matches` to return `true` and watching the negative case fail.
2026-08-27 17:32:54 +02:00
enricobuehler d2286da955 Merge pull request 'The Moonlight plane stops paying for protection it doesn't need, starts listening to the client, and encrypts its video' (#414) from worktree-gamestream-wp1 into main
ci / bun-nix (push) Successful in 27s
ci / docs-drift (push) Successful in 27s
ci / docs-site (push) Successful in 1m10s
ci / web (push) Successful in 1m17s
apple / swift (push) Successful in 2m6s
deb / build-publish-client-arm64 (push) Successful in 1m32s
deb / build-publish-gamescope (push) Successful in 1m48s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 31s
decky / build-publish (push) Successful in 53s
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 14s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 15s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 14s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 20s
deb / build-publish-host (push) Successful in 4m40s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 15s
deb / build-publish (push) Successful in 5m31s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m33s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m33s
docker / builders-arm64cross (push) Successful in 18s
ci / rust (push) Successful in 7m36s
docker / deploy-docs (push) Successful in 44s
arch / build-publish (push) Successful in 9m4s
android / android (push) Successful in 10m11s
deb / smoke-install (push) Successful in 2m42s
ci / rust-arm64 (push) Failing after 11m17s
apple / distribute (push) Successful in 11m18s
windows-host / package (push) Failing after 12m28s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m32s
apple / screenshots (push) Successful in 10m16s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m23s
Reviewed-on: #414
2026-08-27 15:05:12 +00:00
enricobuehler ac14b6554e feat(gamestream): video encryption is on by default — a real Moonlight client negotiates and decodes it
ci / bun-nix (pull_request) Successful in 24s
ci / docs-drift (pull_request) Successful in 32s
ci / docs-site (pull_request) Successful in 1m13s
ci / web (pull_request) Successful in 1m31s
apple / swift (pull_request) Successful in 2m7s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m42s
ci / rust (pull_request) Successful in 7m16s
android / android (pull_request) Successful in 8m11s
Review of 47d9a7d2 plus the on-glass pass it was waiting for, run 2026-08-27 against
.173 (Win11, RTX 4090) from Moonlight on macOS. Four legs, all pass:

  1. encryption off      -> streams, encrypt_video: false (no regression)
  2. =1 (supported)      -> the CLIENT OPTS IN BY ITSELF, decodes, 0 errors
  3. =require            -> negotiated, decoded in hardware, 0 errors
  4. =require + 5% loss  -> 27 s, 8 keyframes decoded, ZERO IDR re-requests

Leg 2 is why the default moves: I had argued a client might only enable video
encryption on WAN/relay links — which is why the `require` lever exists at all — and
it opted in on a LAN unprompted. Default-on therefore delivers encryption in
practice rather than being a no-op offer.

Leg 4 is the one that mattered most: at 5 % injected wire loss the client never asked
for a single keyframe, so FEC recovered every dropped SEALED shard. That is the
FEC-then-encrypt ordering proven on hardware, not just in the unit test.

Two independent confirmations of the wire format fell out of the run:
  * the client negotiated packet_size 1360 where the plaintext leg used 1392 — it
    subtracted sizeof(ENC_VIDEO_HEADER) exactly as the reference says, so the on-wire
    datagram is 1408 either way and still fits the MTU it sized for;
  * FFmpeg parsed nal_unit_type 19 (IDR_W_RADL) and reported 'Decoded frame with POC
    0/0' on a Main 10 VideoToolbox path — a real decoder reading real HEVC out of the
    decrypted, FEC-reassembled payload.

Default is now Supported; PUNKTFUNK_GS_ENCRYPT=0 is the escape hatch back to the
plaintext wire, `require` stays as the test lever that forces the negotiation.

Review fixes in the same change (found reading 47d9a7d2 back, before the test):
- A failed seal pushed the CLEARED buffer, putting a 0-byte datagram on the wire.
  Unreachable in practice (GCM only refuses absurd lengths) but the wrong failure
  mode: the shard is now dropped, which is what FEC is there for. seal_shard returns
  bool and — per clippy, correctly — takes &mut [u8] now that it never clears.
- `off` and `key` derive separately from self.enc_key; they cannot desync today, but
  if they ever did, sealing would encrypt from offset 32 INTO the shard body and
  corrupt every packet with no error anywhere. A debug_assert states the invariant.
- Stale rtsp module header (it still said plaintext-only, P1.5).

Docs follow the behaviour: moonlight.md/clients.md now say video is encrypted, and
the 'legacy control encryption is weaker' caveat is corrected — it was overstating
things now that video and audio are both sealed; what remains true is that pairing is
plain HTTP and the control channel uses the older GameStream scheme.

Gates: Linux container fmt + clippy --all-targets -D warnings (non-vacuous) +
send_pacing 14/14 + gamestream 90/90; Windows .133 clippy nvenc,amf-qsv,qsv clean +
14/14 + 89/89; check-docs-drift.sh + check-docs-links.sh clean. .173 restored to its
original binary and host.env, build tree removed.
2026-08-27 16:48:14 +02:00
enricobuehler 47d9a7d2fa feat(gamestream): video encryption exists on the wire, behind a knob until glass confirms it
GS competitive program WP7 — the largest remaining wire divergence from a Sunshine-class
host, and the last item the docs' "weaker than the native protocol" caveat rested on.

- RTSP DESCRIBE advertises SS_ENC_VIDEO (0x02) as encryptionSupported and NEVER as
  encryptionRequested: requiring encryption would refuse every client that doesn't do
  it. ANNOUNCE's x-ss-general.encryptionEnabled echo decides the session, and is
  honored only for a bit the host actually offered.

- Each finished datagram is AES-128-GCM-sealed under the /launch rikey and shipped as
  [iv 12][frameNumber u32 LE][tag 16] || ciphertext(blocksize) — the ENC_VIDEO_HEADER
  layout, whose 32 bytes sit OUTSIDE the FEC blocksize (the client already subtracted
  them from the packetSize it negotiated, so the datagram still fits its MTU).

- The order is FEC first, THEN encrypt per shard. That is the load-bearing property,
  and it has its own test: the client decrypts what it received and runs RS recovery
  over those plaintexts, so parity computed over ciphertext would recover nothing.
  Sealing is in-place into a buffer that reserved the prefix, so the pooled
  no-allocation path (WP1.3) survives; parity, which encode_into sizes exactly, costs
  one memcpy on the ~20 % of packets that are parity.

- The GCM nonce counter is PROCESS-global and monotonic, never reset. (key, nonce)
  reuse is the one catastrophic GCM failure, and a session-scoped counter would repeat
  the moment a KEYLESS /resume — which WP3 defines as keeping the current keys —
  started a fresh packetizer on the same rikey.

DEFAULT OFF, opt in with PUNKTFUNK_GS_ENCRYPT=1. This is the compat plane's video hot
path and a wire mistake there is a black screen for any client that opts in; I cannot
run a stock Moonlight client in this environment, so it ships dark and the WP0.3
on-glass pass flips the default. SS_ENC_CONTROL_V2 and SS_ENC_AUDIO stay unoffered:
control-v2 also re-frames RTSP itself, and the audio-GCM layout is not in the
sanctioned wire reference.

Also WP8's docs pass, now that the claims are false in the user's favour:
moonlight.md said the GameStream path "doesn't use the native protocol's
FEC/encryption extensions" — Moonlight-compatible FEC has shipped for months and the
host now adapts it to reported loss. Rewrote that (and the clients.md twin) to say
what Moonlight actually does and doesn't get, documented the bitrate as the wire
budget it became in WP2.1, and documented the three new knobs (PUNKTFUNK_FRAME_DRIVEN,
PUNKTFUNK_GS_ADAPT, PUNKTFUNK_GS_ENCRYPT) — check-docs-drift.sh gates that.

⚠ The drift gate also demanded PUNKTFUNK_IDD_ADAPTIVE be pruned from the undocumented
baseline: it is documented in configuration.md but was never pruned, so that ratchet is
red on main independently of this branch. Pruned here since the gate refuses to pass
otherwise.

Gates: Linux container fmt + clippy --all-targets -D warnings (non-vacuous) +
send_pacing 14/14 + gamestream 89/89 (new: encrypted round-trip incl. tamper + wrong
key + IV uniqueness, and RS recovery THROUGH encryption); Windows .133 clippy
nvenc,amf-qsv,qsv clean + 14/14 + 88/88; check-docs-drift.sh and check-docs-links.sh
both clean.
2026-08-27 16:15:20 +02:00
enricobuehler 247832014a feat(gamestream): the compat plane joins the shared session plan, ships the real HDR grade, and stops streaming unprotected stereo
GS competitive program WP5 (the media items implementable without new wire research —
4:4:4 and encoderCscMode stay follow-ups: the 4:4:4 SCM extension bit values are not in
the sanctioned wire reference, and CscMode honor is an encoder-wide colorspace plumb).

- WP5.1 the capture format comes from the shared SessionPlan (gs_session_plan: 4:2:0,
  depth 10 only with HDR, no cursor-forward — this plane has no client cursor channel)
  instead of the hand-hardcoded OutputFormat::resolve(hdr, gpu). One resolver for both
  planes, and the visible win: a gamescope GameStream session (cursor blend off there)
  now resolves nv12_native, so the producer's NV12 feeds Vulkan Video directly and the
  per-frame RGB→NV12 CSC the native plane already skips is skipped here too. Also the
  structural prerequisite for 4:4:4 when its advertisement bits are researched.

- WP5.4 the encoder gets the source's REAL HDR grade every frame (set_hdr_meta from the
  capturer, exactly the native loop's shape): an HDR backend embeds it as in-band
  mastering/CLL SEI on keyframes — the channel a stock Moonlight decoder tone-maps
  from. This plane never called it, so an HDR GameStream session shipped no grade at
  all; the 0x010e control cue only flips the display mode and carries generic fallback
  metadata.

- WP5.5 RS(4,2) audio FEC on every layout — stereo included. The parity math is
  layout-agnostic (shards are opaque encrypted packets) and a stock client runs the
  same fixed RS(4,2) recovery regardless of channel count; the old channels>2 gate was
  bring-up caution that left the MOST COMMON configuration with zero audio loss
  protection.

Gates: Linux container fmt + clippy --all-targets -D warnings + send_pacing 14/14 +
gamestream 87/87; Windows .133 clippy nvenc,amf-qsv,qsv clean + 14/14 + 86/86.
On-glass owed with the WP0.3 pass: gamescope NV12 path on .41, HDR SEI in a client
bitstream dump, stereo FEC under netem loss.
2026-08-27 15:58:47 +02:00
enricobuehler 2031066539 fix(gamestream): the MAC Moonlight wakes is finally the host's own
GS competitive program WP6.1. serverinfo's <mac> was a fake constant
(01:02:03:04:05:06) over HTTPS — and Moonlight PERSISTS that field as the target for
its Wake-on-LAN, so every client-side wake against this host was a silent no-op. It
now carries the real routed-NIC MAC via the same crate::wol::wake_macs the native
plane's mDNS advert uses (routed NIC first, lowercase colon-hex — the wire reference's
documented shape: real MAC on HTTPS, zeros on plain HTTP).

Cached on first SUCCESS only: /serverinfo is polled, so per-poll NIC enumeration is
waste — but a cold-booted host may not have a routable address yet (the #366 boot-race
lesson), so a failed read retries on the next poll instead of latching zeros forever.
Plain HTTP keeps hiding the MAC.

Gates: Linux container fmt + clippy -D warnings + send_pacing 14/14 + gamestream
87/87 (new pin: never the fake MAC, hidden over HTTP); Windows .133 clippy
nvenc,amf-qsv,qsv clean + 14/14 + 86/86.
2026-08-27 15:48:43 +02:00
enricobuehler 31eb91e2b1 feat(gamestream): the host tells its owner the truth, and a resume actually resumes
GS competitive program WP3 (design/gamestream-competitive-program.md). Three fixes
that together make Moonlight's Resume/Quit UX exist against this host for the first
time — serverinfo hard-coded state=FREE/currentgame=0, so no stock client ever showed
Resume or Quit, and /resume read no query params at all, so even a reached resume ran
on stale crypto keys.

- serverinfo now reports the live session's appid + SUNSHINE_SERVER_BUSY — to the
  session OWNER's pinned cert ONLY. Owner-scoped on purpose (owner_current_game, pure
  + tested): a non-owner shown the truth would route same-app taps into the owner-only
  /resume and /cancel and lose the reject/join/steal admission it gets via /launch
  today; and a busy signal over plain HTTP would leak what's running to the LAN.
  Advertisement fails CLOSED on unknown fingerprints (unlike the control gate, which
  deliberately fails open).

- /resume parses rikey/rikeyid and RE-KEYS the live session (a resuming Moonlight
  mints fresh keys; the control GCM and audio CBC derive from them — the old keys made
  every post-resume control packet undecryptable). Malformed rikey refuses the resume;
  keyless keeps the current keys. The media/RTSP source-IP binds re-anchor to the
  address the client resumes from, so a device that moved networks can come back.

- /resume RESTARTS the media planes for the new connection. A PLAY that found
  'streaming' still true took its already-running branch: the old threads kept
  streaming at the vanished endpoint and the resumed client got no media. The handler
  clears the run flags and WAITS (bounded, 2 s) for the old threads' FULL exit — each
  media thread now bumps AppState::media_exited as its last act, after its teardown
  (capturer re-pool, lease guard, events) has run — so the successor threads never
  race the predecessor over the pooled capturer and the old exit path cannot stomp
  the new session's flags. The launch surviving that wait is re-keyed; one cleared by
  the old threads' client-unreachable teardown falls back to a clean /launch. The
  ENet control plane already handles the fast-reconnect half (a new Connect replaces
  the tracked peer; the old peer's late timeout is gated off it).

Gates: Linux container fmt + clippy --all-targets -D warnings (non-vacuous) +
send_pacing 14/14 + gamestream 86/86 (new: owner-scoped currentgame matrix, resume
re-key round-trip incl. malformed/keyless arms, busy-state XML pin); Windows .133
clippy nvenc,amf-qsv,qsv clean + 14/14 + 85/85. On-glass resume with a stock
Moonlight client owed (WP0.3 pass).
2026-08-27 15:45:11 +02:00
enricobuehler 7df36a1b3d feat(gamestream): the host finally listens when the client reports loss
GS competitive program WP2.2-2.4 (design/gamestream-competitive-program.md). The
Moonlight plane's only in-stream quality signal — the periodic 0x0201 loss-stats
report — used to fall through the control decoder and be silently dropped; the host
was blind to client-observed loss and its only recovery was serving IDR round-trips.
No Sunshine-class host does more than log this signal. Now:

- WP2.2 the control stream decodes 0x0201 (LE i32s: loss count, window ms,
  last-good frame — the verified IDX_LOSS_STATS reading, apollo-comparison #94) into
  cumulative per-session counters on AppState (GsLossStats), read as window deltas.

- WP2.3 adaptive FEC: the parity percent climbs fast under loss (+max(5, pct/2) per
  lossy window, cap 50) and decays slowly on clean windows (-5 per 8, floored at the
  configured base). Applied live through the packetizer between frames — safe because
  block geometry is derived per frame and the client reads each block's parity count
  from the per-packet fecInfo wire percent.

- WP2.4 bitrate de-rating: SUSTAINED loss (2+ consecutive lossy windows) de-rates the
  wire budget x0.85 per window, floored at max(1/4 of the negotiated rate, 5 Mbps);
  clean windows climb it back at 1/20 of the negotiated rate per 4 — division of
  labour as on the native plane: parity answers random/burst loss, fewer bits answer
  congestion. The client's negotiated number stays the hard ceiling.

- The WP2.1 invariant is kept live: every step re-derives the ENCODER rate under
  (budget, percent) and retargets it in place (Encoder::reconfigure_bitrate); the send
  pacer's rate follows through an atomic. An encoder that refuses the in-place
  retarget (software paths) turns adaptation OFF for the session — raising parity
  over a frozen encoder rate would push the wire back over budget. The stats sample
  reports the LIVE budget, so the console shows what the host actually targets.

PUNKTFUNK_GS_ADAPT=0 pins both levers at their configured values (the A/B lever).
Constants are deliberately conservative first values — the WP0.3 netem matrix tunes
them. Also: the stale module/status doc headers the completeness inventory flagged.

Gates: Linux container fmt + clippy --all-targets -D warnings (non-vacuous) +
send_pacing 14/14 + gamestream 83/83 (incl. the two new adaptation state-machine
tests); Windows .133 clippy with nvenc,amf-qsv,qsv clean + send_pacing 14/14 +
gamestream 82/82 (amf-qsv,qsv — the box has no NVENC import lib for test linking).
On-glass netem verification (WP0.3) owed.
2026-08-27 15:22:52 +02:00
enricobuehler ab5e642f84 perf(gamestream): the compat plane stops paying for congestion protection it doesn't need
GS competitive program WP0/WP1/WP2.1 (design/gamestream-competitive-program.md in the
planning repo). Four latency/overshoot gaps between the Moonlight-compat plane and the
native plane, all closed by wiring GS into machinery the native plane already had:

- WP1.1 arrival-driven capture: the GS encode loop slept out its whole tick and then
  SAMPLED, holding a frame that arrived just after the previous sample for up to a full
  interval (~half on average, ~8 ms at 60 fps). It now sleeps to a 0.9-interval floor and
  wakes on the capture's actual arrival, exactly the native T1.1 shape — with the same
  credit bucket pinning the long-run average at the negotiated rate, so a mirrored panel
  running faster than the session cannot overdrive the wire. The bucket
  (CaptureCredit, ex the native loop's private PaceBudget) and the PUNKTFUNK_FRAME_DRIVEN
  gate move to send_pacing, shared by both planes.

- WP1.2 microburst pacing: the GS sender passed burst_bytes: None into the shared pacer,
  so EVERY frame — a 3-packet P-frame included — spread across 0.75 of the frame interval
  (an ~11 ms tail at 60 fps), and small frames still slept out the full budget. It now
  runs the native parameterization: an auto-sized burst leaves immediately (a normal
  frame goes out whole, unpaced) and only an oversized frame's overflow spreads, across
  the time it needs at ~3x the stream rate, bounded to ~2 intervals. The chunking stays
  BOUNDED (<=12 steps) — on that non-RT thread the per-step sleep overshoot must stay
  independent of bitrate.

- WP1.3 pooled packetization: the packetizer copied the whole AU into a staging Vec and
  allocated one Vec per shard (~3300 for a 4 MB IDR) plus unpooled parity, every frame.
  Datagram buffers now cycle sender -> packetizer -> wire through a recycle channel,
  parity goes through the pooled encode_into, and each payload byte is copied exactly
  once, straight from the encoder's buffer into its datagram. A poisoned-pool equivalence
  test pins the pooled path byte-identical to a fresh packetizer.

- WP1.5 wake-on-packet control: the ENet service loop was a 2 ms spin-sleep, so every
  client input/IDR-request datagram waited out the rest of the tick. The control socket
  now blocks with a 2 ms read timeout (same idle cadence and CPU); an arriving datagram
  wakes it immediately. rusty_enet's receive loop returns on the first produced event
  (verified in its transpiled enet_protocol_receive_incoming_commands), so the block only
  ever sits where the sleep used to.

- WP2.1 budget honesty: the encoder was handed the full negotiated bitrate and then 20%
  FEC parity plus per-shard framing went ON TOP — every session carried ~1.23x what the
  client configured, and on the constrained links where the setting matters the overshoot
  was the failure. The encoder rate is now derived UNDER the client's number
  (gs_encoder_bps, the compat twin of the native encoder_kbps_for_budget from ABR
  overhaul Phase 4), so the wire fits the budget the Moonlight user actually set.

- WP0 measurement: the wire's frame_processing_latency field (1/10 ms, the Sunshine
  extension Moonlight's overlay shows as 'Host processing latency') is now stamped per
  frame instead of hardcoded 0; the 1 Hz stats sample grows a send_spread stage measured
  on the sender thread; and the video tests gain a GS-wire loopback harness — a
  client-shaped reassembler running Cauchy RS recovery over lossy datagram subsets —
  locking layout + recovery against every later packetizer change.

Also: the mirror source's capture-format 'gpu' predicate now matches the virtual source's
(resolved_backend_is_gpu, not the zerocopy env knob).

Gates: Linux container (punktfunk-rust-ci) fmt --all --check clean, clippy --all-targets
-D warnings clean and non-vacuous, send_pacing 14/14, gamestream 81/81 (incl. the new
harness + equivalence + schedule pins). Windows .133 clippy/tests with nvenc,amf-qsv,qsv
run in parallel with this commit; macOS spot-check shows only the known cfg(linux) stub
errors. On-glass A/B (WP0.3 baseline matrix) still owed.
2026-08-27 15:01:55 +02:00
enricobuehler 891f165f82 Merge pull request 'A stall learns whether anything was dirty, and lateness stops cutting the bitrate' (#413) from worktree-standby-sink-investigation into main
ci / docs-drift (push) Successful in 32s
ci / docs-site (push) Successful in 1m11s
ci / web (push) Successful in 1m16s
apple / swift (push) Successful in 2m7s
deb / build-publish-gamescope (push) Successful in 46s
ci / rust-arm64 (push) Successful in 3m23s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 22s
deb / build-publish-client-arm64 (push) Successful in 1m38s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 19s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 29s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 15s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 15s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 17s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 17s
deb / build-publish-host (push) Successful in 5m12s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m16s
deb / build-publish (push) Successful in 6m41s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m18s
arch / build-publish (push) Successful in 8m45s
ci / rust (push) Successful in 8m44s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
docker / builders-arm64cross (push) Successful in 15s
ci / bun-nix (push) Failing after 11m20s
android / android (push) Successful in 11m58s
deb / smoke-install (push) Successful in 4m14s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 7m32s
apple / distribute (push) Successful in 11m16s
flatpak / build-publish (push) Successful in 6m31s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 8m26s
docker / deploy-docs (push) Failing after 12m14s
apple / screenshots (push) Successful in 9m59s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m9s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m8s
windows-host / package (push) Failing after 9m50s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
2026-08-27 13:00:12 +00:00
enricobuehler 53621a0fe0 fix(core/abr): frames that died of lateness stop ratcheting the bitrate — the +50k bump exempts the all-late window
ci / bun-nix (pull_request) Successful in 30s
ci / docs-drift (pull_request) Successful in 1m0s
ci / docs-site (pull_request) Successful in 1m6s
ci / web (pull_request) Successful in 1m27s
apple / swift (pull_request) Successful in 2m12s
ci / rust-arm64 (pull_request) Successful in 2m11s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Successful in 5m53s
android / android (pull_request) Successful in 6m39s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 8m22s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 7m14s
window_loss_ppm's frames_dropped bump fired even when lost == 0 && late > 0:
every presumed-lost shard eventually ARRIVED, the frames died of lateness —
a delivery hole (host compose stall's resume edge, client-radio pause), which
neither lever this number drives can touch: FEC repairs loss, not delay, and
a bitrate backoff cannot shorten a hole. In the 2026-08-27 field log that was
loss_ppm=50000 exactly, x0.7 three times to 6.86 Mbps in 4 s, on a wire with
zero measured loss — the false ratchet is why a 160 ms hole reads as a
quality crater on our stream while the same pause is a cursor hiccup on the
competition.

The bump survives everywhere it is right: silent total loss (shards that
never arrived count in neither recovered nor late) reads late == 0 and still
bumps, and any window with real net loss bumps as before. Client-side mint,
so old clients keep the old behavior until updated.
2026-08-27 14:33:36 +02:00
enricobuehler 1121734141 feat(capture/stall): a stall learns whether anything was dirty — the cursor witness splits input pauses from display freezes
The 2026-08-27 NVIDIA-laptop field case (36 stalls/27 s, blamed on the dark
laptop panel) reattributes to damage starvation: the desktop's only damage
source is the DWM-composited cursor, the cursor moves only while the client's
input arrives, and DwmFlush stayed 8-33 ms through every hole — DWM was
composing all along and correctly presenting nothing, because nothing was
dirty. The detector could not see that, so it fitted a 1.87 s 'display
metronome' to the reporter's input cadence and the WARN put the panel on
trial (connected_inactive lists it on every laptop under the exclusive
isolate — a standing correlation trap).

- try_consume samples GetCursorPos (rate-limited, user32 only) and
  accumulates motion per gap; the stall-ending frame's own move is held back
  one call so it never counts into the gap it ended. Some(0) = nothing was
  dirty anywhere; Some(n>0) through a present-free hole = damage existed and
  the display stack composed none of it — the positive conviction
  CONTENT-SILENCE could never make.
- window_report attributes the pre-hole flow: flow_dwm_only = every lookback
  present came from dwm.exe — a game's holes are never demoted.
- classify: ComposeSilence + working witness + flow_dwm_only + cursor still
  => new class DAMAGE-IDLE. Excluded from the metronome (fed post-
  classification via StallWatch::cycle now), both repeated-stall WARNs, and
  the connected_inactive blame; per-stall lines carry
  cursor_moved_px_during_gap + flow_dwm_only either way.
- The below-OS METRONOMIC warn stops prescribing panel A/Bs for holes the
  witness can already explain, and says every pre-0.28 field metronome ran
  with the REALTIME lever default-on.

Logic verified by extracting stall.rs verbatim (+ the etw pure slice) into a
native scratch crate: 16/16 tests green, incl. the new damage_idle_split and
damage-idle-does-not-feed-the-metronome cases. Windows-only glue (GetCursorPos
sampler, field inits) is compile-gated on the Windows legs.
2026-08-27 14:33:26 +02:00
enricobuehler 44929dcbf8 Merge pull request 'The daily-driver five: 10-bit SDR, system keys for remote desktop, keep-host-audio, mode variables for prep, and a cursor sized to the client' (#412) from worktree-daily-driver-five into main
ci / bun-nix (push) Successful in 26s
ci / docs-drift (push) Successful in 38s
deb / build-publish (push) Successful in 5m28s
ci / docs-site (push) Successful in 1m3s
ci / web (push) Successful in 1m6s
apple / swift (push) Successful in 2m10s
deb / build-publish-gamescope (push) Successful in 1m4s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 14s
ci / rust-arm64 (push) Successful in 1m45s
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 15s
deb / build-publish-client-arm64 (push) Successful in 1m22s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 13s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 1m4s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 59s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m52s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 28s
docker / deploy-docs (push) Successful in 51s
deb / build-publish-host (push) Successful in 6m54s
docker / builders-arm64cross (push) Successful in 13s
ci / rust (push) Successful in 6m56s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 8m9s
flatpak / build-publish (push) Successful in 6m9s
arch / build-publish (push) Successful in 11m27s
android / android (push) Successful in 11m54s
deb / smoke-install (push) Successful in 3m58s
apple / distribute (push) Successful in 11m21s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m52s
apple / screenshots (push) Successful in 10m7s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m37s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m19s
windows-host / package (push) Successful in 13m47s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 32s
Reviewed-on: #412
2026-08-27 11:34:43 +00:00
enricobuehler 0b473cdb38 Merge pull request 'The couch tiles stop minting a console window' (#411) from worktree-win-msix-console-flash into main
ci / docs-drift (push) Successful in 29s
ci / bun-nix (push) Successful in 30s
ci / rust (push) Canceled after 44s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 17s
ci / rust-arm64 (push) Canceled after 54s
ci / web (push) Canceled after 53s
ci / docs-site (push) Canceled after 53s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 16s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 17s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 22s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 23s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 24s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 25s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 21s
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 28s
docker / deploy-docs (push) Canceled after 0s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 2m3s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
Reviewed-on: #411
2026-08-27 11:34:16 +00:00
enricobuehler ee5e89d7d3 feat: the daily-driver five — 10-bit SDR, system keys in desktop mode, keep-host-audio, mode env for prep, cursor at client DPI
ci / web (pull_request) Successful in 1m40s
ci / docs-site (pull_request) Successful in 1m50s
ci / bun-nix (pull_request) Successful in 28s
ci / docs-drift (pull_request) Successful in 26s
ci / rust-arm64 (pull_request) Successful in 3m31s
apple / swift (pull_request) Successful in 2m9s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m5s
android / android (pull_request) Successful in 9m8s
ci / rust (pull_request) Successful in 10m20s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m41s
Five field requests from one returning user, traced and fixed together
(report: the "Daily-Driver Five" triage).

**10-bit SDR, independent of HDR.** VIDEO_CAP_10BIT and VIDEO_CAP_HDR were
always separate wire bits, but everything above the wire welded them: the
client advertised both from one "HDR" switch, the handshake required an HDR
capture source for any 10-bit depth, SessionPlan derived hdr from bit_depth,
and the encoder stamped BT.2020 PQ on every 10-bit format. Now a client
setting ("10-bit SDR", default off, tier-P) advertises the depth bit alone;
the handshake resolves bit_depth=10 without the HDR gates where the SDR-10
chain exists (Windows IDD-push + direct NVENC + HEVC — everywhere else the
session stays 8-bit, said honestly in the Welcome); the Welcome's colour
label, the virtual display's HDR bring-up and the capturer's want-HDR flag
all follow the new session-hdr verdict instead of the depth. The capture
side grows PixelFormat::Rgb10a2Sdr — the BGRA slot expanded 8→10 by a
trivial full-res pass (HdrRgb10Converter::new_sdr_expand) — which NVENC
ingests as ABGR10 and encodes Main10 under the ordinary BT.709 SDR VUI,
both 4:2:0 and 4:4:4. The win is the ENCODER's precision (compression
stops adding banding to gradients); the desktop stays 8-bit and neither
display's colour state is touched. The client already decodes by the VUI,
so no decode-side change was needed.

**"Capture system shortcuts" now works in Desktop mouse mode.** The setting
was wired end to end and then discarded by one `!desktop` term in the
presenter's grab condition — documented as "a remote desktop is something
you Alt-Tab away from", which made the toggle inert for exactly the
remote-desktop use it matters most for. The keyboard grab now follows the
setting in BOTH mouse models; desktop mode keeps an escape capture mode
never had (the unlocked pointer clicks any other window, focus drops, the
chords come back). Docs updated; the Mac's ⌘-chord behaviour is unchanged.

**Keep host audio playing (Moonlight's "Mute host PC speakers", unchecked,
per session).** New client setting + CLIENT_CAP_KEEP_HOST_AUDIO (0x20,
request-only, no HOST_CAP echo — an older host just re-routes as always).
A session holding the bit gets the host's audio devices left alone for its
lifetime (RAII guard): Windows skips the IPolicyConfig default parking and
loopbacks the operator's own default device; Linux taps the default sink's
monitor instead of claiming the default. Host-global best-effort across
concurrent sessions, as the cap documents. Rows in the GTK, Windows and
console-UI settings (desktop-only until Android advertises the bit).

**Prep commands carry the negotiated mode.** A per-app do/undo step's whole
environment was PF_APP_ID; the width/height/refresh/HDR it needs for a
per-mode frame cap were resolved 80 lines above and written only to the
unix-only marker file. Both serving planes now pass PF_STREAM_WIDTH /
_HEIGHT / _REFRESH / _HDR (the marker file's vocabulary, via one shared
hooks::prep_mode_env) — so an RTSS cap is one step instead of one
hard-coded entry per device, on Windows hosts too.

**The forwarded cursor matches the client's DPI.** The desktop-mode pointer
was resampled by the video fit factor alone, and SDL shows a custom cursor
surface at ~1:1 physical pixels on every backend — so on a 200% client our
pointer was half the size of every native one (and X11 the same; Wayland
escapes via buffer scale). The resample factor now folds in
SDL_GetWindowDisplayScale, the same model the Apple client gets for free
from point-sized NSCursors.

Verified: workspace clippy --locked -D warnings + targeted tests (hooks,
profiles, caps, console-ui rows, capture-policy) green on Linux
(pf-gtkflow); pf-capture/pf-frame windows-msvc green via xcheck; rustfmt
clean. NOT verified: the Windows-only halves of pf-encode/punktfunk-host
(no reachable Windows toolchain — .133 down; windows-host.yml is
dispatch-only, see the preceding commit), and everything on-glass: the
SDR-10 stream end to end, the desktop-mode grab, keep-host-audio wiring,
prep env on a live launch, and the cursor size at 200%.
2026-08-27 13:05:01 +02:00
enricobuehler 2b6bde577a fix(host): the Welcome-time display prep has not compiled on Windows since #408
bbc01cdd (ABR overhaul Phase 4, merged today as #408) threaded the new EncDerive
through prepare_display, but the call site in native/handshake.rs passes enc_of
BEFORE bit_depth while the signature takes bit_depth first — a type error in a
cfg(target_os = "windows") block. Nothing caught it: the ci.yml clippy job runs
on Linux and never compiles this block, and windows-host.yml only runs on
dispatch/release, so main's Windows host is currently unbuildable and no check
is red.

Reorder the arguments to match the signature. (--no-verify: this commit is cut
as a minimal cherry-pickable hotfix from a branch whose full fmt-clean state
lands in the following commits.)
2026-08-27 13:05:01 +02:00
enricobuehler de19a98f1b fix(clients/windows): the couch tiles stop minting a console window
ci / rust-arm64 (pull_request) Successful in 2m37s
ci / web (pull_request) Successful in 1m49s
ci / docs-site (pull_request) Successful in 1m22s
ci / bun-nix (pull_request) Successful in 1m12s
ci / docs-drift (pull_request) Successful in 34s
ci / rust (pull_request) Successful in 8m59s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m15s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m39s
`punktfunk-session` keeps the CONSOLE subsystem on purpose — its stdout is the
shell/session contract. Both couch entry points are GUI processes with no console
to lend it, so a spawn without CREATE_NO_WINDOW makes Windows mint one: a black
terminal that then sits behind the couch UI for the whole session. The shell's
own stream spawns (spawn.rs) have carried the flag since the port; the two
`--browse` hand-offs never did — the "Punktfunk Console" MSIX tile and
`punktfunk-client --console` (Steam shortcut, HTPC startup item) hit it every launch.

The two hand-offs were copy-paste twins, which is how they drifted apart on one
flag, so they become one file (couch.rs) that both roots include — the standalone
`punktfunk-console.exe` bin by `#[path]`, the shell as a module. `session_binary()`
folds in there too, so there is one resolver rather than two.

Second, quieter bug in the same spawn: stdout was inherited. With a console that
was merely invisible output; with CREATE_NO_WINDOW the inherited handle is invalid,
and `match_window` reports the settled window size on stdout REGARDLESS of
`--json-status` — a `println!` onto a dead handle panics the child mid-stream. So
stdout is an explicit `Stdio::null()`, not an inheritance.

Verified: couch.rs type-checks and clippies clean against x86_64-pc-windows-msvc in
an isolated harness (the crate itself cannot be cross-checked from macOS — SDL3 and
aws-lc-rs do not cross-build). On-glass verification on Windows is owed.
2026-08-27 12:57:14 +02:00
enricobuehler 45d3ff6ab7 Merge pull request '"Send logs to host" reaches every UI, and a console that cannot start says so' (#410) from worktree-gamepad-ui-and-send-logs into main
audit / cargo-audit (push) Successful in 1m4s
audit / bun-audit (plugin-kit) (push) Successful in 36s
audit / bun-audit (web) (push) Successful in 19s
apple / swift (push) Successful in 2m2s
audit / bun-audit (sdk) (push) Successful in 23s
audit / pnpm-audit (push) Successful in 17s
audit / docs-site-audit (push) Successful in 25s
audit / c-abi-asan (push) Successful in 5m32s
android / android (push) Successful in 8m56s
audit / miri (push) Successful in 6m30s
arch / build-publish (push) Successful in 8m44s
audit / license-gate (push) Successful in 7m8s
ci / bun-nix (push) Successful in 25s
ci / web (push) Successful in 1m9s
ci / rust-arm64 (push) Successful in 1m27s
ci / docs-drift (push) Successful in 27s
ci / docs-site (push) Successful in 1m5s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
deb / build-publish-gamescope (push) Successful in 48s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m49s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 27s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 26s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 29s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 29s
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 10s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 57s
deb / build-publish-client-arm64 (push) Successful in 2m56s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m14s
apple / distribute (push) Successful in 11m14s
deb / build-publish (push) Successful in 6m3s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
deb / build-publish-host (push) Successful in 6m15s
docker / deploy-docs (push) Successful in 44s
nix / flake (push) Failing after 4m55s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 7m57s
flatpak / build-publish (push) Successful in 7m52s
ci / rust (push) Successful in 19m10s
windows-host / package (push) Failing after 3m10s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
apple / screenshots (push) Successful in 10m23s
docker / builders-arm64cross (push) Successful in 11s
deb / smoke-install (push) Successful in 10m33s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m50s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 22m58s
Reviewed-on: #410
2026-08-27 10:48:57 +00:00
enricobuehler 91fa05bc21 Merge pull request 'The web console is named in every install line, and a host that has none gets one' (#409) from worktree-fedora-web-console-install into main
android / android (push) Canceled after 26s
apple / swift (push) Canceled after 29s
apple / distribute (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 37s
ci / rust (push) Canceled after 41s
ci / rust-arm64 (push) Canceled after 31s
ci / web (push) Canceled after 3s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
ci / docs-drift (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-gamescope (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
deb / smoke-install (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
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
decky / build-publish (push) Successful in 49s
installer-smoke / smoke (arch) (push) Successful in 1m4s
installer-smoke / smoke (fedora-44) (push) Successful in 1m34s
installer-smoke / smoke (debian-13) (push) Successful in 2m23s
Reviewed-on: #409
2026-08-27 10:48:30 +00:00
enricobuehler 0d8b156dcc feat(clients): "Send logs to host" reaches the GTK and WinUI shells, carrying the session's trail
ci / bun-nix (pull_request) Successful in 25s
ci / docs-drift (pull_request) Successful in 30s
ci / web (pull_request) Successful in 1m49s
ci / docs-site (pull_request) Successful in 1m54s
apple / swift (pull_request) Successful in 2m7s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 5m41s
android / android (pull_request) Successful in 6m37s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 9m0s
nix / flake (pull_request) Successful in 8m38s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 4m22s
ci / rust (pull_request) Successful in 19m45s
The ring layer moves out of the session binary into pf_client_core::logring
(desktop-gated beside the ring it feeds) — it started as punktfunk-session's
private module, and the moment both desktop shells wanted the row, a copy per
bin was exactly the drift the crate exists to prevent. The session keeps the
same layer under its new name; tracing-log and log fall out of its manifest.

Both shells install it beside their visible layer with its own DEBUG filter —
the env filter keeps scoping only what's printed, because the ring exists for
the diagnostics nobody enabled before the bug happened.

The row itself lands on each shell's saved-host menu on the console's own gate
(paired + online), wired to logring::send_to_host on a worker thread, outcome
as a toast (GTK) / the status line (WinUI), in the console's wording verbatim.

And the bundle is worth sending: orchestrate now pipes the session child's
stderr through logring::forward_child_stderr — every line still lands on our
stderr exactly as inherit interleaved it, and lands in the ring too. Without
that, the one surface a GUI-only user can export held everything EXCEPT the
stream it was exported about — the same hole the WinUI logfile module was
built to close for the file; its child forwarder now feeds the ring on the
same pass.
2026-08-27 12:27:40 +02:00
enricobuehler f6d85f11e2 fix(install): the web console is named in every install line, and a host that has none gets one
ci / bun-nix (pull_request) Successful in 24s
ci / docs-drift (pull_request) Successful in 25s
ci / docs-site (pull_request) Successful in 57s
ci / web (pull_request) Successful in 1m1s
installer-smoke / smoke (arch) (pull_request) Successful in 49s
ci / rust-arm64 (pull_request) Successful in 1m27s
apple / swift (pull_request) Successful in 2m6s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
installer-smoke / smoke (fedora-44) (pull_request) Successful in 2m21s
installer-smoke / smoke (debian-13) (pull_request) Successful in 3m19s
android / android (pull_request) Successful in 5m15s
ci / rust (pull_request) Successful in 6m10s
Reported from the field: the guided installer (scripts/install.sh) left a Fedora box with a host
and no web console — and then printed the console's URL anyway. Reproduced with the real script
against a faked Fedora 44 os-release: with `punktfunk-host` on PATH the installer prints "already
installed — skipping the install" and never runs its dnf line, so `punktfunk-web` is never added.
The console is where you pair, approve a device and change every setting, so that box can stream
nothing and offers no way to fix it.

The registry was never the problem: `punktfunk-web` is present in the fedora-44 and bazzite RPM
groups, the debian repo and the arch repo (checked all four). The install step was gated on ONE
binary while installing THREE packages.

  * scripts/install.sh asks per package (host / punktfunk-web-server / punktfunk-scripting) instead
    of skipping everything the moment the host binary exists. A box that got the host by hand, from
    an older docs line, or from a package manager set to drop weak deps now gets its console on a
    re-run. Installing a package that is already there is a no-op, so re-running stays safe.
  * The install lines NAME the console on every family. apt relied on `Recommends: punktfunk-web`
    and pacman on an optdepend, both of which vanish under `APT::Install-Recommends "0"` /
    `install_weak_deps=False` / plain pacman. dnf already named it. The packages keep their weak
    deps — a hard Requires would break the COPR build, which has no bun and ships no console — but
    no documented path leans on them any more.
  * The installer stops lying when the console is absent: step 6 warns instead of silently dropping
    punktfunk-web from the units it enables, and the closing banner offers the install line rather
    than a URL nothing serves.

Verified unchanged on the channels that were already right: the Bazzite sysext bakes in web +
scripting, the NixOS module defaults `web.enable` to `host.enable`, the SteamOS script defaults
`WITH_WEB=1`, and the Windows installer bundles the console unconditionally behind a payload guard
added after 0.22.1/0.22.2 shipped without one. COPR is the documented exception (no bun in mock).

The gates that let this ship:
  * check-docs-drift.sh gate 7 asserted the install line only as far as the host package name, so
    a line that dropped the console still passed. It now names all three per family.
  * installer-smoke.yml asserted `punktfunk-host --version` and nothing about the console. It now
    asserts the binary and the user unit per family, and carries the reported failure as a test:
    remove punktfunk-web, re-run the installer, require it back.

data/platforms.json is the source for both the docs and the installer, so the per-distro pages,
the install landing page and the web-console page were updated with it — including a "no console on
a box that has the host?" recovery block for anyone already in this state.
2026-08-27 12:09:59 +02:00
enricobuehler 1dbee2e7ed fix(clients/android): "Send logs to host" reaches the touch UI, and a console that cannot start says so
Two halves of the same field report: a 0.31.4 user whose gamepad UI never
activates — on Always, with a pad attached — and who then has no way to send
anyone the log that would say why.

"Send logs to host" existed only in the Skia console's host menu. That put the
log-escape hatch behind the very shell that was failing: a phone whose console
does not come up has no route to its own logs at all. The upload moves out of
SkiaConsole into `SendLogs`, one implementation both shells call, and the touch
home's card menu grows the row on the console's own gate (paired + online, host
card only). The wording stays the desktop console's verbatim, so a quoted
message means the same thing on every client.

The activation half is a gap in what the app admits. `App` folds
`SkiaConsole.wanted() && healthy` into the gamepad-UI decision on top of the
user's setting, and those two terms are the ONLY ones that can veto "Always" —
the mode, the pad, the TV check and the dev flag are ORed, so a device whose
console never builds ignores every one of them. Until now that produced a switch
the app silently disobeyed, indistinguishable from the switch being broken.
`SkiaConsole.unavailable()` names the reason and the Interface settings group
prints it as its footer: null on every device where the console works, so it
costs nothing there.

The reason string itself was already in the ring the new row uploads —
`console: render thread ended: …` and `console: window surface: …` go through
`log::error!`, which `RingTee` tees into `pf_client_core::logring`. So the two
halves close the loop: the user stuck in the touch UI can now send the log that
names why they are stuck in it.

Verified: 98 app unit tests green, `:app:assembleDebug` clean, native builds for
all three ABIs.
2026-08-27 12:05:09 +02:00
enricobuehler 9615cd7612 Merge pull request 'The bitrate becomes the wire budget it always claimed to be' (#408) from worktree-abr-phase4 into main
ci / web (push) Successful in 1m13s
apple / swift (push) Successful in 2m9s
ci / docs-site (push) Successful in 1m27s
ci / bun-nix (push) Successful in 19s
ci / docs-drift (push) Successful in 20s
ci / rust-arm64 (push) Successful in 3m40s
deb / build-publish (push) Successful in 4m11s
deb / build-publish-gamescope (push) Successful in 40s
android / android (push) Successful in 10m1s
arch / build-publish (push) Successful in 10m0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 17s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 19s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 18s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 17s
ci / rust (push) Successful in 9m45s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 16s
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 16s
deb / build-publish-client-arm64 (push) Successful in 1m25s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 14s
deb / build-publish-host (push) Successful in 7m2s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m12s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m28s
docker / builders-arm64cross (push) Successful in 14s
docker / deploy-docs (push) Successful in 41s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m39s
apple / distribute (push) Successful in 11m29s
deb / smoke-install (push) Successful in 4m52s
flatpak / build-publish (push) Successful in 5m51s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 7m23s
windows-host / package (push) Failing after 2m13s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
apple / screenshots (push) Successful in 10m9s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m37s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m6s
Reviewed-on: #408
2026-08-27 09:34:47 +00:00
enricobuehler bbc01cdd8a The bitrate becomes the wire budget it always claimed to be (ABR overhaul Phase 4)
ci / bun-nix (pull_request) Successful in 18s
ci / docs-drift (pull_request) Successful in 24s
ci / docs-site (pull_request) Successful in 1m34s
ci / web (pull_request) Successful in 1m45s
ci / rust-arm64 (pull_request) Successful in 1m48s
apple / swift (pull_request) Successful in 2m10s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m59s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m10s
android / android (pull_request) Successful in 10m41s
ci / rust (pull_request) Successful in 7m57s
'20 Mbps' used to mean 22-30+ Mbps on the wire: FEC parity (1-50 %),
packet framing (~4.5 %) and the audio plane all rode on top of the
number the user set — and on the constrained links where the setting
matters most, that overshoot WAS the failure. Per the signed-off RFC
§5.1 (amended 2026-08-27: no capability handshake — the semantics just
change; new-client→old-host is byte-for-byte today's behavior, and
old-client→new-host shifts conservative by the overhead share):

Host: every number on the control plane — Hello resolve, SetBitrate,
acks, live_bitrate, the console — is the TOTAL WIRE BUDGET. Encoder
opens/reconfigures convert through EncDerive (budget − audio, over
framing + FEC) snapshotted at the live FEC percent, and read-backs
convert back so a short apply reports budget truth. A FEC step
re-derives the encoder rate in place, so parity reallocates WITHIN the
budget instead of inflating the wire. The audio reservation comes from
the resolved plane (exact PCM cost, or the shared Opus budget ladder).
PyroWave is the identity (its pin is an encoder operating point);
GameStream keeps its historical semantics untouched.

Client: the controller's actual becomes the WIRE measure — received
bytes with headers, seals and parity (they spend the budget), minus
probe filler, plus the mirrored audio reservation — so utilization and
the proven mark compare like with like against budget targets.

The derivation is pure and unit-tested: roundtrips never inflate the
budget, reallocation is monotone in FEC, and a budget too small for
its own audio floors honestly. punktfunk-core --features quic: all 500
lib tests green natively; punktfunk-host clippy -D warnings green on
the CI image. Docs: the bitrate sections say what the number now means.
2026-08-27 11:29:35 +02:00
enricobuehler b97fa1186f Merge pull request 'The controller learns stillness from the host, and motion stops reading as congestion' (#407) from worktree-abr-phase3 into main
apple / swift (push) Successful in 2m5s
ci / rust-arm64 (push) Successful in 1m35s
ci / docs-site (push) Successful in 1m23s
ci / docs-drift (push) Successful in 1m2s
deb / build-publish-gamescope (push) Successful in 44s
deb / build-publish-client-arm64 (push) Successful in 1m52s
ci / rust (push) Successful in 8m41s
android / android (push) Successful in 9m2s
deb / build-publish (push) Successful in 4m9s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 12s
apple / distribute (push) Canceled after 9m45s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 18s
apple / screenshots (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 19s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 39s
arch / build-publish (push) Canceled after 9m51s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 15s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 16s
ci / web (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
decky / build-publish (push) Successful in 1m13s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 22s
deb / build-publish-host (push) Successful in 4m55s
deb / smoke-install (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 16s
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 10s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 27s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 27s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 12s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 3m16s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
Reviewed-on: #407
2026-08-27 09:22:20 +00:00
enricobuehler 5f77074e51 Merge pull request 'A tag is a pointer, not a version — pin every checkout/cache to a commit' (#405) from worktree-actions-pin into main
android / android (push) Canceled after 20s
apple / swift (push) Canceled after 1m5s
apple / distribute (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 27s
ci / rust (push) Canceled after 0s
audit / bun-audit (web) (push) Successful in 17s
ci / rust-arm64 (push) Canceled after 0s
audit / bun-audit (sdk) (push) Successful in 18s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
ci / docs-drift (push) Canceled after 0s
audit / docs-site-audit (push) Successful in 23s
audit / bun-audit (plugin-kit) (push) Successful in 50s
audit / pnpm-audit (push) Successful in 13s
deb / build-publish (push) Canceled after 3s
deb / build-publish-host (push) Canceled after 2s
audit / cargo-audit (push) Successful in 1m2s
deb / build-publish-gamescope (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
deb / smoke-install (push) Canceled after 0s
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/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
installer-smoke / smoke (debian-13) (push) Successful in 1m9s
installer-smoke / smoke (fedora-44) (push) Successful in 1m18s
audit / license-gate (push) Successful in 5m27s
audit / miri (push) Successful in 5m47s
audit / c-abi-asan (push) Successful in 6m43s
windows-drivers / probe-and-proto (push) Successful in 53s
windows-drivers / driver-build (push) Successful in 1m35s
installer-smoke / smoke (arch) (push) Successful in 44s
nix / flake (push) Successful in 27m36s
Reviewed-on: #405
2026-08-27 09:21:12 +00:00
enricobuehler 8358f9ed37 Merge pull request 'The AMD field log answers itself, and AMF reports its real bitrate' (#406) from worktree-amd-program-wave2 into main
android / android (push) Canceled after 26s
arch / build-publish (push) Canceled after 37s
ci / rust (push) Canceled after 48s
ci / rust-arm64 (push) Canceled after 48s
ci / web (push) Canceled after 47s
ci / docs-site (push) Canceled after 45s
ci / bun-nix (push) Canceled after 14s
ci / docs-drift (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-gamescope (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
deb / smoke-install (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 24s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 25s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
Reviewed-on: #406
2026-08-27 09:20:43 +00:00
enricobuehler cba7587684 chore(ci): baseline the two new wire constants — protocol internals, not knobs
apple / swift (pull_request) Successful in 2m13s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 5m57s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m1s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m18s
ci / bun-nix (pull_request) Successful in 20s
ci / docs-drift (pull_request) Successful in 23s
ci / docs-site (pull_request) Successful in 1m44s
ci / web (pull_request) Successful in 1m52s
ci / rust-arm64 (pull_request) Successful in 2m34s
ci / rust (pull_request) Successful in 7m24s
2026-08-27 11:03:08 +02:00
enricobuehler 2b107f5a18 chore(core): prefix the two new exported constants in the C header (R21)
ci / bun-nix (pull_request) Successful in 39s
ci / docs-drift (pull_request) Failing after 36s
ci / docs-site (pull_request) Successful in 1m2s
ci / web (pull_request) Successful in 1m16s
apple / swift (pull_request) Successful in 2m9s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m49s
android / android (pull_request) Successful in 6m6s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m16s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m44s
ci / rust (pull_request) Successful in 15m42s
2026-08-27 10:42:00 +02:00
enricobuehler a387ddd4ab The controller learns stillness from the host, and motion stops reading as congestion (ABR overhaul Phase 3)
Static content used to poison the client's ABR against the first moment
of motion (the 2026-08-26 field chain, step 5), and a frame-driven
source could never climb at all (the documented dead band). Four
changes, per the signed-off RFC §4.1–4.3 + §7 Q4:

- The host marks idle-keepalive re-encodes on the wire
  (USER_FLAG_REPEAT; whole-frame and streamed paths) and says so in a
  new trailing Welcome byte (host_caps2 / HOST_CAP2_REPEAT_MARK — the
  second capability byte the 0x80 wall predicted, same append
  discipline, placeholder chain extended one link). Old clients ignore
  both; against an old host the client keeps every legacy rule.
- Idle windows (every AU a repeat) are NEUTRAL: they train no OWD/
  decode/encode baselines, accrue no climb credit or re-probe
  authority, and never authorize a climb — loss/flush/drop keep their
  full power. The pinned climb-gate test is extended, not deleted.
- Utilization is measured per frame at the source's own rate: a 35 fps
  menu on a 90 Hz config climbs when its frames run full. The
  proven-headroom cap prorates with it — it bounds the next target's
  projected WIRE rate at ×1.5 over what was delivered — or the two
  gates would deadlock exactly where the fix is needed.
- Recovery is bounded and multiplicative: the proven mark is a
  two-bucket windowed max (~30–60 s) instead of all-session, and the
  first active window after ≥3 s of stillness re-arms slow start with
  the cooldown cleared — the ~103 s additive crawl from the floor
  becomes seconds, never past ×1.5 over recently-proven delivery.
- FLOOR_KBPS drops 5000 → 2000 (RFC §7 Q4), with a one-shot log
  warning on the first descent below the old fence; the client-settings
  doc says so.

punktfunk-core --features quic: all 500 lib tests green natively,
including six new field-shaped scenarios (frame-driven climb, idle
neutrality head-to-head vs a legacy host, bounded onset re-arm,
windowed proven decay, floor + warning, idle-never-climbs).
2026-08-27 10:40:48 +02:00
enricobuehler 5da68ab858 Merge main — the ABR review's four items landed there first (#399-#404)
ci / bun-nix (pull_request) Successful in 42s
ci / docs-drift (pull_request) Successful in 38s
ci / web (pull_request) Successful in 1m9s
ci / rust-arm64 (pull_request) Successful in 1m49s
ci / docs-site (pull_request) Successful in 2m23s
android / android (pull_request) Successful in 7m2s
ci / rust (pull_request) Successful in 15m41s
Phases 1+2 of the ABR overhaul implemented review §2.1/§2.2/§2.3/§2.4 while this
branch was doing the same, so every ABR conflict resolves to main's side and my
duplicates are deleted:

- abr.rs: main folds the mode-switch rebind into set_stream_cap itself (a
  `mode_switch` flag) instead of a second method — my rebind_stream_cap and its
  test are removed; main's a_mode_switch_reteaches_the_stream_cap_both_ways
  covers strictly more (up-switch, re-authorize, down-switch, disabled).
- pump.rs / control_task.rs / data.rs: main's ack queue and stream-cap recompute
  are the same shape; taken wholesale.
- stream.rs: main's §2.2/§2.3 arms taken (its gap cast saturates, mine did not).

What this branch still carries is what main does not: the capture-stall telemetry
(posture line, repeated-stall WARN, CONTENT-SILENCE prose), the AMF work
(applied_bitrate_bps readback, accepted-props logging, AV1 B-picture pin,
no-reordering assertion), the adl-emul probe fixes, and the wire-MTU-re-key
attribution arm.
2026-08-27 10:26:57 +02:00
enricobuehler 2cd5787ed6 fix(encode/amf): pin AV1 B-pictures off, and assert no-reordering on the bitstream
ci / docs-drift (pull_request) Successful in 47s
ci / bun-nix (pull_request) Successful in 1m19s
ci / web (pull_request) Successful in 1m27s
ci / docs-site (pull_request) Successful in 1m27s
apple / swift (pull_request) Successful in 2m13s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
android / android (pull_request) Canceled after 2m51s
ci / rust-arm64 (pull_request) Canceled after 2m42s
ci / rust (pull_request) Canceled after 2m42s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m47s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 4m8s
H.264 already pins BPicturesPattern=0 ('RDNA3+ defaults > 0'). AV1's three
B-picture properties are VCN5 features (AMF header, verified 2026-08-26) whose
defaults are 0/false today — but a default that flips on newer silicon is
exactly how H.264 grew them, and AV1 is the one AMD codec with no LTR and no
intra-refresh, so a reordering surprise lands on the codec that can least afford
it. Optional sets: pre-VCN5 drivers decline the names, which is the right no-op.
HEVC needs no twin — AMF defines no B-frame property for it at all, and a VCN3
capture measured 3 I + 52 P + 0 B.

The live smoke test now asserts AUs leave in submit order, so a driver that
silently declines a pin fails the test instead of shipping reordered output.
2026-08-27 10:19:12 +02:00
enricobuehler 72959ef07d fix(core): bitrate acks queue in arrival order instead of a latest-wins slot
08-22 ABR review §2.4: a full resolve ack plus a corrective short retarget in
the same 750 ms report window collapsed to whichever arrived last — host-cap
learning needs two consecutive short acks, so a lost correction delayed or
prevented the cap and could reintroduce the overdrive sawtooth. Bounded queue
(8), drained fully per window.
2026-08-26 21:20:45 +02:00
enricobuehler fa0f66e151 fix(core): a mode switch re-sizes the ABR stream cap, and clamps the learned ceiling with it
08-22 ABR review §2.1: the stream-shape cap was computed once from the Welcome
mode and never again, so a 4K→720p switch kept authorizing 4K-sized climbs for
the whole session (only the reactive loss/decode signals reined them in). The
mode-gen site now recomputes the cap from the accepted mode (depth/chroma are
session-negotiated and ride along) and rebind_stream_cap clamps an already-
learned ceiling down to it. Up-switches lift only the cap — with no untrimmed
measurement stored, a higher ceiling would be evidence-free (§3.3 re-probe owns
that half). Pinned by a_mode_switch_rebind_clamps_the_learned_ceiling_but_never_raises_it.
2026-08-26 21:15:45 +02:00
enricobuehler d3a5f13a45 ci: pin every checkout/cache to a commit, and move off the stale v4 majors
installer-smoke / smoke (arch) (pull_request) Successful in 53s
windows-drivers / driver-build (pull_request) Successful in 1m45s
apple / swift (pull_request) Successful in 2m18s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 10m8s
installer-smoke / smoke (fedora-44) (pull_request) Successful in 2m59s
installer-smoke / smoke (debian-13) (pull_request) Successful in 3m52s
nix / flake (pull_request) Successful in 8m11s
windows-drivers / probe-and-proto (pull_request) Successful in 34s
ci / docs-drift (pull_request) Successful in 34s
ci / bun-nix (pull_request) Successful in 29s
ci / web (pull_request) Successful in 1m12s
ci / docs-site (pull_request) Successful in 1m10s
ci / rust-arm64 (pull_request) Successful in 1m39s
ci / rust (pull_request) Successful in 6m4s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m58s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 7m44s
A tag is a pointer, not a version. `actions/checkout@v4` re-resolves on
every run, so whoever controls that tag controls the first step of nearly
every job in this repo — including the jobs holding signing keys. 52
checkout sites and 31 cache sites now name a commit; announce.yml's lone
existing SHA pin moves up with them so the tree is uniformly pinned rather
than half-pinned, which is the state where a reader cannot tell a
deliberate omission from a miss.

checkout v4 -> v7.0.1, cache v4 -> v6.1.0, both by commit.

The reason this needed proving rather than just doing: v5+ of both declare
`runs.using: node24`, and Gitea's runner only accepts node24 from a certain
version on. Checkout is step 0 of almost everything here, so getting it
wrong fails the whole fleet at once — including the runs that would tell
you it broke.

What was established first:
- every runner serving this repo is Gitea Runner v1.0.8, whose vendored
  act accepts node24;
- `runs.using` is only a validation gate — act execs a BARE `node` from
  PATH, so the image's node is the real interpreter, and the oldest one
  here is 18.19.1 (ubuntu:24.04's nodejs, in rust-ci-noble);
- so checkout v7's node24-targeted ESM bundle was run under that exact
  node, in that exact image: it parsed inputs, drove `git` through
  @actions/exec, created .git, set up and tore down credential config, and
  ran its post step. It reached a genuine git error against the synthetic
  file:// remote used for the test — not a module-load or missing-API
  failure. It works.
- actions clone straight from github.com here (no Gitea mirror), so these
  SHAs are meaningful; both tags are lightweight, pointing directly at the
  commits pinned.

No breaking change in v5..v7 applies: this repo passes zero checkout
inputs, and uses neither `pull_request_target` nor `workflow_run` (v7's
fork-PR restriction). cache v5/v6 pick the same legacy service path as v4
because the runner never sets ACTIONS_CACHE_SERVICE_V2.

upload-artifact@v3 is deliberately NOT swept — it is pinned low on purpose,
because Gitea's artifact backend identifies as GHES and v4 refuses it.
2026-08-26 21:08:11 +02:00
enricobuehler 3fe1af991a fixup: publish wire_rekeys from the send thread (it owns the packetizer) 2026-08-26 21:08:04 +02:00
enricobuehler 57444e7be7 fix(host): a wire-MTU-re-keyed session stops blaming its metronomic recoveries on the display
The 2026-08-26 lab sessions over an overlay hop (udp_mtu 1336) produced the
'host/display disturbance' warn at period 1.7 s — just outside both client
cooldown bands — while the real cause was the path black-holing full-size video
until the re-key. Period alone cannot make this call; the session's transport
context can. New first-priority arm: wire_rekeys > 0 names the constrained path
and points at PUNKTFUNK_WIRE_MTU.
2026-08-26 21:03:36 +02:00
enricobuehler 809f1faa26 The comments were the spec, and the code had drifted — 2026-08-25 security review (#396)
audit / bun-audit (plugin-kit) (push) Successful in 31s
audit / cargo-audit (push) Successful in 56s
audit / bun-audit (sdk) (push) Successful in 27s
audit / pnpm-audit (push) Successful in 18s
audit / bun-audit (web) (push) Successful in 32s
audit / docs-site-audit (push) Successful in 27s
apple / swift (push) Successful in 2m15s
ci / rust-arm64 (push) Successful in 2m50s
ci / web (push) Successful in 1m26s
audit / miri (push) Successful in 5m59s
audit / license-gate (push) Successful in 6m10s
ci / bun-nix (push) Successful in 24s
ci / docs-site (push) Successful in 1m46s
ci / docs-drift (push) Successful in 34s
audit / c-abi-asan (push) Successful in 6m42s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 7m42s
deb / build-publish-gamescope (push) Successful in 1m8s
android / android (push) Successful in 9m45s
ci / rust (push) Successful in 8m15s
arch / build-publish (push) Successful in 10m13s
deb / build-publish-client-arm64 (push) Successful in 2m30s
deb / build-publish-host (push) Successful in 6m44s
apple / distribute (push) Successful in 11m46s
decky / build-publish (push) Successful in 1m2s
windows-host / package (push) Successful in 12m11s
windows-host / winget-source (push) Skipped
deb / build-publish (push) Successful in 7m6s
apple / screenshots (push) Successful in 10m7s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m57s
windows-host / canary-manifest (push) Successful in 58s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m11s
deb / smoke-install (push) Successful in 8m20s
nix / flake (push) Successful in 26m53s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m20s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 17s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 20s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 19s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 17s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 18s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 16s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / builders-arm64cross (push) Successful in 9s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 59s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m21s
docker / deploy-docs (push) Successful in 32s
flatpak / build-publish (push) Successful in 5m4s
2026-08-26 18:56:30 +00:00
enricobuehler d34d431618 Merge pull request 'DSCP marks the peers the local network owns' (#404) from worktree-abr-dscp-default into main
ci / rust-arm64 (push) Successful in 2m12s
ci / docs-site (push) Successful in 1m29s
ci / web (push) Successful in 1m43s
ci / bun-nix (push) Successful in 22s
apple / swift (push) Successful in 2m13s
ci / docs-drift (push) Successful in 25s
deb / build-publish-gamescope (push) Successful in 33s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 14s
deb / build-publish-client-arm64 (push) Successful in 2m15s
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 17s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 18s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 13s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 15s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 36s
ci / rust (push) Successful in 6m46s
docker / builders-arm64cross (push) Successful in 17s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m12s
deb / build-publish (push) Successful in 5m6s
deb / build-publish-host (push) Successful in 5m41s
docker / deploy-docs (push) Successful in 40s
arch / build-publish (push) Successful in 9m25s
android / android (push) Successful in 10m5s
apple / distribute (push) Canceled after 8m26s
apple / screenshots (push) Canceled after 0s
deb / smoke-install (push) Successful in 3m31s
flatpak / build-publish (push) Successful in 4m51s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 4m47s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 5m34s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 2m44s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
2026-08-26 18:44:40 +00:00
enricobuehler 7ebdd7e5d9 fix(ci): pin cargo-ndk too — it builds the shipped Android .so files
ci / web (pull_request) Successful in 1m13s
ci / rust-arm64 (pull_request) Successful in 2m56s
ci / docs-site (pull_request) Successful in 1m20s
ci / docs-drift (pull_request) Successful in 30s
ci / bun-nix (pull_request) Successful in 1m16s
android / android (pull_request) Successful in 6m3s
ci / rust (pull_request) Successful in 8m6s
apple / swift (pull_request) Successful in 2m8s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
nix / flake (pull_request) Successful in 12m26s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m48s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m6s
Last floating tool in the builder images. Unpinned, two rebuilds months
apart bake different cargo-ndk versions with nothing recording it, and this
is the tool that drives the .so files in the published APK/AAB. crates.io
is append-only with a checksummed index, so the version string is the whole
pin — no checksum needed, unlike the bun/sccache/FFmpeg cases.

4.1.2 is current, so this is a zero-change pin like the others.
2026-08-26 20:26:27 +02:00
enricobuehler c62cfd58de fix(ci): a git tag is not a pin — assert the FFmpeg commits we actually build
android / android (pull_request) Canceled after 0s
apple / swift (pull_request) Canceled after 0s
apple / distribute (pull_request) Canceled after 0s
apple / screenshots (pull_request) Canceled after 0s
ci / rust (pull_request) Canceled after 0s
ci / rust-arm64 (pull_request) Canceled after 0s
ci / web (pull_request) Canceled after 0s
ci / docs-site (pull_request) Canceled after 0s
ci / bun-nix (pull_request) Canceled after 0s
ci / docs-drift (pull_request) Canceled after 0s
nix / flake (pull_request) Canceled after 0s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Canceled after 0s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Canceled after 0s
rust-ci-noble builds FFmpeg and nv-codec-headers from `git clone --branch
<tag>`, and the .so files land in the host .deb every Ubuntu user
installs. Tags are mutable: upstream can repoint one and, unlike a branch,
nothing would look different. The comment above it claimed "immutable-tag
clone, so no separate checksum needed", which is the same
comment-asserts-what-the-code-doesn't shape this whole branch has been
unpicking.

Both are now asserted against the commit the tag points at TODAY, so the
built tree is byte-identical and only the mutability is closed. A moved tag
now fails the build loudly, matching the sha256sum -c pins next door.

Both tags are ANNOTATED, so the bare ref is a tag object, not a commit —
the pin is the peeled `refs/tags/X^{}`, which is what `rev-parse HEAD`
returns after a --depth 1 tag clone. Pinning the tag object would have
failed every build.

rustup's installer and `cargo install cargo-ndk` are left: both float, but
crates.io is append-only with a checksummed index and rustup verifies its
own downloads — neither is a fixed name whose bytes can move.
2026-08-26 20:24:51 +02:00
enricobuehler 1a604cbf3e Merge remote-tracking branch 'origin/main' into worktree-abr-dscp-default
ci / rust-arm64 (pull_request) Successful in 3m24s
ci / web (pull_request) Successful in 2m0s
ci / docs-site (pull_request) Successful in 1m18s
ci / bun-nix (pull_request) Successful in 21s
ci / docs-drift (pull_request) Successful in 30s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m18s
ci / rust (pull_request) Successful in 7m2s
android / android (pull_request) Successful in 6m30s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m57s
apple / swift (pull_request) Successful in 2m9s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
# Conflicts:
#	docs-site/content/docs/configuration.md
2026-08-26 20:16:31 +02:00
enricobuehler c1bffa9e7b Merge pull request 'The control loop stops believing its own bookkeeping' (#403) from worktree-abr-control-loop into main
apple / swift (push) Successful in 2m7s
ci / bun-nix (push) Successful in 22s
ci / docs-drift (push) Successful in 27s
ci / docs-site (push) Successful in 1m13s
deb / build-publish-gamescope (push) Successful in 43s
ci / web (push) Successful in 1m43s
deb / build-publish-client-arm64 (push) Successful in 1m36s
ci / rust-arm64 (push) Successful in 3m48s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 19s
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 21s
deb / build-publish-host (push) Successful in 5m7s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 14s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 23s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 16s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 19s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 41s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m47s
ci / rust (push) Successful in 8m21s
docker / builders-arm64cross (push) Successful in 35s
arch / build-publish (push) Successful in 11m0s
deb / build-publish (push) Successful in 4m20s
docker / deploy-docs (push) Successful in 40s
flatpak / build-publish (push) Failing after 3m45s
apple / distribute (push) Successful in 11m50s
android / android (push) Successful in 10m18s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Failing after 11m40s
windows-host / package (push) Successful in 12m12s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 42s
deb / smoke-install (push) Successful in 4m48s
apple / screenshots (push) Successful in 10m11s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 18m30s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 16m56s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 5m13s
2026-08-26 18:14:54 +00:00
enricobuehler 8ca86b1682 Merge pull request 'The transmission plane stops decaying to a blast' (#401) from worktree-abr-transmission-plane into main
android / android (push) Canceled after 46s
arch / build-publish (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 50s
ci / web (push) Canceled after 44s
ci / docs-site (push) Canceled after 24s
ci / bun-nix (push) Canceled after 10s
ci / docs-drift (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Canceled after 4s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 22s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 4s
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 30s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 27s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
2026-08-26 18:14:29 +00:00
enricobuehler dcedd7147f Merge pull request 'PyroWave forces Automatic bitrate' (#399) from worktree-pyrowave-forces-auto into main
android / android (push) Canceled after 22s
arch / build-publish (push) Canceled after 0s
ci / rust (push) Canceled after 4s
ci / rust-arm64 (push) Canceled after 36s
ci / bun-nix (push) Canceled after 30s
ci / docs-site (push) Canceled after 31s
ci / web (push) Canceled after 34s
ci / docs-drift (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
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
apple / swift (push) Canceled after 1m52s
apple / distribute (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
deb / build-publish-gamescope (push) Successful in 50s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
deb / build-publish (push) Canceled after 1m36s
deb / build-publish-host (push) Canceled after 1m35s
deb / build-publish-client-arm64 (push) Canceled after 1m33s
deb / smoke-install (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 0s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 2m24s
2026-08-26 18:14:10 +00:00
enricobuehler 4dcc31dc3b Merge pull request 'Verify NVENC sub-frame readback in release, and stop trusting a driver that lied' (#400) from worktree-nvenc-subframe-verify into main
ci / web (push) Successful in 1m11s
ci / rust-arm64 (push) Successful in 1m34s
ci / bun-nix (push) Successful in 37s
ci / docs-site (push) Successful in 1m15s
ci / docs-drift (push) Successful in 37s
deb / build-publish-gamescope (push) Successful in 1m1s
deb / build-publish-client-arm64 (push) Successful in 2m9s
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 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 24s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 18s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 33s
deb / build-publish (push) Successful in 4m51s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m54s
docker / builders-arm64cross (push) Successful in 20s
deb / build-publish-host (push) Successful in 6m8s
docker / deploy-docs (push) Successful in 52s
ci / rust (push) Successful in 6m53s
arch / build-publish (push) Successful in 10m32s
android / android (push) Successful in 11m7s
deb / smoke-install (push) Successful in 3m52s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 16m39s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 16m5s
windows-host / package (push) Canceled after 6m53s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
2026-08-26 17:52:06 +00:00
enricobuehler 987ecabfde docs(nix): key the deploy host key port-agnostically, and say which address to scan
apple / swift (pull_request) Successful in 2m8s
ci / docs-site (pull_request) Successful in 1m15s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 2m40s
ci / bun-nix (pull_request) Successful in 1m3s
ci / docs-drift (pull_request) Successful in 24s
ci / rust-arm64 (pull_request) Successful in 3m49s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m14s
ci / rust (pull_request) Successful in 6m4s
android / android (pull_request) Failing after 10m12s
nix / flake (pull_request) Successful in 7m22s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m49s
Two things that cost real time setting DEPLOY_KNOWN_HOSTS for the first
time. ssh looks a host key up by the exact string it dialled, so a plain
entry stops matching the moment DEPLOY_PORT is not 22 — and it fails
looking like a host-key error, not a formatting one. The comma form covers
both. And DEPLOY_HOST is unom-1's public Hetzner IP since the 2026-07-12
cutover, so a stale private address in an SSH config is the wrong thing to
scan.

Also say ed25519 only: pinning every type ssh-keyscan prints means the
weakest pinned key decides, since a host offering just RSA would satisfy
the check on the RSA line.
2026-08-26 19:35:28 +02:00
enricobuehler 2bb6af3b92 Merge pull request 'A launcher tile can open Heroic's console mode' (#402) from worktree-heroic-console-and-runners into main
ci / bun-nix (push) Successful in 39s
ci / web (push) Successful in 1m17s
ci / docs-site (push) Successful in 1m18s
ci / docs-drift (push) Successful in 32s
ci / rust-arm64 (push) Successful in 1m49s
deb / build-publish-gamescope (push) Successful in 34s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 21s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 35s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
deb / build-publish-client-arm64 (push) Successful in 1m48s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 12s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 13s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 14s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m6s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m38s
docker / builders-arm64cross (push) Successful in 13s
ci / rust (push) Successful in 6m59s
deb / build-publish (push) Successful in 5m49s
deb / build-publish-host (push) Successful in 6m14s
docker / deploy-docs (push) Successful in 39s
android / android (push) Successful in 9m57s
arch / build-publish (push) Successful in 10m13s
deb / smoke-install (push) Successful in 5m4s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 19m46s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 20m43s
windows-host / package (push) Canceled after 8m57s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
2026-08-26 17:26:18 +00:00
enricobuehler 2a951a6bb5 DSCP marks the peers the local network owns (ABR overhaul Phase 1, §2.5)
apple / swift (pull_request) Successful in 2m7s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m19s
ci / web (pull_request) Successful in 2m1s
ci / docs-site (pull_request) Successful in 2m4s
ci / docs-drift (pull_request) Successful in 38s
ci / bun-nix (pull_request) Successful in 1m13s
android / android (pull_request) Successful in 6m37s
ci / rust (pull_request) Successful in 7m40s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m53s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m13s
The marking was opt-in because some consumer ISPs/routers bleach or
reject DSCP-tagged packets — but that risk lives on WAN paths, and the
win (APs map DSCP to WMM airtime priority — real for exactly the Wi-Fi
downlink field case) lives on local ones. The default becomes AUTO:
mark toward RFC1918 / ULA / link-local / loopback peers, stay off
toward anything routable.

PUNKTFUNK_DSCP=1 still forces it on everywhere, =0 stays the kill
switch, and the embedder hook (the Android low-latency tie-in) still
forces on regardless of the peer — for paths like VPNs the address
math can't recognize as local. Decision + classifier are pure and
unit-tested; the connected-loopback test covers the AUTO path end to
end. Windows qWAVE mechanics unchanged.
2026-08-26 19:16:31 +02:00
enricobuehler 5bf64e07bf The control loop stops believing its own bookkeeping (ABR overhaul Phase 2)
apple / swift (pull_request) Successful in 2m16s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m13s
ci / bun-nix (pull_request) Successful in 23s
ci / docs-drift (pull_request) Successful in 26s
android / android (pull_request) Successful in 5m44s
ci / docs-site (pull_request) Successful in 3m59s
ci / web (pull_request) Successful in 4m35s
ci / rust (pull_request) Successful in 9m53s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m21s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m56s
The four correctness seams from the 08-22 auto-bitrate review §2, chosen
options per the RFC (planning design/abr-stack-overhaul.md §3):

- §2.3: a failed bitrate-change encoder rebuild now snaps the client back
  (retarget_tx) — the control task acks BEFORE the apply, so the client's
  climb base, utilization and proven math tracked a rate the encoder
  never ran until some later event happened to correct them.
- §2.2: the ABR rebuild announces PipelineGap on success, like the
  mode-switch and topology rebuilds already do — a ~0.6 s host-local
  stall read as congestion killed slow start for the session (the 401 ms
  field case: minutes at ~15 Mbps on a clean link).
- §2.1: an accepted mode switch re-teaches the stream-shape cap —
  computed once from the Welcome mode, 1080p→4K kept a 1080p-sized climb
  ceiling and 4K→720p left an oversized one standing. A re-set
  set_stream_cap also rebinds the already-learned ceiling downward
  (set_ceiling deliberately never lowers); the FIRST set keeps the
  founding semantics, pinned by the existing stream-bound test.
- §2.4: the bitrate_ack slot becomes a queue drained in arrival order —
  latest-wins collapsed a full resolve ack + corrective short retarget
  landing in the same 750 ms window, and host-cap learning needs two
  CONSECUTIVE short acks.

punktfunk-core --features quic: 490 tests green natively, including the
new a_mode_switch_reteaches_the_stream_cap_both_ways.
2026-08-26 19:10:28 +02:00
enricobuehler ae13b29abd feat(host/library): launcher_ui can open Heroic's console mode
ci / docs-site (pull_request) Successful in 1m18s
ci / web (pull_request) Successful in 1m21s
ci / docs-drift (pull_request) Successful in 28s
ci / bun-nix (pull_request) Successful in 1m54s
ci / rust-arm64 (pull_request) Successful in 2m45s
android / android (pull_request) Successful in 5m18s
ci / rust (pull_request) Successful in 10m21s
Heroic 2.21 added a fullscreen gamepad UI — the Big Picture of that launcher,
and the tile you actually want on a stream. Nothing could publish it: a plugin
names a launcher UI and the host builds the line, and the only Heroic value the
host knew was `heroic`, which opens the ordinary window.

`launcher_ui` now also takes `heroic-console` on Linux, resolving to the same
prefix `heroic` does — native binary if on PATH, else the Flatpak — plus
`--console --fullscreen`. Both flags, because Heroic reads them separately:
`--console` only routes the UI to that front end (`isCLIConsoleMode`) and
`--fullscreen` is what fills the screen (`isCLIFullscreen`). No URI can do it —
`heroic://` speaks `ping` and `launch` and nothing else — which is the same
reason Playnite's fullscreen tile spawns its exe directly rather than going
through the registered protocol handler. An older Heroic ignores the unknown
`--console` and still honours `--fullscreen`, so the tile degrades to a
fullscreen desktop UI rather than to nothing.

That makes the value a launcher UI rather than a launcher. It already was one on
Windows, where `playnite` has always meant `Playnite.FullscreenApp.exe`; the doc
comment claiming otherwise is corrected. A `heroic_ui` kind mirroring `steam_ui`
would have been tidier and was rejected on the compatibility asymmetry: an
unknown KIND degrades to an unlaunchable tile, but an unknown VALUE is a hard
400 that refuses the whole reconcile, so either shape has to be gated on
`minHost` in the plugin index and the value is much the smaller change. A plugin
publishing `heroic-console` must set `minHost` to the release carrying this.

Second fix, from the same reading: `resolvable_launcher_ui` special-cased only
Playnite-on-Windows and answered `true` for everything else, so a Heroic tile
survived `sanitize_launcher_entries` even when `heroic_launch_prefix()` resolves
to nothing. Keeping `~/.config/heroic` after uninstalling Heroic is enough to
reach that — the plugin's `detect` only looks for the directory — and the
operator got a launcher tile that does nothing. Both Heroic values now probe the
prefix, exactly as Playnite probes for its exe.
2026-08-26 19:09:56 +02:00
enricobuehler 9c278ee351 The transmission plane stops decaying to a blast (ABR overhaul Phase 1)
ci / bun-nix (pull_request) Successful in 23s
ci / docs-drift (pull_request) Successful in 28s
ci / docs-site (pull_request) Successful in 1m11s
ci / rust-arm64 (pull_request) Successful in 1m30s
ci / web (pull_request) Successful in 1m42s
ci / rust (pull_request) Successful in 6m18s
android / android (pull_request) Successful in 6m32s
Static content used to strip every defense before motion: the unpaced
microburst allowance was an absolute max(128 KiB, wire/4) — sized for
gigabit LAN, so at Wi-Fi bitrates every frame went out back-to-back —
and adaptive FEC decayed to 1 %, so the first big motion frame died
unprotected (the 2026-08-26 field case; RFC design/abr-stack-overhaul.md
in punktfunk-planning, §2.1–2.4).

- The allowance is now TIME at the pace rate: 10 ms, clamped to
  [16 KiB, 256 KiB] (send_pacing::auto_burst_bytes). One constant lines
  up both proven ends — ~19 KiB at the field case's 15 Mbps pace (its
  discriminator was PACE_BURST_KB=16) and ~112 KiB at 90 Mbps LAN pace,
  so LAN latency does not regress. PACE_FACTOR=0 keeps the legacy rule.
- A streamed AU consumes ONE allowance across its block flushes instead
  of a fresh one per block (the latent per-block multiplication).
- One frame's paced spread is bounded to ~2 frame intervals so an IDR
  can't back the encode|send sync_channel(3) up into cadence_degraded,
  which refuses every climb; MAX_PACE_SPREAD stays the absolute bound.
- Adaptive FEC gains a burned floor: any window with real shard loss
  raises the decay floor to 5 %; ~2 clean minutes re-earn 1 %, an early
  re-burn doubles the requirement (bounded), a durable step-down resets
  it — no session-permanent latch, per the encode stand-down's house
  rule. PUNKTFUNK_FEC_PCT pin semantics and the GameStream plane are
  untouched.

Pure logic (FecFloor, auto_burst_bytes, native_budget) is unit-tested;
the burned-floor band is noted in the FEC_PCT docs.
2026-08-26 18:58:28 +02:00
enricobuehler f79e9eb524 fix(encode): verify NVENC sub-frame readback in release, and stop trusting a driver that lied
ci / docs-drift (pull_request) Successful in 36s
ci / bun-nix (pull_request) Successful in 37s
ci / web (pull_request) Successful in 1m9s
ci / docs-site (pull_request) Successful in 1m41s
ci / rust-arm64 (pull_request) Successful in 1m46s
android / android (pull_request) Successful in 5m12s
ci / rust (pull_request) Successful in 5m34s
The chunked poll ships slice bytes the moment the doNotWait lock publishes
them, trusting that they are a byte-exact prefix of the AU the finishing
blocking lock returns. The check for that trust — the ChunkState shadow
comparison — was compiled out of release builds, so a driver branch whose
sub-frame accounting runs ahead of the flushed bytes shipped not-yet-written
buffer content straight to the wire: self-consistent tiling, wrong bytes,
no client counter moving. That is the black-band field report (Windows
client, 4090 host, identical across all three decoders, Android immune —
it never negotiates the slice wire), and most likely the Linux/AMD grey
smears too.

Make the shadow prefix check unconditional (one AU-sized copy + compare
per frame — noise next to the encode), and on divergence latch
subframe_broken before bailing into the existing encode-stall recovery:
the in-place rebuild now resolves sub-frame OFF for every later session
open on this encoder, so the driver that lied is disarmed after exactly
one rebuild instead of looping into MAX_ENCODER_RESETS. The client sees
the abandoned-AU path it already handles (ages the frame out, IDR
re-anchors) — a one-second hiccup, once, instead of endless corruption.
Both direct-NVENC backends (Windows D3D11, Linux CUDA) get the identical
change; the bail path also stops leaking the popped frame's input mapping.
2026-08-26 18:45:20 +02:00
enricobuehler eed7b5e589 fix(ci): pin the tools the builder images bake in, not just the ones the jobs fetch
ci / bun-nix (pull_request) Successful in 28s
ci / docs-drift (pull_request) Successful in 30s
ci / web (pull_request) Successful in 1m14s
ci / docs-site (pull_request) Successful in 1m15s
ci / rust-arm64 (pull_request) Successful in 2m11s
apple / swift (pull_request) Successful in 2m11s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Successful in 5m47s
android / android (pull_request) Successful in 6m31s
nix / flake (pull_request) Successful in 6m35s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m52s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m15s
The workflow-level pins closed the bootstrap path only. On the normal path
the bun that gets vendored into the published .deb/.rpm comes from the
builder image, and every image still installed it with the upstream
installer piped into bash — so the script still chose bytes that ship to
users. rust-ci and fedora-rpm now take the same pinned, SHA-256-checked
bun-v1.3.14 asset the workflows do; arch-ci takes bun from pacman, which
verifies package signatures.

Same class, found while sweeping and worse than the cited sites: five
images fetched sccache over a version-pinned URL with no integrity check at
all. sccache is RUSTC_WRAPPER for every binary we ship — it can serve
poisoned object files straight into a signed package, which is the position
the review called the highest-leverage in a build. Download, verify against
upstream's published sum, then extract.

packaging/flatpak/build-flatpak.sh took flatpak-cargo-generator.py from a
mutable master, the same fetch flatpak.yml just pinned; both now name the
same commit and sum, so the local build and CI agree.

Arch note: bun rides the existing -Syu transaction rather than a later
layer. A separate layer resolves against the DB baked into the CACHED -Syu
layer, and Arch mirrors carry only current versions, so a cache-hit rebuild
months on would fail to fetch a package the stale snapshot names.

None of this takes effect until the images are rebuilt: docker.yml's
`builders` job keys on the git tree hash of ci/, so this re-keys the whole
family. Until then the workflow bun pins sit behind `command -v bun ||` and
short-circuit against the image's baked bun.

rustup's own installer is left piped, as apple.yml already does — pinning
rustup-init is a separate decision, and the same argument reaches every
image at once.
2026-08-26 18:17:26 +02:00
enricobuehler 933074fafc feat(win-display): the adl-emul Probe walks headless AMD adapters — the lab rung needs it 2026-08-26 18:12:05 +02:00
enricobuehler 54c3414f36 fix(win-display): the adl-emul probe says which adapters it skipped, and why
First .173 run: 15 logical adapters enumerated, zero connectors walked, exit 0,
no explanation — a probe whose deliverable is an rc must never end silent. One
record per distinct (bus, vendor, present) shape before the filter.
2026-08-26 18:11:07 +02:00
enricobuehler 637d438532 test(amf): live readback proof for applied_bitrate_bps on real VCN hardware 2026-08-26 18:00:27 +02:00
enricobuehler 1511374959 PyroWave forces Automatic bitrate (ABR overhaul RFC §5.2)
apple / swift (pull_request) Successful in 2m5s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 2m27s
ci / rust-arm64 (pull_request) Successful in 3m37s
ci / bun-nix (pull_request) Successful in 29s
ci / docs-drift (pull_request) Successful in 55s
ci / docs-site (pull_request) Successful in 2m24s
android / android (pull_request) Successful in 7m51s
ci / rust (pull_request) Successful in 8m18s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m48s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 5m55s
An explicit client rate under PyroWave was ill-defined (all-intra bpp
semantics — the operating point is bits per pixel, not kbps) and bypassed
the PUNKTFUNK_PYROWAVE_MAX_MBPS operator ceiling entirely.

Host: resolve_bitrate_kbps_for ignores the requested rate under PyroWave
(warn when overriding) so every PyroWave session goes through the per-mode
bpp pin + ceiling, and bitrate_auto treats PyroWave sessions as Automatic
so mode switches re-resolve the pin whatever the Hello carried.

Clients: pf-client-core sends bitrate 0 when the preference is an
ADVERTISED PyroWave (a failed decode probe falls back to H.26x, where the
user's rate must survive); the Apple client mirrors the same gate at its
own Hello site. The console UI and the Apple settings dim the bitrate
control with a one-line explanation instead of offering an inert rate.
The stored setting is preserved everywhere — switching codecs back
restores it.
2026-08-26 17:43:31 +02:00
enricobuehler f632ee68d2 fix(windows,ci,zerocopy): finish the deferred half — a real prompt, the rest of the pipes, and an arch bug CI cannot see
ci / docs-drift (pull_request) Successful in 28s
ci / bun-nix (pull_request) Successful in 28s
ci / docs-site (pull_request) Successful in 1m25s
ci / web (pull_request) Successful in 1m26s
ci / rust-arm64 (pull_request) Successful in 2m8s
apple / swift (pull_request) Successful in 2m15s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 6m52s
ci / rust (pull_request) Successful in 7m19s
nix / flake (pull_request) Successful in 9m1s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 4m15s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 7m21s
Follow-ups left open by the security-review pass.

The Windows client had the deep-link fix's enforcement but not its UX: a
name- or address-resolved link was REFUSED with a note pointing at the host
list, marked in-code as degraded, because that shell had no prompt surface
wired up. It has one — `ContentDialog`, already used for "Remove saved
host?" and "Delete profile?" — so the link now asks, naming the host and
anything it wants to launch, and on confirm runs the identical dial the
id-referenced path runs. Same wake, trust and error surfaces; confirming is
one click in front of the same code.

The supply-chain pins only covered the jobs the report cited. deb.yml
(a signing-key job) and arch.yml still piped bun's installer into bash;
flatpak.yml still took the deploy host key on first contact while holding
FLATPAK_GPG_PRIVATE_KEY. deb now takes the same pinned, SHA-256-checked bun
asset as rpm and windows-host; arch takes bun from pacman, which verifies
package signatures; flatpak verifies the host key from DEPLOY_KNOWN_HOSTS
through the fail-open-through-setup preflight it already had.

Found while sweeping, worse than the cited sites: flatpak.yml fetched
flatpak-cargo-generator.py from a mutable `master` and ran it in the job
holding the GPG key — third-party Python deciding which crate sources the
signed build vendors. Pinned to a commit and checksummed.

pf-zerocopy's EGL bindings declared glShaderSource/glGetUniformLocation as
`*const i8`, but `CStr::as_ptr()` yields `*const c_char` — i8 on x86_64,
u8 on aarch64. So the crate did not compile for aarch64 at all, and CI is
x86_64-only and could never see it. Verified both directions: with c_char
`cargo check -p pf-zerocopy --target aarch64-unknown-linux-gnu` passes,
and the old spelling still fails E0308 there.

Docs: the note saying Windows has no prompt is gone, and the first-connect
sentence put Windows in the wrong group — it pre-fills its trust prompt
from the link like Linux and Android; only the Apple apps show a notice.
Corrected an Apple comment claiming it seeds the add sheet with the address
and fingerprint when it only sets a notice string.
2026-08-26 17:40:31 +02:00
enricobuehler b43363b141 feat(capture,encode): AMD program wave 2 — the field log self-describes, and AMF reports its applied bitrate
- Every capture session stamps a 'GPU-priority posture' INFO line (both REALTIME
  opt-ins) at open, so a stalling log carries the levers even when no WARN fires.
- Repeated stalls WITHOUT a stable period now WARN with the full triage payload
  (tallies, suspects, levers) — the 2026-08-26 7700 XT log had 6 holes in 8 s and
  zero guidance because only the metronomic arms spoke.
- CONTENT-SILENCE prose stops overselling benignity: a frozen presenter
  (disturbance-immunity Flavor 3) reads identically, and our probes all run at the
  host's elevated GPU priority.
- Native AMF implements applied_bitrate_bps via a GetProperty readback (typed the
  existing vtable slot), so encoder_ceiling learning / the ABR overdrive guard stop
  being inert on AMD; optional-property rejections log at INFO and the encode-active
  line carries ltr/intra_refresh — the VCN capability matrix builds itself from
  field logs.
- Doc drift: PUNKTFUNK_GPU_PRIORITY_CLASS default is high (not auto) everywhere it
  is described; PUNKTFUNK_IDD_ADAPTIVE documented; troubleshooting names the
  REALTIME-lever first step and the new repeating-stall warning.

Design: punktfunk-planning design/windows-amd-host-program.md §3.1–§3.3 wave 2.
2026-08-26 17:32:26 +02:00
enricobuehler 1198522931 Merge pull request 'A slice block's lying base can no longer ship as a complete frame' (#398) from worktree-slice-tiling-guard into main
android / android (push) Canceled after 56s
arch / build-publish (push) Canceled after 58s
ci / rust (push) Canceled after 57s
ci / rust-arm64 (push) Canceled after 57s
apple / swift (push) Successful in 2m4s
ci / web (push) Canceled after 46s
ci / docs-site (push) Canceled after 3s
ci / bun-nix (push) Canceled after 0s
ci / docs-drift (push) Canceled after 0s
deb / build-publish (push) Canceled after 2s
deb / build-publish-host (push) Canceled after 2s
deb / build-publish-gamescope (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
deb / smoke-install (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 5s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 4s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 4s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
apple / distribute (push) Successful in 13m33s
apple / screenshots (push) Successful in 10m10s
Reviewed-on: #398
2026-08-26 14:48:11 +00:00
enricobuehler 79982060c3 Merge pull request 'The Fedora install line must name punktfunk-web, not hope for it' (#395) from worktree-fedora-web-weakdep 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
ci / docs-drift (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
installer-smoke / smoke (arch) (push) Successful in 45s
installer-smoke / smoke (fedora-44) (push) Successful in 1m43s
installer-smoke / smoke (debian-13) (push) Successful in 2m39s
Reviewed-on: #395
2026-08-26 14:45:13 +00:00
enricobuehler fc6060f274 Merge pull request 'KWin's stored setup darkened the desk behind our back, and a TV remote could not leave the first settings tab' (#397) from worktree-kwin-stored-config-strand into main
ci / web (push) Successful in 1m22s
ci / docs-site (push) Successful in 1m22s
ci / bun-nix (push) Successful in 1m24s
ci / docs-drift (push) Successful in 30s
ci / rust-arm64 (push) Successful in 2m12s
deb / build-publish-gamescope (push) Successful in 57s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 17s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15s
deb / build-publish-client-arm64 (push) Successful in 2m1s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 14s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 20s
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 24s
deb / build-publish (push) Successful in 3m59s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m4s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m25s
ci / rust (push) Successful in 7m10s
docker / builders-arm64cross (push) Successful in 11s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m37s
deb / build-publish-host (push) Successful in 5m58s
docker / deploy-docs (push) Successful in 40s
android / android (push) Successful in 9m18s
arch / build-publish (push) Successful in 9m51s
flatpak / build-publish (push) Successful in 4m37s
deb / smoke-install (push) Successful in 3m20s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 7m22s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m7s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m48s
windows-host / package (push) Successful in 12m13s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 21s
2026-08-26 14:44:44 +00:00
enricobuehler 4399664217 feat(console-ui): the legend speaks the device in hand — remote marks on TV, Nintendo's own letters, keys vs pad tracked at the source
ci / docs-drift (pull_request) Successful in 37s
ci / bun-nix (pull_request) Successful in 1m6s
ci / web (pull_request) Successful in 1m24s
ci / docs-site (pull_request) Successful in 1m26s
ci / rust-arm64 (pull_request) Successful in 1m54s
android / android (pull_request) Successful in 6m7s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m54s
ci / rust (pull_request) Successful in 6m40s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m14s
Follow-up from the same Chromecast field report: the hint legend only ever
spoke gamepad (or desktop-keyboard keycaps), whatever was actually driving.
A TV remote got Enter/Esc/Tab — keys it does not have — and a Switch pad's
badges read the Xbox letters, so 'A Select' sat over the button engraved B.

The shell now notes WHAT drove last at every input seam — the Android host
loop labels its two menu paths (discrete events are the remote, the pad
sampler is the pad), the desktop overlay's menu channel is pad by
construction, and the keyboard path notes itself. Each frame the legend
resolves from that:

* a pad speaks its own family — PlayStation shapes, Nintendo letters (both
  pairs swapped, the way the pad is engraved), ABXY for the rest;
* keys on Android are a TV remote: an OK badge for select, the ↩ return
  arrow for back, the section hint pointing at the D-pad path (▲) that
  reaches the tab strip, and the Y/X hints hidden outright — a remote has
  neither, and advertising a button the device cannot press is worse than
  silence;
* keys on the desktop stay keyboard keycaps;
* nothing has driven yet: the connected pad's family, else the platform's
  key device — so an untouched console reads right on every device.

The controller chip grows a remote mark and says 'TV remote' when that is
what is driving; a pointer press changes nothing, since a tap says nothing
about the buttons in the user's other hand. The screen dump gains the two
remote frames (Home + Settings) for the eyeball pass.
2026-08-26 16:42:06 +02:00
enricobuehler e684b3e4bd fix(core): a slice block's lying base can no longer ship as a complete frame
ci / docs-drift (pull_request) Successful in 27s
ci / bun-nix (pull_request) Successful in 52s
ci / docs-site (pull_request) Successful in 1m10s
ci / web (pull_request) Successful in 1m39s
apple / swift (pull_request) Successful in 2m10s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m50s
ci / rust (pull_request) Successful in 5m29s
android / android (pull_request) Successful in 6m27s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 4m5s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 7m21s
A slice-streamed sentinel's wire base was bounds-checked (in range, below
the final block) but never validated to TILE the AU: a base that lied
within bounds left a zero gap and an overlap, and the reassembler still
stamped the frame `complete`. The decoder then painted the wrong-offset
bytes as garbage rectangles while no loss counter moved — so the recovery
machinery never fired and the corruption marched on indefinitely (the
field report: black bars 'moving like an equalizer', identical across
Vulkan/DXVA/CPU decoders, Android immune because it never negotiates the
slice wire).

At completion, verify the blocks tile [0..total_data] contiguously; a
frame that doesn't is killed exactly like a lying final header — index
remembered against stragglers, counted into frames_dropped, which is the
signal that arms the freeze gate and requests recovery. The
slice-progressive prefix walk gets the same contiguity guard so a lying
base can't extend a 'contiguous' prefix either.
2026-08-26 16:28:11 +02:00
enricobuehler 1b8d4799ef fix(inject): the doc naming the write we removed trips the grep gate
ci / rust-arm64 (pull_request) Successful in 1m58s
ci / web (pull_request) Successful in 1m45s
apple / swift (pull_request) Successful in 2m4s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m51s
ci / bun-nix (pull_request) Successful in 28s
ci / docs-drift (pull_request) Successful in 24s
android / android (pull_request) Successful in 6m24s
nix / flake (pull_request) Successful in 6m14s
ci / rust (pull_request) Successful in 14m22s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m44s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m8s
Gate C in check-unsafe-hygiene.sh matches the qualified `env::set_var`
anywhere in a file, comments included — deliberately, so the contract stays
dumb and stable. The new SESSION_BACKEND doc explained what it replaced by
naming it, which reads as a fresh call site to the gate. Reword rather than
raise the baseline: the baseline is there to catch a real write later, and
this change deleted one.
2026-08-26 09:32:39 +02:00
enricobuehler 8c4b1b8c62 style: the two folds rustfmt wanted (ci / rust fmt gate)
ci / web (pull_request) Successful in 1m16s
ci / docs-site (pull_request) Successful in 1m21s
ci / bun-nix (pull_request) Successful in 21s
ci / docs-drift (pull_request) Successful in 20s
ci / rust-arm64 (pull_request) Successful in 4m25s
android / android (pull_request) Successful in 8m37s
ci / rust (pull_request) Successful in 9m9s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m0s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m57s
2026-08-26 09:29:30 +02:00
enricobuehler 98e68a49a7 fix(host,web,clients,ci): the comments were the spec, and the code had drifted
ci / bun-nix (pull_request) Successful in 35s
ci / docs-drift (pull_request) Successful in 35s
ci / docs-site (pull_request) Successful in 1m18s
ci / web (pull_request) Successful in 1m42s
apple / swift (pull_request) Successful in 2m16s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 6m46s
ci / rust (pull_request) Failing after 4m25s
android / android (pull_request) Canceled after 9m18s
nix / flake (pull_request) Canceled after 6m51s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m56s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Canceled after 1s
Security review 2026-08-25, 58 confirmed findings across host, console,
clients and supply chain. Nearly every serious one is a documented boundary
whose code stopped enforcing what its comment promised — so where the two
disagreed, the comment won and the code was made to match, and where it
could not be, the comment was corrected instead.

Critical — a console session cookie alone reached code execution: every
pairing route rode the generic catch-all with the operator's admin bearer
attached. Arming, approving and PIN submission now sit behind the console
password like the other trust-root routes, and the armed PIN is returned
once in that gated response instead of riding a 1 s status poll.

High — the plugin lane no longer reads the unredacted log ring (which
carried the webhook credentials the /hooks carve-out exists to withhold);
hook lines log an origin and a short id, never a URL or a command line; a
plugin-reported pid is held to procscan's start-time floor before the
SYSTEM host will signal it; ClipOffer is gated on the live grant mask, so a
revoked guest loses the host clipboard in both directions; ENet refuses
connects with no live launch instead of letting LAN peers squat all four
slots; Windows secrets are born with their DACL applied rather than
world-readable; the sysext feed binds FEED and a monotonic SERIAL inside
the signed bytes; privileged_field allowlists the host-resolved launch
kinds so a new kind is privileged by default; five parser panics reachable
from one malformed NALU are range-checked; release-signing jobs pin bun,
sccache and actions by checksum/SHA; h2 -> 0.4.19 (RUSTSEC-2026-0258).

Deep links only auto-dial by stable record id now — a display name or an
address gets a confirmation on every client. The Apple identity key moves
to ThisDeviceOnly so it stops riding encrypted backups.

pf-vdisplay stops routing session identity through the process environment:
the injector backend threads through a typed slot, so per-batch getenv no
longer races a per-session setenv. The four remaining writes have no
in-repo readers and are documented as such; the SAFETY proof that claimed
ENV_LOCK made them sound is gone.

Verified: cargo clippy --workspace --all-targets --locked -D warnings and
cargo fmt --all --check clean in the CI image; web builds, tsc --noEmit
clean, 22/22 server tests; Swift debug+release + 26/26, Kotlin :kit 7/7.

Not fixed, deliberately: the plugin token can still mint command execution
(the plugin launch kind exists so a plugin names a command the host runs —
per-plugin identity does not change that, and the runner is one process
hosting fibers, so there is nowhere to hang a credential); the shared
plugin-UI origin; the rollback Authenticode publisher pin (Azure mints a
fresh leaf per request, and the signer subject is not in the tree). Each is
now described accurately where it lives instead of being claimed closed.
2026-08-26 09:22:36 +02:00
enricobuehler b64ac3cb32 fix(console-ui): the settings tabs answered only to shoulders — Up from the top row puts a TV remote's D-pad on the strip
ci / web (pull_request) Successful in 1m4s
ci / rust-arm64 (pull_request) Successful in 1m25s
ci / docs-drift (pull_request) Successful in 24s
ci / docs-site (pull_request) Successful in 1m6s
ci / bun-nix (pull_request) Successful in 1m11s
ci / rust (pull_request) Failing after 4m4s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 4m27s
android / android (pull_request) Canceled after 3m42s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Canceled after 1m17s
A Chromecast field report: with only the TV remote — the pad had dropped off,
as Chromecast pads do — every settings tab but the first was unreachable. The
split of the one 30-row scroll into tabs put section switching on L1/R1, Tab
and PgUp/PgDn, and a remote has none of those; the D-pad's left/right already
mean 'adjust the focused row'.

Up from the list's top row now steps onto the tab strip instead of recoiling:
left/right travel the ring (wrapping, like the shoulders), Down or A drop
back into the rows of the tab that's showing, B still leaves the screen, and
the shoulders keep working from either focus. The strip shows the handoff —
brighter pill, ‹ › chevrons — the rows rest their focus ring while it holds
focus, and the hint bar names the D-pad's meaning up there. A pointer press
on the rows takes the focus back.

The gamepad path is untouched: nothing changes until an Up is pressed on the
top row, which used to be a boundary recoil.
2026-08-26 09:22:30 +02:00
enricobuehler f92b093f92 fix(kwin): KWin's stored setup darkened the desk and the restore never saw it — snapshot before create, re-assert after teardown
A Bazzite triple-monitor field report: monitors cycling off/on around every
session, sessions ending with some or all monitors dark or at the wrong
resolution, and an exclusive apply logging also_disabled=[] on a desk with
three enabled physicals.

KWin persists output state per MONITOR SET (kwinoutputconfig.json). Creating
our virtual output changes the set, and a set that ever ran exclusive has
'physicals disabled' stored — so KWin disables them ITSELF, in the window
between our output's creation and the topology apply's enumeration. The apply
then finds nothing enabled, captures nothing for restore, and teardown
re-enables nothing: the desk stays dark, and KWin's own recovery improvises
modes. Under Extend/Primary the same stored setup switched screens off that
those topologies promise never to touch.

Three moves, all in the KWin backend:

* create() snapshots the enabled physicals BEFORE the virtual output exists —
  the only read KWin's reaction cannot have polluted.
* Exclusive: the snapshot joins the restore list (union_restore) — an output
  KWin disabled before we could see it is still re-enabled at teardown.
  Extend/Primary: reenable_stranded() puts the stored-config casualties back
  on, immediately and once more after a short delay (KWin can apply the
  stored setup after our first read). One shot each, never a loop.
* The group-teardown restore gains a delayed verify that re-asserts AFTER our
  output is reclaimed — under the without-us set, so KWin persists the heal
  into the stored setup instead of re-fighting us next session. It stands
  down if a new session's managed output already owns the topology.

A box stranded BEFORE this fix stays stranded once: the snapshot cannot see
outputs that were already dark. Re-enabling them once in System Settings
while no stream is live writes the healthy without-us setup back, and the
loop cannot re-form.
2026-08-26 09:22:11 +02:00
enricobuehler b9adcc4897 fix(docs): the Fedora install line must name punktfunk-web, not hope for it
ci / docs-drift (pull_request) Successful in 29s
ci / bun-nix (pull_request) Successful in 29s
ci / web (pull_request) Successful in 1m8s
ci / docs-site (pull_request) Successful in 1m19s
ci / rust-arm64 (pull_request) Successful in 1m27s
installer-smoke / smoke (arch) (pull_request) Successful in 1m27s
installer-smoke / smoke (fedora-44) (pull_request) Successful in 3m7s
installer-smoke / smoke (debian-13) (pull_request) Successful in 3m46s
ci / rust (pull_request) Successful in 6m26s
`punktfunk` only *Recommends* punktfunk-web (and punktfunk-scripting), so
`sudo dnf install punktfunk` installs the host with no console on any box where
weak deps are off — install_weak_deps=False, --setopt=install_weak_deps=0, or an
rpm-ostree layering that drops them. The user then follows step 4 and gets
"Unit punktfunk-web.service could not be found", with nothing saying why.

Name both subpackages in the install line at its single source
(data/platforms.json + the docs-site snapshot), which the guided installer and
the Fedora page quote verbatim, and drop the "the console comes with it" claim
from the README. Adds the symptom to Troubleshooting, including the other cause:
COPR has no punktfunk-web at all (no bun in its mock chroot).
2026-08-25 21:00:10 +02:00
enricobuehler d2c6e1c9c0 Merge pull request '0.31.4 — the screens that stayed lit, the power menu that did nothing, and the console no browser would open' (#394) from worktree-release-0314 into main
audit / bun-audit (web) (push) Successful in 24s
audit / bun-audit (sdk) (push) Successful in 25s
audit / bun-audit (plugin-kit) (push) Successful in 26s
audit / docs-site-audit (push) Successful in 24s
audit / pnpm-audit (push) Successful in 12s
audit / cargo-audit (push) Successful in 43s
ci / rust-arm64 (push) Successful in 1m40s
ci / web (push) Successful in 1m25s
ci / docs-site (push) Successful in 1m11s
ci / bun-nix (push) Successful in 33s
ci / docs-drift (push) Successful in 39s
audit / license-gate (push) Successful in 6m4s
audit / c-abi-asan (push) Successful in 7m33s
audit / miri (push) Successful in 7m48s
android-screenshots / screenshots (push) Successful in 1m17s
ci / rust (push) Successful in 16m49s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 8m34s
android / android (push) Successful in 10m54s
decky / build-publish (push) Successful in 58s
arch / build-publish (push) Successful in 12m5s
sbom / sbom (push) Successful in 42s
linux-client-screenshots / screenshots (push) Successful in 6m18s
flatpak / build-publish (push) Successful in 7m39s
docker / builders-arm64cross (push) Successful in 14s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 18s
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 15s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 15s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 15s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 18s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 17s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 30s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m30s
docker / deploy-docs (push) Successful in 29s
nix / flake (push) Successful in 25m39s
deb / smoke-install (push) Successful in 3m41s
deb / build-publish (push) Successful in 6m45s
deb / build-publish-host (push) Successful in 5m29s
deb / build-publish-gamescope (push) Successful in 37s
deb / build-publish-client-arm64 (push) Successful in 1m28s
apple / distribute (push) Successful in 13m7s
apple / swift (push) Successful in 2m0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m52s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m52s
apple / screenshots (push) Successful in 9m32s
windows-host / package (push) Successful in 12m0s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 26s
web-screenshots / screenshots (push) Successful in 6m16s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m12s
Reviewed-on: #394
2026-08-24 22:26:27 +00:00
enricobuehler 1d755ebeeb release: 0.31.4 — version bump, notes, CHANGELOG, Play notes
ci / bun-nix (pull_request) Successful in 39s
ci / docs-drift (pull_request) Successful in 39s
ci / web (pull_request) Successful in 1m13s
ci / docs-site (pull_request) Successful in 1m18s
apple / swift (pull_request) Successful in 2m12s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 4m59s
android / android (pull_request) Successful in 8m34s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 8m7s
nix / flake (pull_request) Successful in 8m13s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m56s
ci / rust (pull_request) Successful in 18m25s
21 commits since v0.31.3 (14 non-merge). Cut from origin/main c407f6a6 (#393
merged).

THE NUMBER: a patch, and the cleanest one in a while — NOTHING versioned moves.
WIRE_VERSION 2, C ABI 25 with include/punktfunk_core.h showing NO diff against
the v0.31.3 tag (nor against v0.31.2 or v0.31.1 — third release running), driver
protocol 6 / min 3 with pf-driver-proto unchanged, gamepad channel 3, plugin
index schema 1, host event schema 1 (events.rs no diff), gamescope +pfhdr8 with
no new patch files, SDK 0.1.6 and plugin-kit 0.4.4 both untouched. api/openapi.json
is CONTENT-identical — info.version is the entire diff, the first release since
v0.31.0 where the management API grows nothing. No lockfile moves, in any of the
five. Four feat commits, all additive and all client- or Linux-host-side; v0.31.3
and v0.31.1 each carried two feats as a patch on the same reasoning.

THE SHAPE: Linux-host, and the faults share a premise — a policy written against
ONE desktop that silently did nothing on the others. Topology::Exclusive on a
gamescope session was implemented as org_kde_kwin_dpms and reached only a live
KDE desktop, so a Game Mode box (the deployment whose TV the operator most wants
dark) got nothing: #389 is five commits closing that — a DRM floor for a box with
no desktop at all, sway and Hyprland arms, the managed route's own hold, and the
box-session free that was gated on is_steam_launch. The sleep:idle BLOCK
inhibitor refused every Suspend() for a stream's length including the operator's
own from Steam's power menu, and on a DM-stopping takeover polkit's allow_active
fallback found no session to elect and dropped all three power actions to
auth_admin_keep (#391). The web console served the LEGACY identity because every
launcher names that pair and none of them CAN choose, so browsers rejected it and
the tray's mgmt-pinned probe refused the handshake and labelled a healthy console
dead (#393). Plus the client's encode down-driver (#392), an installTap format
snapshot reaching the ObjC terminate handler (#387), the console's bitrate row
(#388), and a GTK front door for the console UI (#390).

TWO ENTRIES WORTH THE READER'S ATTENTION, both recorded as such:
  * The sway arm of #389 is NOT verified on glass — the lab has Bazzite, Nobara
    and SteamOS, no sway box. Hyprland, KDE and the DRM floor were all measured
    on real machines. The notes say so in "Thanks" rather than burying it.
  * #389's Hyprland arm shipped WRONG in its first cut and was corrected on glass
    (540e282e): the dpms dispatcher is a TOGGLE that ignores the state word, so
    the blind "send off, later send on" would have LIT an already-dark head at
    stream start and DARKENED a lit one at teardown — the exact failure the
    feature exists to prevent. Read -> act only if different -> verify.

DOCS FRESHNESS, per docs/releases/README.md step 1: #390 and #393 carried their
own docs-site updates (clients.md, and #389's two gamescope.md edits). Two facts
were left owed, both in gamescope.md and both the "stale sentence" class that
docs-drift cannot catch:
  * the darkening mechanism list still read "KDE, else DRM" and never learned
    about the sway and Hyprland arms from e0a82201;
  * the "could not deliver a dark screen" example was a box in Game Mode, which
    is now precisely the case that IS served by the DRM floor. The real remaining
    gap is GNOME/Mutter, so it says that instead.
Also: the punktfunk group grew a third grant this cycle (the three logind power
actions, via 49-punktfunk-power.rules), and the group's own callout block
enumerated only the usbip nodes — one sentence added there.
No new PUNKTFUNK_* variable this cycle (PUNKTFUNK_UI_TLS_CERT/KEY are
pre-existing and already in the baseline), no new host subcommand, and no install
command, repo URL or port change, so data/platforms.json and the website's
vendored copy need nothing.

VERIFIED HERE: scripts/ci/check-docs-drift.sh clean, and PROVEN LIVE rather than
assumed — a deliberate one-byte break of the openapi copy makes it fail, and it
passes again restored; scripts/ci/check-docs-links.sh clean; the android.yml Play
notes gate run verbatim, 481/500 characters and unique against every other
release's file; both openapi copies cmp identical and stamped 0.31.4; cargo fmt
--all --check clean; git diff v0.31.3..HEAD empty on include/punktfunk_core.h, on
crates/pf-driver-proto, on punktfunk-core/src/lib.rs, on punktfunk-host/src/events.rs
and on api/openapi.json, which is the direct evidence for those five version rows;
git diff v0.31.1..HEAD on the C header also empty, which is the "third release
running" claim; Cargo.lock's 36 workspace version strings moved with Cargo.toml
and zero 0.31.3 strings remain; 27 crate dirs / 39 workspace members, both
unchanged; notes voice scan clean (zero backticked terms above ## For developers,
and zero internal names on a scan for gamescope/polkit/logind/DPMS/CRTC/DRM/
systemd/kwin/mutter/wlroots/rustls/ioctl); CHANGELOG link pinned to
src/tag/v0.31.4.

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

LEFT AS A DECISION, not made here: the tag itself. Notes, CHANGELOG and Play text
are authored ahead of it exactly as docs/releases/README.md step 1 requires, so
whichever workflow wins the create race seeds a complete release body — but
pushing v0.31.4 fans out every build workflow, and that is step 2.
2026-08-24 23:34:38 +02:00
enricobuehler c407f6a6d9 The console served the identity nothing pins, and the tray called it dead (#393)
ci / bun-nix (push) Successful in 29s
ci / web (push) Successful in 1m12s
ci / docs-site (push) Successful in 1m27s
ci / rust-arm64 (push) Successful in 2m3s
ci / docs-drift (push) Successful in 2m8s
deb / build-publish-gamescope (push) Successful in 1m9s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 38s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 17s
deb / build-publish-client-arm64 (push) Successful in 1m45s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 26s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 28s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 25s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 13s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 15s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
deb / build-publish (push) Successful in 4m53s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m17s
deb / build-publish-host (push) Successful in 5m42s
ci / rust (push) Successful in 7m17s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m36s
docker / builders-arm64cross (push) Successful in 19s
docker / deploy-docs (push) Successful in 52s
android / android (push) Successful in 8m54s
arch / build-publish (push) Successful in 9m32s
deb / smoke-install (push) Successful in 4m10s
windows-host / package (push) Successful in 19m10s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 21s
nix / flake (push) Successful in 20m2s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 21m0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m30s
The web console never followed the identity split: every launcher names the legacy cert.pem/key.pem, so it served a CN-only, SAN-less RSA cert. Browsers reject that outright, and the tray's liveness probe — pinned to the native identity the mgmt API serves — could never complete the handshake, labelling a healthy console "not responding" beside a tooltip reading "idle".

Resolved at the Nitro entry, the one place every launcher routes through: prefer the native pair when both halves are present, usable and in the same directory; fall back to the legacy pair otherwise. The tray's console probe drops its pin rather than gaining a second one, and `serve` now mints the native identity before the legacy one.

Reviewed adversarially (no blockers; the one real finding — a pair that could be assembled from two different directories — fixed in 1e2b956d). All 8 checks green, including the native-only clippy gate. Verified end to end against a built server: both pairs → P-256 with SANs; legacy only → RSA as before; 0-byte native cert → clean fallback rather than a restart loop.
2026-08-24 20:55:06 +00:00
338 changed files with 21365 additions and 3766 deletions
+1 -1
View File
@@ -63,7 +63,7 @@ jobs:
image: 192.168.1.58:5010/punktfunk-android-ci:latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Promote
env:
+2 -2
View File
@@ -29,10 +29,10 @@ jobs:
image: 192.168.1.58:5010/punktfunk-android-ci:latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Cache (gradle)
uses: actions/cache@v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
+3 -3
View File
@@ -110,7 +110,7 @@ jobs:
image: 192.168.1.58:5010/punktfunk-android-ci:latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# FIRST, because it costs a second and everything after it costs ten minutes.
#
@@ -183,7 +183,7 @@ jobs:
# the rest of the fleet in the central cache. target/ is deliberately NOT cached
# anymore — sccache covers recompilation without shipping multi-GB tars per run.
- name: Cache (cargo registry)
uses: actions/cache@v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
/usr/local/cargo/registry
@@ -192,7 +192,7 @@ jobs:
restore-keys: cargo-home-
- name: Cache (gradle)
uses: actions/cache@v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
+5 -1
View File
@@ -29,7 +29,11 @@ jobs:
announce:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
# SHA-pinned, unlike the plain `@v4` the build workflows use: this job holds
# UPDATE_MANIFEST_KEY — the Ed25519 key every host pins to decide whether an update is real —
# and a tag is mutable, so whoever can move it runs code in front of that key. Same style as
# the appleboy pins in deploy-services.yml; the trailing comment is the release it resolves to.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Publish the SIGNED stable update manifest — the moment every host's update check learns
# about this release (planning: host-update-from-web-console.md §3.3). Deliberately here in
+3 -3
View File
@@ -156,7 +156,7 @@ jobs:
gitea.event.pull_request.head.repo.fork != true
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Rust toolchain (self-healing on a fresh runner)
run: |
@@ -220,7 +220,7 @@ jobs:
TEAM_ID: F4H37KF6WC
PROJECT: clients/apple/Punktfunk.xcodeproj
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Select release Xcode
run: |
@@ -690,7 +690,7 @@ jobs:
runs-on: macos-arm64
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Rust toolchain + iOS Simulator targets (+ nightly for the tvOS slices)
run: |
+10 -8
View File
@@ -115,10 +115,12 @@ jobs:
git nodejs rust clang cmake ninja nasm pkgconf python vulkan-headers \
gtk4 libadwaita sdl3 ffmpeg pipewire wayland libxkbcommon opus libei \
mesa libglvnd unzip libarchive || echo "::warning::pacman guard failed (stale image db?) — proceeding with baked packages"
command -v bun >/dev/null || {
curl -fsSL https://bun.sh/install | bash
install -m0755 "$HOME/.bun/bin/bun" /usr/local/bin/bun
}
# Arch ships bun in [extra], so the bootstrap takes the pacman-signed package instead of
# piping bun.sh's installer into root's shell — this job builds and publishes the package,
# and the installer would be upstream code choosing bytes we then ship. Kept behind the
# `command -v` guard rather than folded into the list above: the image's baked bun is not
# in pacman's db, so `--needed` cannot see it and would re-download bun on every run.
command -v bun >/dev/null || pacman -S --noconfirm --needed bun
bun --version
# THE BUILDER'S FFmpeg IS PART OF THE PACKAGE CONTRACT, not merely a build detail.
@@ -161,7 +163,7 @@ jobs:
fi
echo "healed: builder now links $HAVE"
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
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
@@ -172,7 +174,7 @@ jobs:
# clients/windows, whose windows-reactor/windows deps are git-pinned — cargo must CLONE
# them (windows-rs is huge) merely to resolve the workspace, even though nothing Windows
# is ever compiled here. Cached, that cost is paid once per runner.
- uses: actions/cache@v4
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
/usr/local/cargo/registry
@@ -239,7 +241,7 @@ jobs:
run: echo "bunver=$(bun --version 2>/dev/null || echo none)" >> "$GITHUB_ENV"
- name: Cache the built web console
id: webconsole
uses: actions/cache@v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: web/.output
key: web-console-linux-bun${{ env.bunver }}-${{ hashFiles('web/**', 'sdk/**') }}
@@ -357,7 +359,7 @@ jobs:
# push restores the built package instead of spending ~10 minutes on someone else's C++ tree.
# Arch is rolling, so the cache is invalidated by our own patch changes only — a stale binary
# against newer system libs is the same risk the distro's own package carries between rebuilds.
- uses: actions/cache@v4
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
id: gamescope
with:
path: dist-gamescope
+13 -13
View File
@@ -78,9 +78,9 @@ jobs:
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Cache /usr/local/cargo so the cargo-audit binary (and the advisory DB clone) persist.
- uses: actions/cache@v4
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
/usr/local/cargo/bin
@@ -119,7 +119,7 @@ jobs:
- name: Install git + CA certs
working-directory: /
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# `bun audit` queries the registry advisory DB for the versions pinned in the tree's
# bun.lock. No install/build needed — it reads the manifest + lockfile. Fails the job on any
# advisory, the same fail-on-vulnerability stance as cargo-audit above; triage a finding by
@@ -163,7 +163,7 @@ jobs:
- name: Install git + CA certs
working-directory: /
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: bun audit (non-blocking)
run: bun audit || echo "::warning::docs-site has known advisories (CMS/UI + nitropack chains) — tracked in punktfunk-planning design/cra-readiness.md"
@@ -176,7 +176,7 @@ jobs:
run:
working-directory: clients/decky
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# decky is pnpm-managed (pnpm-lock.yaml lockfileVersion 9.0 → pnpm 10 reads it). Like
# bun audit, `pnpm audit` needs no install/build — lockfile + registry advisory DB only.
# --prod: rollup bundles only the prod deps into the shipped plugin; devDependencies are
@@ -197,8 +197,8 @@ jobs:
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/cache@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
/usr/local/cargo/bin
@@ -291,21 +291,21 @@ jobs:
# sites; do not blanket-disable the check.
MIRIFLAGS: -Zmiri-disable-isolation -Zmiri-symbolic-alignment-check
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Two caches, split on purpose so a Cargo.lock change does not re-download a ~400 MB
# toolchain. Both use their OWN `miri-` key prefix — never a shared one.
# The Miri sysroot is per-toolchain and per-target (two are built here: host + MSVC), so it
# belongs with the toolchain, not with the lockfile.
- name: cache the nightly toolchain + Miri sysroots
uses: actions/cache@v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
/usr/local/rustup/toolchains/${{ env.MIRI_TOOLCHAIN }}-x86_64-unknown-linux-gnu
~/.cache/miri
key: miri-toolchain-v1-${{ env.MIRI_TOOLCHAIN }}
- name: cache the cargo registry
uses: actions/cache@v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: /usr/local/cargo/registry
key: miri-registry-v1-${{ hashFiles('Cargo.lock') }}
@@ -412,17 +412,17 @@ jobs:
# use it anyway. Keeps a future workflow-level sccache from becoming a puzzle.
RUSTC_WRAPPER: ""
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Own `san-` key prefixes — never shared with the miri caches, per the cache-poisoning
# note there (and so an incomplete save from one job can never starve the other).
- name: cache the nightly toolchain
uses: actions/cache@v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: /usr/local/rustup/toolchains/${{ env.SAN_TOOLCHAIN }}-x86_64-unknown-linux-gnu
key: san-toolchain-v1-${{ env.SAN_TOOLCHAIN }}
- name: cache the cargo registry
uses: actions/cache@v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: /usr/local/cargo/registry
key: san-registry-v1-${{ hashFiles('Cargo.lock') }}
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
runs-on: [self-hosted, gpu]
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Tier-3 GPU stream benchmark
# Through the environment, not interpolated into the command line: a `${{ }}` expansion is
# substituted before the shell parses the line, so an input carrying shell syntax would run
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this heals the job while the running :latest predates the bake.
+10 -10
View File
@@ -64,7 +64,7 @@ jobs:
CC_x86_64_unknown_linux_gnu: sccache cc
CXX_x86_64_unknown_linux_gnu: sccache c++
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this heals the job while the running :latest predates the bake.
@@ -115,7 +115,7 @@ jobs:
- name: Cache keys
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
- uses: actions/cache@v4
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
/usr/local/cargo/registry
@@ -128,7 +128,7 @@ jobs:
# control is operator-side: Gitea's "require approval for fork PRs".)
key: cargo-home-ci-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-home-ci-
- uses: actions/cache@v4
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: target
# -v3-: the prior `cargo-target-<rustc>-*` cache was poisoned when the runner ran
@@ -269,7 +269,7 @@ jobs:
image: 192.168.1.58:5010/punktfunk-rust-ci-arm64cross:latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this heals the job while the running :latest predates the bake.
@@ -284,7 +284,7 @@ jobs:
- name: Cache keys
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
- uses: actions/cache@v4
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
/usr/local/cargo/registry
@@ -297,7 +297,7 @@ jobs:
# control is operator-side: Gitea's "require approval for fork PRs".)
key: cargo-home-ci-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-home-ci-
- uses: actions/cache@v4
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: target
# Its OWN prefix: aarch64 artifacts must never share the amd64 jobs' target cache.
@@ -350,7 +350,7 @@ jobs:
- name: Install git + node + CA certs
working-directory: /
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# RETRIED, like every other single-shot network call in CI (scripts/ci/retry.sh's header
# has the why: this box runs many jobs in parallel and drops packets under that load).
# `bun install` streams download-and-extract, so a tarball truncated mid-stream surfaces
@@ -392,7 +392,7 @@ jobs:
- name: Install git + CA certs
working-directory: /
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Retried — see the web job above; this is the job the flake was measured on.
- name: Install dependencies
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
@@ -423,7 +423,7 @@ jobs:
# actions/checkout needs all three (see the web job).
- name: Install git + node + CA certs
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Regenerates each bun.nix from its committed bun.lock and diffs, and checks that the
# bun2nix version pin agrees across flake.nix and both package.json files (bun.nix has no
# schema stability across bun2nix releases). Fix with: scripts/ci/check-bun-nix.sh --fix
@@ -444,7 +444,7 @@ jobs:
# actions/checkout needs all three (see the web job).
- name: Install git + node + CA certs
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates curl git nodejs
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# OpenAPI snapshot in sync, PUNKTFUNK_* vars in docs still exist, undocumented-var
# ratchet (baseline: scripts/ci/docs-undocumented-env-baseline.txt), host-cli.md commands
# still exist, data/platforms.json parses.
+30 -14
View File
@@ -100,7 +100,7 @@ jobs:
CC_x86_64_unknown_linux_gnu: sccache cc
CXX_x86_64_unknown_linux_gnu: sccache c++
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this heals the job while the running :latest predates the bake.
@@ -148,14 +148,14 @@ jobs:
# cache is NOT; see below.
- name: Cache keys
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
- uses: actions/cache@v4
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
/usr/local/cargo/registry
/usr/local/cargo/git
key: cargo-home-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-home-
- uses: actions/cache@v4
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: target
# -v3-: bypass a target cache poisoned by a disk-full build (see ci.yml).
@@ -204,7 +204,7 @@ jobs:
run: echo "bunver=$(bun --version 2>/dev/null || echo none)" >> "$GITHUB_ENV"
- name: Cache the built web console
id: webconsole
uses: actions/cache@v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: web/.output
key: web-console-linux-bun${{ env.bunver }}-${{ hashFiles('web/**', 'sdk/**') }}
@@ -220,9 +220,25 @@ jobs:
run: |
# bun builds AND runs the console. Baked into the rust-ci image; bootstrap here too so the
# job stays green against the PREVIOUS image (docker.yml bootstrap lag).
#
# A PINNED release asset, checked by SHA-256 — never `curl https://bun.sh/install | bash`.
# build-web-deb.sh VENDORS this very binary into the punktfunk-web .deb (BUN_BIN, below),
# so an install script piped into root's shell is upstream code choosing bytes we then
# publish under REGISTRY_TOKEN. Not in Debian/Ubuntu, so a pin is the only option here.
# ONE bun across the repo: same version as rpm.yml and windows-host.yml, and the same
# asset + sum as rpm.yml (windows pins bun-windows-x64.zip, so its sum differs) — bump
# all three together (the sums are in the release's SHASUMS256.txt). `-baseline` on
# purpose: it needs no AVX2, so the bun we ship starts on every x86-64 box — something the
# auto-detecting installer never promised, since it reads the BUILDER's CPU, not the user's.
command -v bun >/dev/null || {
apt-get install -y --no-install-recommends unzip
curl -fsSL https://bun.sh/install | bash
BUN_VER=bun-v1.3.14
BUN_SHA=a063908ae08b7852ca10939bbdc6ceed3ddabce8fb9402dce83d65d73b36e6c7
curl -fsSL -o /tmp/bun.zip \
"https://github.com/oven-sh/bun/releases/download/$BUN_VER/bun-linux-x64-baseline.zip"
echo "$BUN_SHA /tmp/bun.zip" | sha256sum -c -
unzip -q -o -j /tmp/bun.zip '*/bun' -d /tmp
install -m0755 /tmp/bun /usr/local/bin/bun
}
export PATH="$HOME/.bun/bin:$PATH"
cd web
@@ -327,7 +343,7 @@ jobs:
CC_x86_64_unknown_linux_gnu: sccache cc
CXX_x86_64_unknown_linux_gnu: sccache c++
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this heals the job while the running :latest predates the bake.
@@ -357,14 +373,14 @@ jobs:
- name: Cache keys
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
- uses: actions/cache@v4
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
/usr/local/cargo/registry
/usr/local/cargo/git
key: cargo-home-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-home-
- uses: actions/cache@v4
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: target
# Own key: this target dir is built against 24.04's glibc/toolchain and must NOT share
@@ -478,7 +494,7 @@ jobs:
image: 192.168.1.58:5010/punktfunk-gamescope-trixie:latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Byte-identical to the other jobs' version step (pf-version.sh is deterministic per commit)
# — but only DISTRIBUTION is used here. The package version is the gamescope upstream
@@ -497,7 +513,7 @@ jobs:
# 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.
# Keyed `-trixie-` so the noble cache entries (which only ever held misses) can't be hit.
- uses: actions/cache@v4
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
id: gamescope
with:
path: gs-cache
@@ -571,7 +587,7 @@ jobs:
image: 192.168.1.58:5010/punktfunk-rust-ci-arm64cross:latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this heals the job while the running :latest predates the bake.
@@ -606,14 +622,14 @@ jobs:
- name: Cache keys
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
- uses: actions/cache@v4
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
/usr/local/cargo/registry
/usr/local/cargo/git
key: cargo-home-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-home-
- uses: actions/cache@v4
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: target
# Its OWN key — these are aarch64 artifacts under target/aarch64-unknown-linux-gnu/
@@ -693,7 +709,7 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Recomputes the SAME version string the builder jobs stamped — pf-version.sh is
# deterministic per commit and GITHUB_RUN_NUMBER is shared across a run's jobs — so the check
+1 -1
View File
@@ -61,7 +61,7 @@ jobs:
run:
working-directory: clients/decky
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: pnpm
run: |
+4 -4
View File
@@ -37,7 +37,7 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Sync compose file
# SHA-pinned (receives DEPLOY_SSH_KEY): a moved tag would mean credential
@@ -75,7 +75,7 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Sync flatpak server compose + Caddyfile
uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
@@ -110,7 +110,7 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Sync nix cache compose + server
uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
@@ -150,7 +150,7 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Sync winget source compose + server
uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
+4 -4
View File
@@ -126,7 +126,7 @@ jobs:
- image: punktfunk-flatpak-ci
dockerfile: ci/flatpak-ci.Dockerfile
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# The key is the git TREE HASH of ci/ — every byte any of these Dockerfiles can see
# (they all use ci/ as build context). One key for the whole family on purpose: a
@@ -224,7 +224,7 @@ jobs:
env:
IMAGE: punktfunk-rust-ci-arm64cross
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Content key
run: |
@@ -305,7 +305,7 @@ jobs:
dockerfile: docs-site/Dockerfile
context: docs-site
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Login to registry
# Username must be the owner of the REGISTRY_TOKEN PAT, not the push actor.
@@ -340,7 +340,7 @@ jobs:
needs: apps
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Sync compose file
# SHA-pinned (not tag-pinned): this action receives DEPLOY_SSH_KEY + host/user/port, so a
+25 -6
View File
@@ -131,7 +131,7 @@ jobs:
# node comes from the image now (act_runner execs a JS action with the CONTAINER's
# node and injects none of its own), so checkout needs no install step ahead of it.
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Tooling (baked — assert, don't install)
run: |
@@ -178,7 +178,7 @@ jobs:
# overwriting the baked installation with an older copy of itself. The crate sources
# stay cached — they are keyed on Cargo.lock, which no image can pin.
- name: Cache flatpak-builder state (crate sources, ccache)
uses: actions/cache@v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .flatpak-builder
key: flatpak-builder-state-${{ hashFiles('Cargo.lock', 'packaging/flatpak/**') }}
@@ -215,8 +215,17 @@ jobs:
# device" (see packaging/flatpak/prune-windows-lock.py). The committed Cargo.lock is
# untouched; cargo --offline only needs sources for the crates it compiles.
run: |
# PINNED to a commit and checked by SHA-256. `master` is a mutable ref, and this is
# third-party python executed in the SAME job that holds FLATPAK_GPG_PRIVATE_KEY — it
# chooses which crate sources the signed build vendors, so an upstream push (or a bad
# day at raw.githubusercontent) would be picking bytes we then sign. Bump both together:
# curl -fsSL .../<new-sha>/cargo/flatpak-cargo-generator.py | sha256sum
GEN_REF=f03a673abe6ce189cea1c2857e2b44af2dd79d1f
GEN_SHA=b373c8ab1a05378ec5d8ed0645c7b127bcec7d2f7a1798694fbc627d570d856c
curl -fsSL --retry 5 --retry-all-errors --retry-delay 5 -o /tmp/flatpak-cargo-generator.py \
https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/master/cargo/flatpak-cargo-generator.py
"https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/$GEN_REF/cargo/flatpak-cargo-generator.py"
echo "$GEN_SHA /tmp/flatpak-cargo-generator.py" | sha256sum -c - \
|| { echo "::error::flatpak-cargo-generator.py sha256 mismatch at $GEN_REF"; exit 1; }
python3 packaging/flatpak/prune-windows-lock.py Cargo.lock /tmp/Cargo.flatpak.lock
python3 /tmp/flatpak-cargo-generator.py /tmp/Cargo.flatpak.lock \
-o packaging/flatpak/cargo-sources.json
@@ -413,10 +422,14 @@ jobs:
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
# unom-1's SSH host key, `ssh-keyscan -p "$DEPLOY_PORT" "$DEPLOY_HOST"` — the same repo
# secret nix.yml publishes with (packaging/nix/README.md). Gated with the rest below: no
# pinned host key, no deploy, never a first-contact-trusts-anything push.
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
run: |
set -euo pipefail
if [ -z "${FLATPAK_GPG_PRIVATE_KEY:-}" ] || [ -z "${DEPLOY_HOST:-}" ]; then
echo "::warning::FLATPAK_GPG_PRIVATE_KEY/DEPLOY_* not set — skipping repo deploy (bundle still published)."
if [ -z "${FLATPAK_GPG_PRIVATE_KEY:-}" ] || [ -z "${DEPLOY_HOST:-}" ] || [ -z "${DEPLOY_KNOWN_HOSTS:-}" ]; then
echo "::warning::FLATPAK_GPG_PRIVATE_KEY/DEPLOY_*/DEPLOY_KNOWN_HOSTS not set — skipping repo deploy (bundle still published). See packaging/nix/README.md for the host key."
exit 0
fi
# 1) Import the signing key into a throwaway keyring; sign the repo.
@@ -481,7 +494,13 @@ jobs:
# objects so clients mid-update aren't broken; the fresh signed summary advertises latest.
install -d -m700 ~/.ssh
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy; chmod 600 ~/.ssh/deploy
SSH="ssh -i $HOME/.ssh/deploy -p ${DEPLOY_PORT:-22} -o StrictHostKeyChecking=accept-new"
# Pin unom-1's host key instead of trusting whoever answers first. This step is holding
# FLATPAK_GPG_PRIVATE_KEY and ships the signed OSTree repo, so `accept-new` — which trusts
# the first key it ever sees, and every run starts with an empty known_hosts, so EVERY run
# is a first contact — would hand the deploy key and the publish to anything that won the
# race for the address. The guard above skips the deploy when the secret is unset.
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts; chmod 600 ~/.ssh/known_hosts
SSH="ssh -i $HOME/.ssh/deploy -p ${DEPLOY_PORT:-22} -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$HOME/.ssh/known_hosts"
DEST="${DEPLOY_USER}@${DEPLOY_HOST}"
# All idempotent — retried because the runner's link to unom-1 drops TCP dials under
# load (the same flake that hits docker.yml's deploy-docs with "dial tcp: i/o timeout").
+60 -1
View File
@@ -39,18 +39,27 @@ jobs:
- family: debian-13
image: debian:trixie
prep: apt-get update -qq && apt-get install -y -qq --no-install-recommends ca-certificates curl git nodejs
rmweb: apt-get purge -y punktfunk-web
oncanary: grep -q ' canary main' /etc/apt/sources.list.d/punktfunk.list
onstable: grep -q ' stable main' /etc/apt/sources.list.d/punktfunk.list
- family: fedora-44
image: fedora:44
prep: dnf install -y -q curl git nodejs
rmweb: dnf remove -y punktfunk-web
oncanary: grep -q 'fedora-44-canary' /etc/yum.repos.d/punktfunk.repo
onstable: grep -q 'rpm/fedora-44$' /etc/yum.repos.d/punktfunk.repo
- family: arch
image: archlinux:base
prep: pacman -Sy --noconfirm --needed curl git nodejs && (pacman-key --init >/dev/null 2>&1 || true)
rmweb: pacman -Rns --noconfirm punktfunk-web
oncanary: grep -q '^\[punktfunk-canary\]' /etc/pacman.conf && ! grep -q '^\[punktfunk\]' /etc/pacman.conf
onstable: grep -q '^\[punktfunk\]' /etc/pacman.conf && ! grep -q '^\[punktfunk-canary\]' /etc/pacman.conf
container:
image: ${{ matrix.image }}
steps:
- name: Prepare the container (${{ matrix.family }})
run: ${{ matrix.prep }}
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# No tty → the script runs as --yes; --no-start because there is no user systemd here.
# Root without sudo → the script's sudo shim, another path under test.
- name: Run the installer unattended
@@ -59,8 +68,58 @@ jobs:
run: |
punktfunk-host --version
punktfunk-host detect-conflicts
# The console is the whole management surface — pairing, approving a device, every setting.
# A host without it can be installed and still be useless, so assert it by name: the binary
# the package puts on PATH and the user unit step 6 enables.
- name: The web console is installed too
run: |
command -v punktfunk-web-server
test -f /usr/lib/systemd/user/punktfunk-web.service
- name: Re-running is a no-op install
run: sh scripts/install.sh --yes --no-start | grep -q 'already installed'
# The reported Fedora failure, as a test: a box that has the host but lost (or never got)
# the console must get one back from a re-run. Before the per-package check, the installer
# saw punktfunk-host on PATH, declared itself done, and left the box without a console
# while still printing the console's URL.
- name: A host without a console gets one back on re-run
run: |
${{ matrix.rmweb }}
! command -v punktfunk-web-server
sh scripts/install.sh --yes --no-start
command -v punktfunk-web-server
# The channel switch, both ways, against the real registries — the half no textual gate can
# reach. Two things are actually at risk here and nowhere else: that the canary repo resolves
# at all, and that the walk back DOWN to stable happens instead of silently no-opping (canary
# is always a minor ahead, so every package manager refuses that step without being told).
# The version moving and then coming back is the proof; the repo-config greps localise a
# failure to the rewrite half. A red here can also mean the family's canary channel is empty
# — check https://git.unom.io/unom/-/packages before hunting in the script.
- name: Switch to canary and back to stable
run: |
set -eu
stable_v=$(punktfunk-host --version | head -1)
sh scripts/install.sh --yes --no-start --channel canary
${{ matrix.oncanary }}
canary_v=$(punktfunk-host --version | head -1)
test "$canary_v" != "$stable_v" \
|| { echo "switch to canary left the version at $stable_v"; exit 1; }
command -v punktfunk-web-server
sh scripts/install.sh --yes --no-start --channel stable
${{ matrix.onstable }}
back_v=$(punktfunk-host --version | head -1)
test "$back_v" = "$stable_v" \
|| { echo "walk back to stable gave $back_v, not $stable_v (a downgrade that did not happen?)"; exit 1; }
# A re-run with no --channel must follow the box, not the flag's stable default.
- name: A bare re-run does not drag a canary box back to stable
run: |
set -eu
sh scripts/install.sh --yes --no-start --channel canary
canary_v=$(punktfunk-host --version | head -1)
sh scripts/install.sh --yes --no-start
${{ matrix.oncanary }}
test "$(punktfunk-host --version | head -1)" = "$canary_v" \
|| { echo "a bare re-run moved a canary box off $canary_v"; exit 1; }
sh scripts/install.sh --yes --no-start --channel stable
- name: --uninstall takes the packages and the repo off again
run: |
sh scripts/install.sh --yes --uninstall
@@ -48,7 +48,7 @@ jobs:
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this heals the job while the running :latest predates the bake.
@@ -72,14 +72,14 @@ jobs:
# it is profile-independent.
- name: Cache keys
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
- uses: actions/cache@v4
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
/usr/local/cargo/registry
/usr/local/cargo/git
key: cargo-home-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-home-
- uses: actions/cache@v4
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: target
# This job builds RELEASE (see the build step) in the same image and target layout as
+14 -4
View File
@@ -145,7 +145,7 @@ jobs:
# and a real `nix build` of a trivial derivation succeeds).
NIX_REMOTE: ""
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# The Determinate installer needs curl + xz; git so nix can read the flake from the checkout;
# rsync + ssh to ship the built cache to unom-1. (node:22-bookworm is the full image and
@@ -235,16 +235,19 @@ jobs:
env:
NIX_CACHE_SIGNING_KEY: ${{ secrets.NIX_CACHE_SIGNING_KEY }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
# unom-1's SSH host key, `ssh-keyscan -p "$DEPLOY_PORT" "$DEPLOY_HOST"`. Gated here with
# the rest: no pinned host key, no publish — never a first-contact-trusts-anything deploy.
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
# Guard BEFORE the build, not before the upload: an unconfigured cache must not cost an
# hour of rustc first. No-ops cleanly until the secret exists, exactly as flatpak.yml's
# repo deploy does, so this workflow stays green through setup.
run: |
set -eu
if [ -n "${NIX_CACHE_SIGNING_KEY:-}" ] && [ -n "${DEPLOY_HOST:-}" ]; then
if [ -n "${NIX_CACHE_SIGNING_KEY:-}" ] && [ -n "${DEPLOY_HOST:-}" ] && [ -n "${DEPLOY_KNOWN_HOSTS:-}" ]; then
echo "go=true" >> "$GITHUB_OUTPUT"
else
echo "go=false" >> "$GITHUB_OUTPUT"
echo "::warning::NIX_CACHE_SIGNING_KEY/DEPLOY_HOST not set — skipping the binary cache publish (see packaging/nix/README.md)."
echo "::warning::NIX_CACHE_SIGNING_KEY/DEPLOY_HOST/DEPLOY_KNOWN_HOSTS not set — skipping the binary cache publish (see packaging/nix/README.md)."
fi
- name: Build the publishable packages
@@ -269,6 +272,7 @@ jobs:
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
run: |
# `set -eu`, NOT `set -euo pipefail`: act_runner may execute a step's `run:` under dash in
# these containers (see scripts/ci/ensure-sccache.sh), and dash dies on `-o pipefail` with
@@ -313,7 +317,13 @@ jobs:
# unom-1 drops TCP dials under load.
install -d -m700 ~/.ssh
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy; chmod 600 ~/.ssh/deploy
SSH="ssh -i $HOME/.ssh/deploy -p ${DEPLOY_PORT:-22} -o StrictHostKeyChecking=accept-new"
# Pin unom-1's host key instead of trusting whoever answers first. This step is holding
# NIX_CACHE_SIGNING_KEY and ships the signed cache, so `accept-new` — which trusts the
# first key it ever sees, and every run starts with an empty known_hosts, so EVERY run is
# a first contact — would hand the deploy key and the publish to anything that won the
# race for the address. Preflight above skips the publish when the secret is unset.
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts; chmod 600 ~/.ssh/known_hosts
SSH="ssh -i $HOME/.ssh/deploy -p ${DEPLOY_PORT:-22} -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$HOME/.ssh/known_hosts"
DEST="${DEPLOY_USER}@${DEPLOY_HOST}"
bash scripts/ci/retry.sh 5 $SSH "$DEST" "mkdir -p ~/$DEPLOY_DIR/site/nar"
# ⚠ ORDER IS LOAD-BEARING: NARs first, narinfos second. A narinfo whose NAR has not landed
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
- name: Install git + node + CA certs
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Both installs retried: bun's download-and-extract is single-shot, and a truncated tarball
# reads as `Fail extracting tarball` (ci.yml's web job has the measurement). A publish job
+20 -6
View File
@@ -86,7 +86,7 @@ jobs:
env:
CARGO_HOME: /usr/local/cargo
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this heals the job while the running :latest predates the bake.
@@ -119,13 +119,27 @@ jobs:
dnf -y install gamescope || true
# bun builds the punktfunk-web console (--with web). Baked into the image; install it
# here too so the job stays green against the PREVIOUS image (docker.yml bootstrap note).
#
# A PINNED release asset, checked by SHA-256 — never `curl https://bun.sh/install | bash`.
# This job holds RPM_GPG_PRIVATE_KEY, and the spec VENDORS this very binary into
# punktfunk-web, so an install script piped into root's shell is upstream code running in
# front of the signing key AND choosing bytes we then sign. Same discipline as
# windows-host.yml's bun pin. Bump BUN_VER and BUN_SHA together (the sums are published in
# the release's SHASUMS256.txt). `-baseline` on purpose: it needs no AVX2, so the bun we
# ship starts on every x86-64 box — something the auto-detecting installer never promised,
# since it reads the BUILDER's CPU, not the user's.
command -v bun >/dev/null || {
dnf -y install unzip
curl -fsSL https://bun.sh/install | bash
install -m0755 "$HOME/.bun/bin/bun" /usr/local/bin/bun
BUN_VER=bun-v1.3.14
BUN_SHA=a063908ae08b7852ca10939bbdc6ceed3ddabce8fb9402dce83d65d73b36e6c7
curl -fsSL -o /tmp/bun.zip \
"https://github.com/oven-sh/bun/releases/download/$BUN_VER/bun-linux-x64-baseline.zip"
echo "$BUN_SHA /tmp/bun.zip" | sha256sum -c -
unzip -q -o -j /tmp/bun.zip '*/bun' -d /tmp
install -m0755 /tmp/bun /usr/local/bin/bun
}
bun --version
- uses: actions/cache@v4
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: /usr/local/cargo/registry
key: cargo-home-fedora-${{ hashFiles('Cargo.lock') }}
@@ -167,7 +181,7 @@ jobs:
run: echo "bunver=$(bun --version 2>/dev/null || echo none)" >> "$GITHUB_ENV"
- name: Cache the built web console
id: webconsole
uses: actions/cache@v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: web/.output
key: web-console-linux-bun${{ env.bunver }}-${{ hashFiles('web/**', 'sdk/**') }}
@@ -270,7 +284,7 @@ jobs:
# script). So the key is that directory's hash and a normal push restores a binary instead of
# building one. Per-Fedora-major, because the binary is soname-coupled to its base exactly
# like the RPM is — an f43 build does not start on f44 (libavutil.so.59 vs .60).
- uses: actions/cache@v4
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
id: gamescope
with:
path: gs-cache
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
steps:
# fetch-depth 0: the dispatch path derives the canary base from the tag history
# (scripts/ci/pf-version.sh), which a shallow clone cannot see.
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
# Pinned syft (keep in sync with the version validated against this repo; bump deliberately).
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
working-directory: /
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
# `Fail extracting tarball` (ci.yml's web job has the measurement). A publish job is the
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
- name: Install git + node + CA certs
working-directory: /
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# --ignore-scripts skips the prepare→codegen hook (mirrors ci.yml); run codegen
# explicitly since build-storybook has no prebuild hook of its own.
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
+1 -1
View File
@@ -188,7 +188,7 @@ jobs:
# rust-skia adds the target.
session_flags: '--no-default-features'
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Ensure Windows toolchain (WDK, Inno Setup, ARM64 target)
shell: pwsh
+2 -2
View File
@@ -52,7 +52,7 @@ jobs:
run:
shell: pwsh
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Probe driver toolchain (informational — never fails the job)
continue-on-error: true
@@ -142,7 +142,7 @@ jobs:
# (the shipping pack proves it). A 0.71-era layout-test overflow once needed LLVM 21; the 0.72 bump
# retired that — see design/windows-build-and-packaging.md.
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Ensure Windows toolchain (WDK, FFmpeg, Inno Setup, ARM64 target)
# Shared self-provision step (also used by windows-client.yml/windows-host.yml) so
# driver-build is self-sufficient on any windows-amd64 runner and never races a manually
+4 -4
View File
@@ -112,7 +112,7 @@ jobs:
runs-on: windows-amd64
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Ensure Windows toolchain (WDK, FFmpeg, Inno Setup, ARM64 target)
shell: pwsh
@@ -284,7 +284,7 @@ jobs:
# see unom/infra runners/ci-core/README.md).
- name: Cache web console output
id: webconsole
uses: actions/cache@v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: web/.output
key: web-console-win-${{ hashFiles('web/**', 'sdk/**') }}
@@ -541,7 +541,7 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Publish the canary update manifest
env:
@@ -566,7 +566,7 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# build-data re-derives the WHOLE catalogue from the releases rather than appending this one,
# so the result cannot drift and re-running any tag reproduces it byte for byte.
+864
View File
@@ -12,6 +12,870 @@ with the version table of the release you are moving to, then read **Breaking ch
---
## v0.32.0
90 commits since v0.31.4 (63 non-merge), counted at the tip this was cut from.
**The C ABI steps 25 → 26, and nothing else versioned moves.** `punktfunk_connect_opts` closes the
`punktfunk_connect_ex*` ladder: eleven generations each added a field by minting a new exported
symbol plus a 20-something-parameter forwarding shim, and the replacement takes every option in ONE
size-prefixed `PunktfunkConnectOpts`. **Every `ex` keeps its symbol and its behaviour**, so this is
an added surface rather than a changed one. `WIRE_VERSION` stays **2**: every wire addition this
cycle is additive (two capability bits, one AU user flag, one trailing `Welcome` field), the same
append discipline every field since `compositor` has followed. The driver protocol, the Windows
gamepad channel, the plugin index schema, the host event schema and the gamescope patch level are
all untouched; `pf-driver-proto` and `punktfunk-host/src/events.rs` show no diff; `api/openapi.json`
is content-identical to v0.31.4, the `info.version` stamp being the whole diff.
**Read `Breaking changes` regardless.** The auto-bitrate overhaul lands here in four phases, and
Phase 4 redefines what the bitrate number *means* on the control plane — deliberately without a
capability handshake. Alongside it: the 2026-08-25 security review (58 findings, most of them minor
hardening; the most serious let an **authenticated** console session reach the pairing routes without
the console password, and pairing is what grants launch), a second GameStream security pass (media-port
endpoint proof, control-stream nonce separation), and the GameStream competitive program, which turns
**both** video and control encryption on by default.
### Versions
| | v0.31.4 | v0.32.0 | Notes |
|---|---|---|---|
| Wire protocol | 2 | **2** | unchanged. Three additions, each ignorable by an older peer: `USER_FLAG_REPEAT` (`0x0100`) on the AU header, `CLIENT_CAP_KEEP_HOST_AUDIO` (`0x20`) in `Hello::client_caps`, and `Welcome::host_caps2` — a trailing field, absent → `0`, carrying `HOST_CAP2_REPEAT_MARK` (`0x01`) |
| C ABI | 25 | **26** | **MOVES.** `punktfunk_connect_opts` + `PunktfunkConnectOpts` replace the `connect_ex*` ladder; layout is locked padding-free on both pointer widths (96/68 B) by const asserts and by the C harness compiling the same sizes, so an appended field can never land in bytes an older `sizeof` already covered. An older, shorter caller gets its tail defaulted to unspecified/auto rather than misread. Also additive: `USER_FLAG_REPEAT`, `CLIENT_CAP_KEEP_HOST_AUDIO`, `HOST_CAP2_REPEAT_MARK`, plus corrected `VIDEO_CAP_10BIT` / `VIDEO_CAP_HDR` prose |
| Rust edition | 2024 | **2024** | unchanged |
| MSRV (`rust-version`) | 1.85 | **1.85** | unchanged |
| Workspace crate dirs | 27 | **27** | unchanged (39 `[workspace] members`, also unchanged) |
| Virtual-display driver protocol | 6 | **6** | unchanged (minimum accepted still 3); `pf-driver-proto` shows no diff against the v0.31.4 tag |
| Windows virtual-gamepad channel | 3 | **3** | unchanged; nothing under the Windows gamepad backends moved |
| Plugin index schema | 1 | **1** | unchanged — but `launcher_ui` accepts a new **value**, `heroic-console`, and a plugin publishing it must gate on `minHost` (below) |
| Host event schema | 1 | **1** | unchanged (`punktfunk-host/src/events.rs` shows no diff) |
| `api/openapi.json` | 0.31.4 | **0.32.0** | **content-identical** — the `info.version` stamp is the whole diff. Re-stamped, not regenerated (`punktfunk-host` does not build on macOS); `api/` and `docs-site/public/` are byte-identical to each other |
| gamescope patch level (`+pfhdrN`) | 8 | **8** | unchanged; no new patch files, `packaging/gamescope/PKGBUILD` still declares `pfhdr8` |
| `@punktfunk/host` (SDK) | 0.1.6 | **0.1.6** | unchanged; nothing under `sdk/` moved |
| `@punktfunk/plugin-kit` | 0.4.4 | **0.4.4** | unchanged **on purpose**. `plugin-kit/src/wire.ts` changes exactly one line and it is a doc comment in the `launch.kind` table — `launcher_ui` is a bare string validated host-side, so no type, schema or runtime behaviour moved. 0.4.4 remains the registry's `latest` |
### Breaking changes
**1. The bitrate is the total wire budget** (ABR RFC §5.1). Every number on the control plane —
`Hello` resolve, `SetBitrate`, acks, `live_bitrate`, the console — is now the **total wire budget**,
not the encoder rate. FEC parity (150 %), packet framing (~4.5 %) and the audio plane used to ride
on **top** of it, so a "20 Mbps" session put 2230+ Mbps on the wire; on the constrained links where
the setting matters most, that overshoot *was* the failure. Encoder opens and reconfigures convert
through `EncDerive` (budget audio, over framing + FEC) snapshotted at the live FEC percent, and
read-backs convert back, so a short apply reports budget truth. A FEC step re-derives the encoder
rate in place, so parity reallocates **within** the budget instead of inflating the wire. The audio
reservation comes from the resolved plane (exact PCM cost, or the shared Opus budget ladder).
**No capability handshake, by design** (RFC §5.1, amended 2026-08-27): new-client → old-host is
byte-for-byte today's behaviour, and old-client → new-host shifts conservative by the overhead
share. **PyroWave is the identity** (its pin is an encoder operating point), and **the GameStream
plane keeps its historical semantics untouched.** Client-side, the controller's `actual` becomes the
wire measure — received bytes with headers, seals and parity, minus probe filler, plus the mirrored
audio reservation — so utilization and the proven mark compare like with like against budget
targets. The derivation is pure and unit-tested: round-trips never inflate the budget, reallocation
is monotone in FEC, and a budget too small for its own audio floors honestly.
*If you embed and you read `live_bitrate` as an encoder rate, it is not one any more.*
**2. `FLOOR_KBPS` drops 5000 → 2000** (RFC §7 Q4), with a one-shot log warning on the first descent
below the old fence.
**3. DSCP marking defaults to AUTO**, where it was opt-in. The host marks toward RFC1918 / ULA /
link-local / loopback peers and stays off toward anything routable: the bleaching risk (consumer
ISPs and routers that reject or strip DSCP) lives on WAN paths, while the win — APs mapping DSCP to
WMM airtime priority — lives on local ones. `PUNKTFUNK_DSCP=1` still forces it on everywhere, `=0`
is still the kill switch.
**4. GameStream video AND control encryption are on by default.** `PUNKTFUNK_GS_ENCRYPT` now
defaults to *supported* carrying **both** bits (offered, never required). The values are `0` (back to
the plaintext wire), **`video`** (new — keeps video encryption, drops only the control offer), and
`require` (forces the negotiation).
Video graduated first, verified 2026-08-27 against a Win11 / RTX 4090 host from Moonlight on macOS,
four legs: off streams unchanged; *supported* had the client **opt in by itself on a LAN,
unprompted** — which is why the default moved, since an offer nobody takes is a no-op; *require*
negotiated and decoded in hardware; and *require* + 5 % injected wire loss ran 27 s and 8 keyframes
with **zero IDR re-requests**, which is the FEC-then-encrypt ordering proven on hardware rather than
in a unit test. Note that the client negotiates `packet_size` 1360 where the plaintext leg used
1392 — it subtracts `sizeof(ENC_VIDEO_HEADER)` exactly as the reference says — so the on-wire
datagram is 1408 either way and still fits the MTU it sized for.
`SS_ENC_CONTROL_V2` graduated the same way, on the same box at 2560x1440@240 HEVC Main10 HDR. It
shipped dark for one commit, then the client turned it on **by itself** when merely offered. The
host's own scheme detector is what settles it, because it reports what it locked onto rather than
what was negotiated: `nonce: V2 { seq_be: false, marker: [67, 67] }`, where `[67, 67]` is `b"CC"`
the client→host V2 marker. The host then sealed its HDR-mode cue under the flipped `b"HC"` and the
client acted on it, so both directions ran on distinct nonces, on hardware.
**5. PyroWave sessions force Automatic bitrate** (RFC §5.2). An explicit client rate under PyroWave
was ill-defined — the operating point is bits per pixel, not kbps — and bypassed the
`PUNKTFUNK_PYROWAVE_MAX_MBPS` operator ceiling entirely. `resolve_bitrate_kbps_for` now ignores the
requested rate under PyroWave (warning when it overrides) so every such session goes through the
per-mode bpp pin and the ceiling, and `bitrate_auto` treats PyroWave sessions as Automatic so a mode
switch re-resolves the pin whatever the `Hello` carried.
**6. Console pairing routes move behind the console password** (security review). Arming, approving
and PIN submission rode the generic catch-all with the operator's admin bearer attached, so — alone
among the trust-root actions — they never re-asked for the console password. Scope it correctly: the
caller had to be an **authenticated console session** already, so this was privilege escalation
within the console, not something an unauthenticated network peer could reach. It matters because
pairing is what grants launch, so a console sign-in became the ability to start a process on the
host. They now sit behind the console password like the other trust-root routes, and the armed PIN is
returned **once** in that gated response instead of riding a 1 s status poll. *A script driving the
pairing routes with only a session cookie is now refused.*
**7. Deep links auto-dial by stable record id only.** A display name or an address gets a
confirmation on every client.
### The auto-bitrate overhaul (four phases)
Against `punktfunk-planning` `design/abr-stack-overhaul.md`; the field chains are the 2026-08-26 and
2026-08-27 reports.
**Phase 1 — the transmission plane stops decaying to a blast** (§2.12.4). The unpaced microburst
allowance was an absolute `max(128 KiB, wire/4)`, sized for gigabit LAN, so at Wi-Fi bitrates every
frame went out back-to-back; it is now **time at the pace rate** (10 ms, clamped). Adaptive FEC no
longer decays to 1 %, so the first big motion frame after a static stretch is not shipped
unprotected. §2.5 is the DSCP default above.
**Phase 2 — the control loop stops believing its own bookkeeping** (§2.22.3). A failed
bitrate-change encoder rebuild now snaps the client back (`retarget_tx`): the control task acks
*before* the apply, so the client's climb base, utilization and proven math had been tracking a rate
the encoder never ran until some later event happened to correct them. The ABR rebuild announces
`PipelineGap` on success. Bitrate acks queue in arrival order instead of a latest-wins slot, and a
mode switch re-sizes the ABR stream cap and clamps the learned ceiling with it.
**Phase 3 — the controller learns stillness from the host** (§4.14.3, §7 Q4). The host marks
idle-keepalive re-encodes on the wire (`USER_FLAG_REPEAT`, whole-frame and streamed paths) and
advertises that it does so via `HOST_CAP2_REPEAT_MARK` in the new trailing `host_caps2` byte. The
capability is what makes the flag's *absence* meaningful: against an advertising host an unflagged
AU is genuinely new content, while against an older host the client must treat activity as unknown
and keep the legacy window arithmetic. Idle windows (every AU a repeat) are **neutral** — they train
no OWD / decode / encode baseline, accrue no climb credit or re-probe authority, and never authorize
a climb; loss, flush and drop keep their full power. Utilization is measured per frame at the
source's own rate, so a 35 fps menu on a 90 Hz config can climb when its frames run full, and the
proven-headroom cap prorates with it (bounding the next target's projected **wire** rate at ×1.5
over what was delivered) or the two gates would deadlock exactly where the fix is needed. Recovery
is bounded and multiplicative: the proven mark is a two-bucket windowed max (~3060 s) rather than
all-session, and the first active window after ≥3 s of stillness re-arms slow start with the
cooldown cleared — the ~103 s additive crawl from the floor becomes seconds, never past ×1.5 over
recently-proven delivery.
**Phase 4 — the budget redefinition**, above.
**Also here:** frames that died of *lateness* stop ratcheting the bitrate. `window_loss_ppm`'s
`frames_dropped` bump fired even when `lost == 0 && late > 0` — every presumed-lost shard had in
fact arrived, and the frames died of delay, which neither lever this number drives can touch (FEC
repairs loss, not delay; a bitrate backoff cannot shorten a hole). In the 2026-08-27 field log that
read `loss_ppm=50000` exactly and cut ×0.7 three times, to 6.86 Mbps in 4 s, on a wire with zero
measured loss.
### Security review 2026-08-25
58 findings across host, console, clients and supply chain. **Read that number for what it is:** the
long tail is minor hardening and build-infrastructure tightening, not 58 exploitable defects. What
the serious ones share is a shape — a documented boundary whose code had stopped enforcing what its
comment promised — so where the two disagreed the comment won and the code was made to match, and
where it could not be, the comment was corrected instead.
**Most serious:** the console pairing routes, above — escalation from an already-authenticated
console session, not a pre-auth hole. **High:** the plugin lane no longer reads the
unredacted log ring (which carried the webhook credentials the `/hooks` carve-out exists to
withhold), and hook lines log an origin and a short id rather than a URL or a command line; a
plugin-reported pid is held to `procscan`'s start-time floor before the SYSTEM host will signal it;
`ClipOffer` is gated on the live grant mask, so a revoked guest loses the host clipboard in both
directions; ENet refuses connects with no live launch instead of letting LAN peers squat all four
slots; Windows secrets are born with their DACL applied rather than world-readable; the sysext feed
binds FEED and a monotonic SERIAL inside the signed bytes; `privileged_field` allowlists the
host-resolved launch kinds, so a new kind is privileged by default; and five parser panics reachable
from one malformed NALU are range-checked. The Apple identity key moves to `ThisDeviceOnly` so it
stops riding encrypted backups. `pf-vdisplay` stops routing session identity through the process
environment — the injector backend threads a typed slot, so per-batch `getenv` no longer races a
per-session `setenv`.
### The GameStream (Moonlight-compatible) plane
The competitive program's WP0WP3, WP5, WP6.1 and WP7.
**Transport.** Arrival-driven capture, microburst pacing and a pooled packetizer (the per-AU copy is
gone); ENet wakes on packet rather than on a poll tick; the encoder budget derives *under* the client
number; `frame_processing_latency` is stamped instead of hardcoded to 0, so Moonlight's own overlay
finally shows real host latency.
**Loss adaptation** (`PUNKTFUNK_GS_ADAPT=0` pins it off). The plane decodes the client's `0x0201`
loss reports and runs a 1 Hz state machine over them: FEC steps up on lossy windows and down on
clean ones, the budget backs off from the second consecutive lossy window and recovers per clean
window, and every step re-derives the encoder rate and reconfigures in place. A refusal turns
adaptation off for the session rather than leaving the two halves disagreeing.
**Session truth.** `serverinfo`'s `state` / `currentgame` are real now, but **owner-scoped only** and
failing closed on unknown fps — showing a non-owner the truth would route same-app taps into an
owner-only `/resume` and lose reject / join / steal via `/launch`. `/resume` parses and **re-keys**
`rikey`/`rikeyid` (stale keys made post-resume control undecryptable), re-binds `peer_ip`, and
restarts media by stop-then-wait on a new `media_exited` counter each media thread bumps as its last
act — a sequential handoff with no capturer race, bounded at 2 s.
**Wake-on-LAN.** `serverinfo`'s `<mac>` was the literal fake `01:02:03:04:05:06`, which made every
Moonlight WoL a silent no-op. It is now the real routed-NIC MAC, cached on first **success** only so
the boot race retries.
**Media.** The capture format comes from the shared `SessionPlan` (`gs_session_plan`) instead of a
hand-hardcoded `OutputFormat::resolve` — one resolver for both planes, and the visible win is that a
gamescope GameStream session resolves `nv12_native`, so the producer's NV12 feeds Vulkan Video
directly and the per-frame RGB→NV12 CSC the native plane already skips is skipped here too. The
encoder gets the source's real HDR grade every frame (`set_hdr_meta` from the capturer), which an
HDR backend embeds as in-band mastering/CLL SEI on keyframes — this plane had never called it, so an
HDR GameStream session shipped **no grade at all**. RS(4,2) audio FEC now covers every layout,
stereo included; the old `channels > 2` gate was bring-up caution that left the most common
configuration with zero audio loss protection.
**Still blocked:** 4:4:4 and `encoderCscMode` — the 4:4:4 SCM extension bit values are not in the
sanctioned wire reference, and CscMode honour is an encoder-wide colorspace plumb.
### GameStream security pass
**The media ports learn their endpoint from the client that can prove it.** Both media planes took
the first UDP datagram arriving from the launch owner's address and never looked inside it. The ping
payload the protocol exists to carry — handed to the client in the SETUP response and echoed back as
its first datagram — was the fixed constant `0011223344556677` for **every session on every host**,
and nothing ever compared it against what arrived. Source-IP binding was the whole guard, so anything
that could send from (or spoof) that address won the endpoint by being first, and was handed the
stream. The payload is now a per-session secret: eight random bytes minted at `/launch`, re-minted at
`/resume`, advertised hex-encoded in SETUP, and checked before an endpoint is adopted. Both planes go
through one `learn_client_endpoint`, because they had drifted into two byte-identical loops.
**Minting alone would have proved nothing.** `SETUP` was **unauthenticated** while its siblings
`ANNOUNCE` and `PLAY` were not, so any peer that could reach 48010 could ask for the payload the
media planes were about to verify, and walk the check. `SETUP` is now gated on `authorized_launch`
like the other two.
**The control stream stops sharing one nonce space with the client.** Each direction now has its own.
**`serverinfo` stops advertising HEVC capacity it may not have.** `MaxLumaPixelsHEVC` was the
constant `1869449984` in every document, including ones whose `ServerCodecModeSupport` had just
dropped HEVC — a GPU-less host encodes H.264 and nothing else, and said so in the mask while quoting
a 4K60 HEVC ceiling two lines above. It now follows the mask, with `0` when HEVC is not offered.
**Four security questions get answers instead of follow-up notes.** None change behaviour. The TLS
verifier's accept-any-client-certificate comment called pinning "a hardening follow-up"; it is not
one, because a TLS handshake completes before the request line is parsed, so per-route pinning cannot
be expressed there at all. The `CertificateVerify` signature **is** checked, and `peer_is_paired`
pins the SHA-256 of that same certificate before any state-changing work, on every route but
`/serverinfo`. The PIN's 4-digit space is likewise not the exposure, because submission is the
bearer-authenticated management API and nowhere else.
### Wire additions
`USER_FLAG_REPEAT` (`0x0100`) marks a host-side re-encode of a held frame — the idle keepalive — so
it carries no new content. Purely informational, set unconditionally; a receiver that predates it
ignores the bit, and a client only trusts its *absence* as "active frame" when the host advertised
`HOST_CAP2_REPEAT_MARK`.
`CLIENT_CAP_KEEP_HOST_AUDIO` (`0x20`) asks the host to leave its own audio devices alone — capture
whatever the operator's default playback device already is, instead of re-routing the desktop mix
onto a silent endpoint. Request-only, with no `HOST_CAP` echo: an older host ignores the bit and
re-routes as it always did, which degrades to "audio still works, host went quiet". Best-effort and
host-global across concurrent sessions — any live session that asked wins for all of them until it
ends. `0x40` and `0x80` remain free.
`Welcome::host_caps2` is the second capability byte the `0x80` wall predicted, delivered as a
trailing field (absent → `0`). `HOST_CAP2_REPEAT_MARK` is its bit `0x01`.
### 10-bit SDR, independent of HDR
`VIDEO_CAP_10BIT` and `VIDEO_CAP_HDR` were always separate wire bits, but everything above the wire
welded them: the client advertised both from one "HDR" switch, the handshake required an HDR capture
source for any 10-bit depth, `SessionPlan` derived `hdr` from `bit_depth`, and the encoder stamped
BT.2020 PQ on every 10-bit format. A client setting now advertises the depth bit **alone**, and the
handshake resolves `bit_depth = 10` without the HDR gates where the SDR-10 chain exists (Windows
IDD-push + direct NVENC + HEVC); everywhere else the session stays 8-bit and says so honestly in the
`Welcome`. The Welcome's colour label, the virtual display's HDR bring-up and the capturer's
want-HDR flag all follow the new session-HDR verdict instead of the depth. Capture grows
`PixelFormat::Rgb10a2Sdr` (the BGRA slot expanded 8→10 by a trivial full-res pass,
`HdrRgb10Converter::new_sdr_expand`), which NVENC ingests as ABGR10 and encodes Main10 under the
ordinary BT.709 SDR VUI, both 4:2:0 and 4:4:4. **Every pre-0.32 client sets the two bits together**,
so nothing changes for them.
### New environment variables
| Variable | Plane | Meaning |
|---|---|---|
| `PUNKTFUNK_GS_ENCRYPT` | GameStream | `0` = plaintext wire, `video` = video encryption only (drops the control offer), unset = both offered (the new default), `require` = force the negotiation |
| `PUNKTFUNK_GS_ADAPT` | GameStream | `0` pins loss adaptation off (FEC percent and budget stay at the configured values) |
| `PF_STREAM_WIDTH` / `_HEIGHT` / `_REFRESH` / `_HDR` | both | the negotiated mode, exported into per-app prep/undo commands (the marker file's vocabulary, via `hooks::prep_mode_env`) — so an RTSS-style per-mode frame cap is one step instead of one hard-coded entry per device, on Windows hosts too |
`PUNKTFUNK_DSCP` is not new, but its default changed (above).
### `launcher_ui` grows a second Heroic value, for its console mode
**Plugin-facing.** `launcher_ui` accepts **`heroic-console`** on Linux, alongside `heroic` and
`lutris`. It resolves to the same prefix `heroic` does — the native binary if on `PATH`, else the
Flatpak — plus `--console --fullscreen`.
Heroic 2.21 added a fullscreen gamepad UI, and it takes **two** flags: `--console` only routes the
UI to that front end (`isCLIConsoleMode`), and `--fullscreen` is what fills the screen
(`isCLIFullscreen`). Neither is reachable by URI — `heroic://` speaks only `ping` and `launch` — so
this is the same shape as Playnite's fullscreen tile on Windows, where the registered protocol
handler can only open the desktop app.
That makes `launcher_ui`'s value a launcher **UI** rather than a launcher, which it already was on
Windows (`playnite` has always meant `Playnite.FullscreenApp.exe`). A `heroic_ui` kind mirroring
`steam_ui` would have been tidier and was rejected: an unknown *kind* degrades to an unlaunchable
tile on an N-1 host, but an unknown *value* is a hard 400 that refuses the whole reconcile — so
either shape has to be gated on `minHost` in the plugin index, and the value is the smaller change.
**A plugin publishing `heroic-console` must set `minHost` to this release.**
Also here: `resolvable_launcher_ui` now probes `heroic_launch_prefix()` for both Heroic values, the
way it already did for Playnite. Both tiles are dropped from a reconcile on a box where Heroic
cannot be resolved, instead of being published as tiles that do nothing — reachable by keeping
`~/.config/heroic` after uninstalling Heroic, since the plugin's `detect` only looks for that
directory.
### Capture, encode and display
**A slice block's lying base can no longer ship as a complete frame.** A slice-streamed sentinel's
wire base was bounds-checked (in range, below the final block) but never validated to *tile* the AU:
a base that lied within bounds left a zero gap and an overlap, and the reassembler still stamped the
frame `complete`. The decoder then painted the wrong-offset bytes as garbage rectangles while no
loss counter moved, so the recovery machinery never fired and the corruption marched on
indefinitely — the field report of black bars "moving like an equalizer", identical across Vulkan,
DXVA and CPU decoders, with Android immune because it never negotiates the path.
**A stall learns whether anything was dirty.** `try_consume` samples `GetCursorPos` (rate-limited,
`user32` only) and accumulates motion per gap, with the stall-ending frame's own move held back one
call so it never counts into the gap it ended: `Some(0)` means nothing was dirty anywhere, and
`Some(n > 0)` through a present-free hole means damage existed and the display stack composed none
of it — a positive conviction `CONTENT-SILENCE` could never make. `window_report` attributes the
pre-hole flow (`flow_dwm_only`), so a game's holes are never demoted. The new **DAMAGE-IDLE** class
is excluded from the metronome, from both repeated-stall WARNs and from the `connected_inactive`
blame. This reattributes the 2026-08-27 NVIDIA-laptop case (36 stalls / 27 s) from the dark laptop
panel to damage starvation, and the below-OS METRONOMIC warn stops prescribing panel A/Bs for holes
the witness can already explain.
**AMF** reports its applied bitrate (live readback proof on real VCN hardware), pins AV1 B-pictures
off and asserts no reordering on the bitstream. **NVENC** sub-frame readback is verified in release
builds rather than trusted. The AMD field log self-describes. `pf-win-display`'s adl-emul probe walks
headless AMD adapters and says which it skipped, and why. A wire-MTU-re-keyed session stops blaming
its metronomic recoveries on the display (`wire_rekeys` is published from the send thread, which owns
the packetizer).
**KWin's stored setup** darkened the desk and the restore never saw it: the snapshot now happens
before create, and is re-asserted after teardown.
**An over-declared AV1 OBU is a parse error, not a decode-thread panic.** `obu_size` is a leb128 read
straight out of the stream, bounded only by `u32::MAX` and tied to nothing about how many bytes are
actually present. `read_obu` then built the OBU with an unchecked
`&data[start_offset..start_offset + obu_size]`, so any access unit whose last OBU declared more
payload than remained panicked with `range end index .. out of range`. That is a bounds check rather
than arithmetic, so it panicked in release too, and aborted whichever thread was decoding. It reaches
every native AV1 rung — `pf-vkdecode`, `pf-dxvadec` and `pf-vaadec` are all re-exports of
`pf_bitstream::av1::Av1Planner`. `PUNKTFUNK_AU_FAULT=truncate` produces the shape, and so does any AU
delivered short over the wire.
### A pad's OS identity is host-wide, not the client's wire index
Every OS-level name a virtual pad needs derived from a pad index and nothing else: the
`Global\pfxusb-boot-<i>` / `Global\pfds-boot-<i>` bootstrap mailboxes, the `SwDeviceCreate` instance
ids (`pf_xusb_<i>`, `pf_pad_<i>`, `pf_ds4_<i>`, `pf_xbox_<i>`), and on Linux the DualSense pairing
MAC, the Deck serial and the Switch MAC — the last three documented as needing to be unique per pad,
because `hid-playstation` adopts the MAC as the HID `uniq` and SDL/Steam dedup by that serial.
The host serves up to `DEFAULT_MAX_CONCURRENT` sessions of the same desktop, each with its own input
thread and router, and **every client numbers its first controller wire pad 0**. So two paired
clients each holding a controller collided on all of them. On Windows the second session's
`Shm::create_named` saw `ERROR_ALREADY_EXISTS` on all five retries and never got a pad for the whole
session — and the create-failure hint told the operator to restart the service, which would have
killed both sessions, when no other process was involved. On Linux nothing errored: both minted the
same DualSense MAC and SDL merged the two pads into one controller. New `pf_inject::pad_pool` makes
the OS slot host-wide, claimed on a pad's first present.
### Maintainability sweep
Five places where one fact lived twice now state it once: the control task's 31 positional arguments
become a named-field `Task`; bring-up and the compositor retarget derive the cursor-composite pair
once; the house DRM-node knob is parsed once, in the crate that owns GPU choice; and three
hand-copies of the encoder IR/LTR policy env knob parse once. No behaviour changes.
### Clients and console UI
**Android: a decoder that refused to start took the picture with it.** `configure()` succeeding says
nothing about `start()` — start is where the codec negotiates buffers with its output consumer and
allocates them, so a decoder that accepted the format can still refuse the surface it must render
into. On a Xiaomi Mi TV Stick (Android 11, armeabi-v7a,
`OMX.amlogic.hevc.decoder.awesome2`) every session logged `start failed: ErrorUnknown` and the decode
thread returned, so not one access unit was ever fed while the pump kept receiving video. The frame
queue filled, the pump jumped to live once per `FLUSH_COOLDOWN`, and **the host read that perfect 2 s
keyframe cadence as a client too slow to sustain the stream** — see
`punktfunk-metronomic-keyframe-warn`. Audio, input and the library all kept working, so it presented
as a permanent black screen with sound.
The async loop now gets a bring-up ladder instead of one attempt. A codec that failed start is in an
error state and cannot be reconfigured, so each rung builds a fresh one and sheds what a start can
choke on, most-suspect first: the `AImageReader` the ASC presenter renders into
(`READER_MAX_IMAGES` full-resolution PRIVATE `COMPOSER_OVERLAY` buffers, which the SurfaceView path
does not allocate at all), then the aggressive low-latency key set. Every downstream branch already
keys off `asc.is_some()`, so a fallen-back session runs the SurfaceView presenter that has always
been the API < 29 fallback. **Rung 0 is always exactly what the session asked for**, so a working
device pays nothing, and the winning rung is logged. The sync loop gets no ladder because both axes
are already shed there; it gets the diagnosis in its error line instead. Android also stops reporting
a stored log bundle's `201` as a failed upload.
"Send logs to host" reaches the GTK, WinUI and Android touch shells, carrying the session's trail,
and a console that cannot start says so. The console UI's hint legend resolves from **what drove
last** at every input seam: a pad speaks its own family (PlayStation shapes, Nintendo's own letters
with both pairs swapped the way the pad is engraved, ABXY otherwise), keys on Android are treated as
a TV remote (an OK badge, the ↩ return arrow, the section hint pointing at the D-pad path, and the
Y/X hints hidden outright because a remote has neither), and keys on the desktop stay keyboard
keycaps. The settings tab strip answered only to shoulder buttons; Up from the top row now reaches
it, which is what a TV remote's D-pad has. Windows couch tiles stop minting a console window.
"Capture system shortcuts" now works in Desktop mouse mode — it was wired end to end and then
discarded by one `!desktop` term in the presenter's grab condition. The forwarded cursor folds
`SDL_GetWindowDisplayScale` into its resample factor, so it is no longer half-size on a 200 % client.
### Dependencies
**One move, in the workspace `Cargo.lock` only:** `h2` 0.4.18 → **0.4.19**, closing
**RUSTSEC-2026-0258** (unbounded empty DATA frames), which was left open deliberately at v0.31.1 and
lands here on its own rather than folded into a version bump. The four lockfiles under `tools/` and
`packaging/windows/` do not move, and no `package.json` under `web/`, `sdk/` or `plugin-kit/` moves.
### CI and supply chain
Every `checkout` and cache action is pinned to a **commit**, not a tag — a tag is a pointer, not a
version — and the stale `v4` majors are gone. The tools the builder images bake in are pinned too,
not just the ones the jobs fetch: `cargo-ndk` (it builds the shipped Android `.so` files), and the
FFmpeg **commits** we actually build rather than the git tag that names them. Release-signing jobs
pin `bun`, `sccache` and actions by checksum/SHA. Two new wire constants are baselined as protocol
internals rather than knobs, and the two new exported constants carry the `PUNKTFUNK_` prefix in the
C header (R21).
Three fixes to the C harness itself. Two had been hiding failures on a developer machine: it now
links on an Apple Silicon box (`-L/opt/homebrew/lib` was missing), and it always builds the
staticlib **with** `quic`, because a featureless `.a` left by an earlier plain build was being
silently reused.
The third had been breaking CI. `tests/c_abi.rs` shells out to a nested `cargo build -p
punktfunk-core --features quic` while the outer `cargo test --workspace` is mid-run. Resolving
features for one package is not the workspace union the outer run resolved, so cargo rebuilt that
subgraph under different metadata **into the shared target directory**, and the outer run's pending
units — which name `target/<profile>/deps/*.rlib` by explicit `--extern` path — were left pointing
at artifacts that had been replaced. Doctests run last, so they are what fell over:
`Doc-tests pf_capture` died with `E0463: can't find crate for pf_frame`, a crate nothing in the diff
had touched, after a green Build and two green Clippy legs. The nested build now uses
`target/c-abi-harness/` and cannot perturb the outer one. The old comment claimed the released build
lock made it safe; the released lock is why it *runs*.
---
## v0.31.4
21 commits since v0.31.3 (14 non-merge), counted at the tip this was cut from.
**Nothing versioned moves.** `WIRE_VERSION` stays **2**, the C ABI stays **25**
`include/punktfunk_core.h` is **byte-identical to the v0.31.3 tag, and to v0.31.2 and v0.31.1
before it**, the third release running with no diff at all — and so do the driver protocol, the
gamepad channel, the plugin index schema, the host event schema and the gamescope patch level.
`pf-driver-proto` shows no diff. `api/openapi.json` is **content-identical**: only `info.version` is
re-stamped, so this is the first release since v0.31.0 where the management API grows nothing.
`@punktfunk/host` stays **0.1.6** and `@punktfunk/plugin-kit` stays **0.4.4**; nothing under `sdk/`
or `plugin-kit/` moved. No dependency moves in any of the five Rust lockfiles.
The cycle is **Linux-host shaped**, and the faults share a premise: *a policy that was written
against one desktop and silently did nothing on the others.* `Topology::Exclusive` on a gamescope
session was implemented as `org_kde_kwin_dpms` and reached only a live KDE desktop, so a Game Mode
box — the deployment whose TV the operator most wants dark — got nothing (#389, five commits:
a DRM floor for a box with no desktop at all, sway and Hyprland arms, the managed route's own
hold, and the box-session free that was gated on Steam). The `sleep:idle` block inhibitor refused
every `Suspend()` for the length of a stream, including the operator's own from Steam's power menu,
and on a display-manager-stopping takeover polkit's `allow_active` fallback found no session to
elect and dropped all three power actions to `auth_admin_keep` (#391). The web console served the
**legacy** identity because every launcher names that pair and none of them *can* choose, so
browsers rejected it outright and the tray's mgmt-pinned probe refused the handshake and labelled a
healthy console dead (#393). Alongside: the client's encode down-driver, whose thresholds were
absolute durations calibrated at 120 Hz and whose stand-down was permanent (#392); an
`installTap` format snapshot that could reach the Objective-C terminate handler (#387); the
console's bitrate row (#388); and a GTK entry point for the console UI (#390).
### Versions
| | v0.31.3 | v0.31.4 | Notes |
|---|---|---|---|
| Wire protocol | 2 | **2** | unchanged. No message added, removed or re-shaped |
| C ABI | 25 | **25** | unchanged. `include/punktfunk_core.h` has **no diff at all** against the v0.31.3 tag — nor against v0.31.2 or v0.31.1. Third release running |
| Rust edition | 2024 | **2024** | unchanged |
| MSRV (`rust-version`) | 1.85 | **1.85** | unchanged |
| Workspace crate dirs | 27 | **27** | unchanged (39 `[workspace] members`, also unchanged). `drm_dpms.rs` is a new **module** inside `pf-vdisplay`, not a new crate |
| Virtual-display driver protocol | 6 | **6** | unchanged (minimum accepted still 3); `pf-driver-proto` shows no diff against the v0.31.3 tag |
| Windows virtual-gamepad channel | 3 | **3** | unchanged. Nothing under the Windows gamepad backends moved |
| Plugin index schema | 1 | **1** | unchanged |
| Host event schema | 1 | **1** | unchanged (`punktfunk-host/src/events.rs` shows no diff) |
| `api/openapi.json` | 0.31.3 | **0.31.4** | **content-identical** — the `info.version` stamp is the whole diff. No route, schema or field added, removed or re-shaped. Re-stamped, not regenerated (`punktfunk-host` does not build on macOS); `api/` and `docs-site/public/` are byte-identical to each other |
| gamescope patch level (`+pfhdrN`) | 8 | **8** | unchanged; no new patch files, `packaging/gamescope/PKGBUILD` still declares `pfhdr8` |
| `@punktfunk/host` (SDK) | 0.1.6 | **0.1.6** | unchanged; nothing under `sdk/` moved, and the API it wraps did not either |
| `@punktfunk/plugin-kit` | 0.4.4 | **0.4.4** | unchanged; nothing under `plugin-kit/` moved. 0.4.4 remains the registry's `latest` |
### ⚠ Breaking changes
**None.** No wire change, no ABI change, no driver-protocol change, no plugin-contract change, and
this time not even an additive API change. Every 0.31.x host, client, driver and plugin keeps
interoperating in both directions with no re-pairing and no rebuild.
One **source-level rename**, internal to `pf-vdisplay` and visible to nobody outside it:
`kwin_dpms``panel_dpms`. It is no longer a KWin module. `org_kde_kwin_dpms` itself — the
vendored protocol's interface name, which is not ours to rename — is untouched.
Five **behaviour** changes that break no build but change what a machine does:
- **The `sleep:idle` inhibitor is no longer held for the whole stream.** It is held only while the
stream is quiet, dropped synchronously on any client input on either plane, and re-taken after
`QUIET_BEFORE_VETO` (30 s) of silence. A host that previously vetoed every `Suspend()` for a
session's length now vetoes only the ones that arrive during genuine inactivity.
- **`Topology::Exclusive` now darkens panels on sway, Hyprland and a desktop-less box**, where it
previously did nothing. If you run a gamescope session on one of those and had selected
`exclusive` expecting the old no-op, you now get dark panels. `extend`, `primary` and the
`SharedDesktop` preset are unchanged and still take no hold at all.
- **`stop_autologin_sessions` is now reached for a non-Steam library launch** when the topology is
exclusive. Previously only `is_steam_launch` reached it. Best-effort: it warns and continues,
unlike the Steam arm above it, because there the cost is a dark screen rather than the game.
- **The encode-attributed backoff re-arms after standing down**, on a doubling interval, rather
than being disarmed for the rest of the session. Its SEVERE/MILD thresholds are now sized from
the negotiated refresh (a fraction of the session's frame budget) instead of the absolute
durations calibrated at 120 Hz, so a 60 Hz session no longer takes an immediate ×0.7 on an
ordinary one-frame hiccup.
- **The web console prefers the native identity pair.** When `PUNKTFUNK_UI_TLS_CERT` names
`cert.pem` and `native-cert.pem`/`native-key.pem` both exist beside it, the console serves the
native pair. A host with no native pair on disk, or an operator-supplied cert under any other
name, falls through unchanged. `serve` now resolves the native identity **before** minting the
legacy one.
### `Topology::Exclusive` becomes a per-desktop dispatcher
`kwin_dpms` asked KWin for DPMS-off, which is the right answer whenever there is a KDE desktop to
ask. A gamescope session owns no output on the box's own desktop, so the desktop backends' `disable`
route is wrong here — disabling would move the operator's workspaces and re-home their windows for a
stream that is not even on their compositor, and on Hyprland a disabled head has no clean undo (the
only known restore is `hyprctl reload`, which drops every runtime override). DPMS is a separate axis
and round-trips cleanly. The module is renamed `panel_dpms` and becomes a dispatcher, each arm
self-gating on its own IPC so a box only pays for the one that answers:
| desktop | mechanism |
|---|---|
| KDE / KWin | in-process `org_kde_kwin_dpms`, then `kscreen-doctor` |
| sway (wlroots) | `swaymsg output <name> dpms off` |
| Hyprland | `hyprctl dispatch dpms off <name>`, then the Lua `hl.dsp.dpms(...)` spelling |
| none at all | `drm_dpms` — the CRTCs off over DRM |
| GNOME / Mutter | **cannot be served** |
Both compositor arms reuse their backend's existing `heads_to_disable` filter with an **empty**
`ours` — a gamescope session owns nothing to spare, but a concurrent wlroots session's `HEADLESS-*`
still must be spared or darkening blacks out that client's stream. They address heads by name and
report which ones actually changed, so the re-light undoes exactly those.
**GNOME is a structural gap, not an omission.** Mutter exposes no DPMS to clients; its own
`exclusive` mechanism (`ApplyMonitorsConfig` omitting the physicals) needs a virtual output of its
own to keep enabled, which a gamescope session does not have; and the DRM floor is refused because
Mutter holds master. The decline was at `debug!` and is now a `warn!` naming the reason — every one
of these means "you asked for your screens off and they stayed on".
**Not verified on glass: the sway arm.** The lab has Bazzite, Nobara and SteamOS — no sway box. Its
argv shape and the sibling-sparing filter are unit-tested and it compiles clean, but the command has
not been run against a live sway session. Hyprland, KDE and DRM were all measured on real machines.
### `drm_dpms`: the CRTCs off over DRM, for a box with no desktop to ask
Measured on the Nobara VM (123, Fedora 44) before any of it was written, because the cheaper answers
all fail on real hardware: getting the box's session out of the way is **not** enough (with no
client holding DRM master the kernel keeps the CRTC configured — `enabled=enabled dpms=On`
indefinitely); `setterm --blank force` is unreachable (`/dev/tty1` is `crw------- root:tty`); the
connector's `dpms` sysfs attribute is read-only even as root; and `/sys/class/graphics/fb0/blank`
works but needs root, which the takeover deliberately stopped needing in 0.31.0.
What works unprivileged: `/dev/dri/cardN` carries a logind uaccess ACL for whoever holds the active
seat (`crw-rw----+`), and `SET_MASTER` succeeds while nobody else is master — exactly the state the
takeover has just produced. Walk `GETRESOURCES`, `GETCRTC` each id, `SETCRTC` with `fb_id=0,
mode_valid=0, count_connectors=0` on the ones actually driving something. The connector reaches
`enabled=disabled dpms=Off`, the same end state `kscreen-doctor --dpms off` reaches through KWin.
Two measured properties shrank the code. The darkness **survives `DROP_MASTER`**, so mastership is
handed straight back — a host still holding it would starve the box's own gamescope when the restore
relaunches its session. And the re-light is **`close(fd)`**: the kernel's last-close restores the
console, so there is no saved mode to replay, no restore that can half-fail, and crash safety is
free. Nothing to journal, nothing to sweep at startup. `SET_MASTER` failing is also the natural
guard for the Attach route — a card already mastered by a live compositor is not ours, and darkening
it would darken the very picture being streamed.
Four ioctls, three `#[repr(C)]` structs, one `unsafe` block. The ioctl numbers encode their payload
size (`0x40`, `0x68`), so the layouts are pinned by `const _: () = assert!(...)` at compile time,
plus a test that restates it greppably and pins the two field offsets the count/data-pass dance
depends on.
### The managed gamescope route takes the hold too
Managed reports `DisplayOwnership::SessionManaged`, and `registry::acquire` returns for anything not
`Owned` **above** its `take_topology_restore()` pickup — so a hold registered the way the bare spawn
does would never be released, and a panel dark after every stream is worse than one left lit. That
is an argument for putting the release somewhere else, not for skipping the feature on the route
that matters most (it is the recommended one, and the only way to serve a client its own virtual
output at its own mode).
Somewhere else is `do_restore_tv_session`, the one teardown every managed path funnels through, at
the very **top** of it because the SteamOS branch returns above the existing drop-in sweep.
The hold is a **bool, not a count**, and that is load-bearing: the managed *session* is what gets
darkened, not each connect. It outlives client disconnects and a same-mode reconnect reuses it warm,
so acquiring per connect would ratchet the refcount up with no matching releases and pin the
operator's panel dark for the rest of the host's life. The edges are split into pure
`managed_darken_{acquire,release}_edge` so the balance rule is unit-tested without a live
compositor: reconnect takes no second hold, the unconditional release is idempotent, a full cycle
re-arms, and `extend` / `SharedDesktop` still take no hold at all.
### The box's own session is freed for `exclusive`, not just for Steam
`stop_autologin_sessions` was reached only via `is_steam_launch`
(`cmd.split_whitespace().next() == Some("steam")`), because freeing Steam's single instance was the
only requirement anyone had for it. But on a Game Mode box that same session is the DRM master of
the TV, so a **non-Steam** library game left it running and the panel showed live Game Mode for the
whole stream — the 2026-08-24 Nobara field report. `exclusive` is now stated as a second,
independent reason: `free_box_session_for_exclusive` is pure and unit-tested over all four
Steam × Exclusive combinations, including that `extend` and the `SharedDesktop` preset must still
leave the box strictly alone.
A related comment had rotted: the one authorising the DPMS skip on this file's other routes ("its
takeover already stopped the desktop") outlived the stop it named by three commits — `c2f5e91b`
replaced the managed takeover's display-manager STOP with an idle drop-in — so the gap read as
intentional. It is replaced by the two reasons that actually hold, written at the returns they
belong to.
### Hyprland: the dpms dispatcher is a toggle, and the classic argv does not parse under Lua
Verified on the NixOS VM (125, Hyprland 0.55.4), and both halves of the arm as first shipped were
wrong — which is why it went on glass.
`hyprctl dispatch dpms off <name>` **dies on the Lua config manager**: `dispatch` is shorthand for
`hl.dispatch(...)`, so the bare words are parsed as a Lua expression —
error: [string "return hl.dispatch(dpms off HDMI-A-1)"]:1:
')' expected near 'off'
The Lua spelling is `hl.dsp.dpms("off", "<name>")`. A hyprlang box wants the classic form, there is
no stable probe for which manager is loaded, and `hyprctl_dispatch` already catches the exit-0
rejections both produce — so: try classic, then Lua, and report both failures if neither lands.
And the dispatcher is a **toggle that ignores the state word**. Measured, both spellings, positional
and table:
On ==[ hl.dsp.dpms("on", "HDMI-A-1") ]==> Off <- asked ON, got OFF
Off ==[ hl.dsp.dpms("on", "HDMI-A-1") ]==> On
Off ==[ hl.dsp.dpms{state="off", ...} ]==> On <- asked OFF, got ON
So a blind "send off, later send on" would **light** an already-dark head at stream start and
**darken** a lit one at teardown — the operator's screen left off after the stream, precisely the
failure this policy exists to prevent. `dpms_one` is therefore read → act only if the state differs
→ verify, via `hyprctl -j monitors all`'s `dpmsStatus` (measured to track the connector's sysfs
`dpms` exactly in both states; a dark monitor stays listed). That shape is also correct where the
call really is a set, so it is not conditional on detecting the manager. It returns whether it
**changed** anything, and `dpms_other_heads` records only those.
### The sleep inhibitor is held only while the stream is quiet
Since `b7a00137` the host held a logind `sleep:idle` inhibitor in **BLOCK** mode for the whole
length of a stream, so a passive video-only viewer could not have the box suspend out from under
them. A block lock on `sleep` refuses **every** suspend, though: "Sleep" in Steam's Big Picture
power menu reaches logind as the same `Suspend()` call and comes back as
Operation inhibited by "Punktfunk" (PID …), reason is "a client is streaming".
Nothing in that UI surfaces a D-Bus error, so the entry simply did nothing for as long as anyone was
connected. Reproduced on a Bazzite host, 2026-08-24, by taking the same lock by hand.
The veto is now held only while the stream is quiet. Any client input on either plane drops it
**synchronously** — releasing is a `close(2)` on the inhibitor fd, no round trip, so a Sleep press
cannot race it — and it is re-taken after 30 s of silence. That is the line the original
justification already drew: a person choosing Sleep is by definition sending input, and a passive
viewer never does. Both planes stamp the clock at their single input entry point: the native input
thread's channel `recv` (**before** the grant tests — a denied event still means a person is there)
and the GameStream control plane's three decode arms, **past** the keepalive gate, because a
keepalive is the one thing a passive viewer does send.
### polkit: three power actions for a session-less takeover
The other half of "the power menu does nothing during a stream", and an independent fault. logind
ships `power-off`/`reboot`/`suspend` as `allow_active: yes`. polkit decides "active" from the
caller's own logind session and, for a caller that has none — every `systemd --user` unit, which is
exactly what the managed gamescope session is — falls back to the user's elected DISPLAY session.
logind elects that only from `user`/`greeter` class sessions, never from the user manager's own, so
a takeover that **stops** the display manager removes the last candidate and all three actions drop
to `auth_admin_keep`: an interactive password prompt, put to a non-interactive caller, on a screen
that is switched off.
On SteamOS-like boxes that is the call being refused. Steam does not ask logind for "Shut Down" at
all — it writes `$STEAMOS_STEAM_SHUTDOWN_SENTINEL` and exits, and `gamescope-session-plus` runs a
plain `poweroff` once Steam is gone. During a stream that wrapper is ours, in the session-less
transient unit.
Measured on Bazzite, 2026-08-24: the identical
`pkcheck --action-id org.freedesktop.login1.power-off` from a `systemd --user` unit answers
authorized with sddm up, `auth_admin_keep` with sddm stopped, and authorized again in that same
stopped state with this rule installed.
`packaging/linux/49-punktfunk-power.rules`, installed by the deb, rpm and arch packagings, scoped to
the (shipped-empty) `punktfunk` group — the same group the takeover's own root helper
(`io.unom.punktfunk.dm-helper`) authorizes on, so it grants to exactly the population the fault
reaches. The `-multiple-sessions` and `-ignore-inhibit` variants are deliberately **not** granted.
### The encode down-driver: frame-budget thresholds and a re-armable stand-down
A 1440p60 Lutris session ratcheted **57 → 5 Mbps over ten minutes** on a link that never dropped a
packet — no keyframe asks, no flushes, a flat decoder, and the host granting every rate it was
asked for. Two causes, both client-side.
**The thresholds were absolute durations calibrated at 120 Hz** ("~half a frame budget",
"≈1.5 × a frame budget"). At 60 Hz one frame is 16.7 ms, so an ordinary one-frame encode hiccup
cleared the SEVERE tier and took the immediate ×0.7, where the same hiccup at 120 Hz (8.3 ms) never
reached it — the exact asymmetry the field log shows against 1440p120 sessions on the same host and
client. They are now sized in the session's own frame budget, plumbed from the negotiated refresh
and re-read on a mode switch.
**And the signal fired for a cause the rate cannot fix.** It exists to find the encoder's compute
knee, where cutting the rate cuts the work; when encode time is held up by a game saturating the
GPU that premise is false, the backoff changes nothing, and `on_ack`'s baseline re-seed erases the
evidence that nothing improved. Each encode-attributed backoff now remembers the level it fired at:
two in a row that fire no lower mean the rate is not the lever, and the signal stands down. Same
shape as `NOOP_CLOCK_FLUSHES_TO_DISARM`. Loss, OWD, decode and keyframe signals keep their full
power throughout, and the host's own climb refusal stays the backstop for a genuine knee.
**The stand-down re-probes rather than lasting the session.** Nothing else this controller learns
from evidence is permanent — both learned caps re-probe on the `CAP_REPROBE_WINDOWS_MIN` ladder, and
the clock-flush detector was itself changed from "off for the rest of the session" to re-armable for
exactly this reason. What the stand-down answers is transient by nature (contention ends when the
game exits to a menu, when the shader storm finishes, when the second app on the card closes), and
what it silences is the only signal that can descend when the encoder is genuinely past its knee on
a link that shows nothing else. A clean run re-arms it, and the interval doubles each time the
silence is immediately re-earned, so standing contention settles into a slow poll rather than
thrashing. The asymmetry is the argument: a too-eager re-arm costs one ×0.7 and stands down again; a
too-permanent silence costs the protection outright.
### The web console serves the native identity; the tray's probe drops its pin
The host has kept two identities since the identity split (`crate::identity`): `native-cert.pem` /
`native-key.pem` (P-256, real SANs — what the native QUIC plane, the mgmt API and every native
client pin) and the legacy `cert.pem` / `key.pem` (RSA, `CN=punktfunk`, **no SAN**, kept byte-stable
for Moonlight). The web console never followed the split. Every launcher — the systemd unit, the
NixOS module, the Windows service supervisor, `web-run.cmd`, the Steam Deck installer — names the
**legacy** pair, and none of them *can* choose: `Environment=` has no "this file, else that one". So
the console served a certificate with no SAN at all, which costs twice over: browsers reject a
CN-only cert outright (`ERR_CERT_COMMON_NAME_INVALID`), and the tray's loopback liveness probe
reused the agent **pinned** to the mgmt identity, so rustls refused the handshake and a healthy
console was labelled "not responding" — next to a tooltip reading "idle", which is the proof, since
the same agent reached mgmt fine on the very same tick.
The entry (`web/nitro-entry/tls-paths.mjs`) is the one place every launcher routes through, so the
choice is made there: prefer the native sibling pair when both files exist, **as a pair or not at
all** (a native cert with the legacy key completes no handshake with anyone). It is a **suffix
test**, not `node:path`: `node:path` resolves per-runtime, so a POSIX CI runner reads
`C:\ProgramData\punktfunk\cert.pem` as one long filename and never swaps — and Windows, where
`windows/service.rs` hands us exactly that, is the platform the CI job can never exercise. The
suffix test gives the same answer everywhere, leaves the prefix **verbatim** (where
`join(dirname(p), …)` normalised `/a/b/../cert.pem` into a different directory the moment `b` was a
symlink), and cannot hand back a mismatched pair. Existence is not enough:
`pf_paths::write_secret_file` is create+truncate+write rather than temp+rename, so a console
starting mid-write could adopt a 0-byte cert and leave `Bun.serve` throwing on every restart (the
Steam Deck unit is `Restart=on-failure` under the default rate limit, i.e. permanently dead) — the
check is a **non-empty** stat, mirroring the host's own `!c.trim().is_empty()`.
`serve` now resolves the native identity **before** minting the legacy one. That closes a first-run
window where the console (which waits on `cert.pem`) could start between the two writes and serve
the SAN-less cert for the rest of the boot, and a second latent fault: with `cert.pem` missing but
native clients paired, the old order let `load_or_create` mint a brand-new `cert.pem` that
`load_or_adopt` then adopted while logging that it was preserving their pins.
The tray's console probe **loses** its pin rather than gaining a second one. It is a different
server and there is no rule that it presents the mgmt certificate — an operator fronting the console
with their own LAN-CA cert would have hit this just as squarely. The probe sends no credentials,
reads no body, and decides only a menu label (plus whether a tray-icon single click opens the
console, which `win.rs` gates on `console_up`). On Windows it was never pinned to begin with:
`punktfunk_config_dir` is `None` off Linux, so `load_pin` already returned `None`.
`web/`'s test scope grows from `server/` to `server/ nitro-entry/`; 18 tests, including a win32
case.
### Apple: the mic tap installs with `format: nil` and follows the device
`installTap(onBus:bufferSize:format:)` validates a non-nil format against the bus and raises an
Objective-C exception on **any** mismatch. Swift cannot catch that, so it reached the terminate
handler and aborted the process — SIGABRT in `AVAudioEngineGraph::InstallTapOnNode`, crashing macOS
0.31.0 at **session start**, not at launch.
The format handed to the tap is necessarily read a moment earlier
(`input.outputFormat(forBus: 0)`), and on macOS the input can move underneath it in that window: a
device switch, a clock/rate change, or the `kAudioOutputUnitProperty_CurrentDevice` swap
`startCapture` itself performs two lines before. The existing guard only rejected the
0 Hz / 0-channel case, which is a different failure — a device that is absent, not one that changed.
Installing with `format: nil` is the documented "use the bus's own format" and makes the mismatch
unrepresentable rather than merely unlikely. The tap then has to follow the real format, so the
rate-dependent pieces (mono bus, resampler, both scratch buffers) move into a `MicChain` the tap
rebuilds when `buffer.format.sampleRate` differs — a chain pinned to a stale rate would resample by
the wrong ratio and pitch-shift the mic. That rebuild subsumes the old grow-on-larger-quantum
branch; the steady state still allocates nothing. Tests cover the sizing arithmetic, including that
`staging` fits the **upward** ratio, which silently truncates every packet on any device below
48 kHz if it is sized for the input rate instead.
### The console's bitrate row: 30 rungs, a typed rate, and neighbour stepping
The gamepad shell's Bitrate picker had been seven rungs ending at **80 Mbps** since the console
shipped — a ceiling a user ran into — while the GTK dialog beside it has always gone to
3000 Mbit/s, so the two surfaces disagreed about what the machine may ask for and the console was
the smaller. Three changes, one row:
- **30 rungs, 1 Mbps to 2 Gbps.** Tight at the bottom (1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 25 …)
where one rung decides whether a thin link is watchable, coarse at the top where a rung is noise.
Rates at or above a gigabit read as Gbps.
- **Y opens a typed rate on that row** — four digits, through the tray keyboard (or SDL text input,
and Steam's own keyboard on a Deck) exactly like the add-host and pair fields. **A** goes on
cycling the ladder everywhere, so the console's grammar is unchanged.
- **A rate that is not a rung steps to its neighbour.** The generic picker snaps an unrecognised
value to its first option, which on this row is Automatic: one nudge threw away a rate typed here
or set by the desktop spinner.
`Screen::edit_key` now takes the context, because this is the first field that commits into the
settings store when it closes rather than holding text for a later action row. The desktop dialog
gets the same complaint's other half: its spinner steps 1 Mbit/s instead of 5, so 3, 4 and 6 are
reachable without typing.
### Linux client: a front door to the console UI
The gamepad console has shipped since the arch split, but on Linux the only way in was
`punktfunk-session --browse` (or `punktfunk-client --browse`, which execs it) on a command line. The
WinUI shell has had both doors for a while; this is the GTK half. Three entry points, one
destination: a gamepad button in the hosts header (`input-gaming-symbolic`, left of the hamburger —
the placement WinUI gives it); a "Console UI" main-menu entry so the mode has a searchable name;
and `io.unom.Punktfunk.Console.desktop`, a second launcher shipped by the deb, rpm, arch and flatpak
packagings, which is what an app-grid search finds and what gets added to Steam as a non-Steam game.
Its `Exec=punktfunk-client --browse --fullscreen` deliberately goes through the shell binary — that
argv already execs the session, and it is the command flatpak's `Exec` rewrite expects.
The in-shell launch is a `gio::Subprocess`: `wait_check_async` lands the child's exit on the GTK
main loop with no thread and no channel, releases `busy`, refreshes the host list (the console can
pair hosts), and banners a non-zero exit — which is also how a session built without its `ui`
feature surfaces ("--browse needs the console UI", exit non-zero).
**The Nix build does not install the new desktop entry**: it compiles the session
`--no-default-features`, so that launcher could only ever print an error. Noted in
`packaging/nix/README.md` next to the existing Skia caveat.
### Dependencies
**None.** No lockfile in the repo moves — not the workspace `Cargo.lock`, not the four under
`tools/` and `packaging/windows/`, and no `package.json` under `web/`, `sdk/` or `plugin-kit/`.
### CI
`ci.yml`'s web test step widens from `bun test server/` to `bun test server/ nitro-entry/`, so the
identity-selection gate runs where the origin-isolation gate already did. Both have the same
property: a failure mode only a browser would catch.
---
## v0.31.3
41 commits since v0.31.2 (26 non-merge), counted at the tip this was cut from.
Generated
+41 -40
View File
@@ -1090,7 +1090,7 @@ dependencies = [
[[package]]
name = "cursor-probe"
version = "0.31.3"
version = "0.32.0"
dependencies = [
"anyhow",
"pf-capture",
@@ -1222,7 +1222,7 @@ dependencies = [
[[package]]
name = "display-disturb"
version = "0.31.3"
version = "0.32.0"
dependencies = [
"pf-win-display",
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
@@ -1959,9 +1959,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.4.18"
version = "0.4.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228"
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
dependencies = [
"atomic-waker",
"bytes",
@@ -2343,7 +2343,7 @@ dependencies = [
[[package]]
name = "latency-probe"
version = "0.31.3"
version = "0.32.0"
[[package]]
name = "lazy_static"
@@ -2446,7 +2446,7 @@ dependencies = [
[[package]]
name = "libvpl-sys"
version = "0.31.3"
version = "0.32.0"
dependencies = [
"bindgen",
"cmake",
@@ -2475,7 +2475,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.31.3"
version = "0.32.0"
dependencies = [
"punktfunk-core",
]
@@ -2967,7 +2967,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pf-bitstream"
version = "0.31.3"
version = "0.32.0"
dependencies = [
"cros-codecs",
"tracing",
@@ -2975,7 +2975,7 @@ dependencies = [
[[package]]
name = "pf-capture"
version = "0.31.3"
version = "0.32.0"
dependencies = [
"anyhow",
"ashpd",
@@ -2996,13 +2996,14 @@ dependencies = [
[[package]]
name = "pf-client-core"
version = "0.31.3"
version = "0.32.0"
dependencies = [
"anyhow",
"ash",
"async-channel",
"libc",
"libloading 0.9.0",
"log",
"mdns-sd",
"openh264",
"opus",
@@ -3022,6 +3023,8 @@ dependencies = [
"serde_json",
"sha2 0.11.0",
"tracing",
"tracing-log",
"tracing-subscriber",
"ureq",
"wasapi",
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
@@ -3032,7 +3035,7 @@ dependencies = [
[[package]]
name = "pf-clipboard"
version = "0.31.3"
version = "0.32.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3050,7 +3053,7 @@ dependencies = [
[[package]]
name = "pf-console-ui"
version = "0.31.3"
version = "0.32.0"
dependencies = [
"anyhow",
"ash",
@@ -3073,7 +3076,7 @@ dependencies = [
[[package]]
name = "pf-dxvadec"
version = "0.31.3"
version = "0.32.0"
dependencies = [
"cros-codecs",
"pf-bitstream",
@@ -3083,7 +3086,7 @@ dependencies = [
[[package]]
name = "pf-encode"
version = "0.31.3"
version = "0.32.0"
dependencies = [
"anyhow",
"ash",
@@ -3109,7 +3112,7 @@ dependencies = [
[[package]]
name = "pf-frame"
version = "0.31.3"
version = "0.32.0"
dependencies = [
"anyhow",
"libc",
@@ -3122,7 +3125,7 @@ dependencies = [
[[package]]
name = "pf-gpu"
version = "0.31.3"
version = "0.32.0"
dependencies = [
"anyhow",
"pf-host-config",
@@ -3136,11 +3139,11 @@ dependencies = [
[[package]]
name = "pf-host-config"
version = "0.31.3"
version = "0.32.0"
[[package]]
name = "pf-inject"
version = "0.31.3"
version = "0.32.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3169,14 +3172,14 @@ dependencies = [
[[package]]
name = "pf-paths"
version = "0.31.3"
version = "0.32.0"
dependencies = [
"tracing",
]
[[package]]
name = "pf-presenter"
version = "0.31.3"
version = "0.32.0"
dependencies = [
"anyhow",
"ash",
@@ -3191,7 +3194,7 @@ dependencies = [
[[package]]
name = "pf-update"
version = "0.31.3"
version = "0.32.0"
dependencies = [
"serde",
"serde_json",
@@ -3199,7 +3202,7 @@ dependencies = [
[[package]]
name = "pf-update-check"
version = "0.31.3"
version = "0.32.0"
dependencies = [
"anyhow",
"aws-lc-rs",
@@ -3211,7 +3214,7 @@ dependencies = [
[[package]]
name = "pf-vaadec"
version = "0.31.3"
version = "0.32.0"
dependencies = [
"cros-codecs",
"pf-bitstream",
@@ -3220,7 +3223,7 @@ dependencies = [
[[package]]
name = "pf-vdisplay"
version = "0.31.3"
version = "0.32.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3253,7 +3256,7 @@ dependencies = [
[[package]]
name = "pf-vkdecode"
version = "0.31.3"
version = "0.32.0"
dependencies = [
"ash",
"cros-codecs",
@@ -3264,7 +3267,7 @@ dependencies = [
[[package]]
name = "pf-win-display"
version = "0.31.3"
version = "0.32.0"
dependencies = [
"pf-paths",
"punktfunk-core",
@@ -3275,7 +3278,7 @@ dependencies = [
[[package]]
name = "pf-zerocopy"
version = "0.31.3"
version = "0.32.0"
dependencies = [
"anyhow",
"ash",
@@ -3487,7 +3490,7 @@ dependencies = [
[[package]]
name = "punktfunk-cli"
version = "0.31.3"
version = "0.32.0"
dependencies = [
"pf-client-core",
"punktfunk-core",
@@ -3497,7 +3500,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.31.3"
version = "0.32.0"
dependencies = [
"android_logger",
"anyhow",
@@ -3521,7 +3524,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-linux"
version = "0.31.3"
version = "0.32.0"
dependencies = [
"anyhow",
"async-channel",
@@ -3538,23 +3541,21 @@ dependencies = [
[[package]]
name = "punktfunk-client-session"
version = "0.31.3"
version = "0.32.0"
dependencies = [
"log",
"pf-client-core",
"pf-console-ui",
"pf-presenter",
"punktfunk-core",
"serde_json",
"tracing",
"tracing-log",
"tracing-subscriber",
"winresource",
]
[[package]]
name = "punktfunk-client-windows"
version = "0.31.3"
version = "0.32.0"
dependencies = [
"async-channel",
"mdns-sd",
@@ -3572,7 +3573,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.31.3"
version = "0.32.0"
dependencies = [
"aes-gcm",
"cbindgen",
@@ -3605,7 +3606,7 @@ dependencies = [
[[package]]
name = "punktfunk-encode-worker"
version = "0.31.3"
version = "0.32.0"
dependencies = [
"pf-encode",
"tracing",
@@ -3614,7 +3615,7 @@ dependencies = [
[[package]]
name = "punktfunk-host"
version = "0.31.3"
version = "0.32.0"
dependencies = [
"aes",
"aes-gcm",
@@ -3684,7 +3685,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.31.3"
version = "0.32.0"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3698,7 +3699,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.31.3"
version = "0.32.0"
dependencies = [
"anyhow",
"ksni",
@@ -3722,7 +3723,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "pyrowave-sys"
version = "0.31.3"
version = "0.32.0"
dependencies = [
"bindgen",
"cmake",
+1 -1
View File
@@ -65,7 +65,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.31.3"
version = "0.32.0"
edition = "2024"
rust-version = "1.85"
license = "MIT OR Apache-2.0"
+1 -1
View File
@@ -102,7 +102,7 @@ installer (all-vendor: NVIDIA, AMD, Intel).
|--------|---------|-------|
| **Ubuntu 26.04+ / Debian 13+** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu](https://docs.punktfunk.unom.io/docs/ubuntu) · [Debian](https://docs.punktfunk.unom.io/docs/debian) · [packaging/debian](packaging/debian/README.md) |
| **Bazzite / Fedora Atomic** (systemd-sysext) | `curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh && sudo bash punktfunk-sysext.sh install` *(no layering, no reboot; rpm-ostree + bootc also supported)* | [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
| **Fedora** (dnf) | `sudo dnf install punktfunk` *(after adding the repo; the console comes with it)* | [Fedora](https://docs.punktfunk.unom.io/docs/fedora) · [packaging/rpm](packaging/rpm/README.md) |
| **Fedora** (dnf) | `sudo dnf install punktfunk punktfunk-web punktfunk-scripting` *(after adding the repo)* | [Fedora](https://docs.punktfunk.unom.io/docs/fedora) · [packaging/rpm](packaging/rpm/README.md) |
| **Arch / CachyOS** (pacman) | `sudo pacman -Syu punktfunk-host` *(binary repo — always a full `-Syu`)* | [Arch Linux](https://docs.punktfunk.unom.io/docs/arch) · [packaging/arch](packaging/arch/README.md) |
| **SteamOS / Steam Deck** (on-device build) | `bash ~/punktfunk/scripts/steamdeck/install.sh` *(after cloning this repo to `~/punktfunk`)* | [SteamOS (Host)](https://docs.punktfunk.unom.io/docs/steamos-host) |
| **Windows** (11 22H2+, x64) | `winget install unom.PunktfunkHost` *(after `winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest`)* · or the signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) · [packaging/winget](packaging/winget/README.md) |
+4 -1
View File
@@ -82,7 +82,10 @@ us beyond the download itself.
checks every package for you. `rpmkeys --checksig` on a downloaded RPM verifies it by hand.
- **The Bazzite sysext feed** carries a detached signature over its `SHA256SUMS`, from that same
key. `punktfunk-sysext` verifies it before installing and refuses a feed it cannot verify — the
public key is baked into the script rather than fetched from the feed.
public key is baked into the script rather than fetched from the feed. The manifest also names
the feed it was signed for and carries a monotonic publish serial, both inside the signed bytes,
so a genuinely-signed manifest replayed from another channel — or an older one put back — is
refused too.
- **Windows installers and MSIX packages** are Authenticode-signed; a release build that cannot
reach its code-signing certificate fails to build rather than falling back to a self-signed one.
Check with `Get-AuthenticodeSignature punktfunk-host-setup-1.2.3.exe`.
+1 -1
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.31.3"
"version": "0.32.0"
},
"paths": {
"/api/v1/client-logs": {
+13 -3
View File
@@ -46,16 +46,26 @@ ENV RUSTUP_HOME=/usr/local/rustup \
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --no-modify-path --profile minimal \
&& rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android \
&& cargo install cargo-ndk --locked \
# Version-pinned like every other tool baked in here: unpinned, a rebuild months apart
# silently bakes a different cargo-ndk, and this one drives the shipped Android .so builds.
# crates.io is append-only with a checksummed index, so the version IS the pin. Bump freely.
&& cargo install cargo-ndk@4.1.2 --locked \
&& rm -rf "$CARGO_HOME/registry" "$CARGO_HOME/git" \
&& chmod -R a+w "$RUSTUP_HOME" "$CARGO_HOME" \
&& rustc --version && cargo ndk --version
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
# Checked by SHA-256, like the bun pin: sccache is RUSTC_WRAPPER, so it sits in front of every
# rustc invocation that produces a SHIPPED binary. Bump SCCACHE_VERSION and SCCACHE_SHA together —
# upstream publishes the sum as <asset>.tar.gz.sha256 next to the release asset.
ARG SCCACHE_VERSION=0.10.0
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
ARG SCCACHE_SHA=1fbb35e135660d04a2d5e42b59c7874d39b3deb17de56330b25b713ec59f849b
RUN curl -fsSL -o /tmp/sccache.tar.gz \
"https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
&& echo "${SCCACHE_SHA} /tmp/sccache.tar.gz" | sha256sum -c - \
&& tar -xzf /tmp/sccache.tar.gz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
&& rm -f /tmp/sccache.tar.gz \
&& sccache --version
# actions/checkout (and every other JS action: cache, upload-artifact) execs `node` INSIDE
+19 -10
View File
@@ -53,21 +53,30 @@ RUN pacman -Syu --noconfirm --needed \
# below. It does NOT affect the gamescope companion leg — that is meson + its own linker,
# and its `-static-libstdc++` link is untouched.
mold \
&& pacman -Scc --noconfirm
# bun builds the punktfunk-web console + the punktfunk-scripting runner AND is vendored
# as their runtime (PF_WITH_WEB=1 / PF_WITH_SCRIPTING=1); it's AUR-only on Arch, so
# bootstrap the official binary — once, here, instead of per run.
RUN curl -fsSL https://bun.sh/install | bash \
&& install -m0755 /root/.bun/bin/bun /usr/local/bin/bun \
&& rm -rf /root/.bun \
# bun builds the punktfunk-web console + the punktfunk-scripting runner AND is vendored as
# their runtime (PF_WITH_WEB=1 / PF_WITH_SCRIPTING=1) — so these bytes end up inside the
# package arch.yml signs and publishes. Arch ships bun in [extra], so take the
# pacman-signed package (pacman verifies package signatures by default) instead of piping
# bun.sh's installer into root's shell, which would be upstream code choosing them. Same
# call as arch.yml's bootstrap guard. It rides THIS transaction rather than a later layer
# on purpose: -Syu refreshes the db in the same step that installs, so a cache-hit rebuild
# can never resolve bun against a stale snapshot the mirrors no longer carry.
bun \
&& pacman -Scc --noconfirm \
&& bun --version
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
# Checked by SHA-256, like the bun pin: sccache is RUSTC_WRAPPER, so it sits in front of every
# rustc invocation that produces a SHIPPED binary. Bump SCCACHE_VERSION and SCCACHE_SHA together —
# upstream publishes the sum as <asset>.tar.gz.sha256 next to the release asset.
ARG SCCACHE_VERSION=0.10.0
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
ARG SCCACHE_SHA=1fbb35e135660d04a2d5e42b59c7874d39b3deb17de56330b25b713ec59f849b
RUN curl -fsSL -o /tmp/sccache.tar.gz \
"https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
&& echo "${SCCACHE_SHA} /tmp/sccache.tar.gz" | sha256sum -c - \
&& tar -xzf /tmp/sccache.tar.gz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
&& rm -f /tmp/sccache.tar.gz \
&& sccache --version
# CARGO_HOME is declared here only so this image agrees with what arch.yml already sets at job
+27 -6
View File
@@ -17,8 +17,8 @@ RUN dnf -y install \
"https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm" \
&& dnf -y install \
# rpmbuild + source-tarball tooling; nodejs runs the Gitea Actions JS (checkout/cache) only
# — the punktfunk-web console builds AND runs on bun (installed below); unzip is for the bun
# installer.
# — the punktfunk-web console builds AND runs on bun (installed below); unzip extracts the
# pinned bun zip.
rpm-build rpmdevtools systemd-rpm-macros git tar gzip nodejs unzip \
# build toolchain + bindgen
gcc gcc-c++ clang clang-devel cmake nasm pkgconf-pkg-config curl ca-certificates \
@@ -43,8 +43,22 @@ RUN dnf -y install \
# Nitro `bun`-preset .output, served by `Bun.serve` with TLS — HTTP/1.1 over TLS). The
# RPM vendors THIS bun binary. Not in Fedora repos; install the official standalone binary to a
# system PATH dir so the rpmbuild `%build`/`%install` (run as any uid) find it.
RUN curl -fsSL https://bun.sh/install | bash \
&& install -m0755 /root/.bun/bin/bun /usr/local/bin/bun \
#
# A PINNED release asset, checked by SHA-256 — never `curl https://bun.sh/install | bash`. The spec
# VENDORS this very binary into punktfunk-web, so the installer would be upstream code choosing
# bytes rpm.yml then signs with RPM_GPG_PRIVATE_KEY. ONE bun across the repo: same version, asset
# and sum as rpm.yml, deb.yml and rust-ci.Dockerfile — bump BUN_VERSION and BUN_SHA together (the
# sums are in the release's SHASUMS256.txt). `-baseline` on purpose: it needs no AVX2, so the bun
# we ship starts on every x86-64 box — something the auto-detecting installer never promised, since
# it reads the BUILDER's CPU, not the user's.
ARG BUN_VERSION=1.3.14
ARG BUN_SHA=a063908ae08b7852ca10939bbdc6ceed3ddabce8fb9402dce83d65d73b36e6c7
RUN curl -fsSL -o /tmp/bun.zip \
"https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/bun-linux-x64-baseline.zip" \
&& echo "${BUN_SHA} /tmp/bun.zip" | sha256sum -c - \
&& unzip -q -o -j /tmp/bun.zip '*/bun' -d /tmp \
&& install -m0755 /tmp/bun /usr/local/bin/bun \
&& rm -f /tmp/bun.zip /tmp/bun \
&& bun --version
# libcuda link stub — the zerocopy path links a fixed set of cuXxx driver symbols, but CI has
@@ -78,9 +92,16 @@ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
# musl build: one static binary serves the Ubuntu and Fedora images alike.
# Checked by SHA-256, like the bun pin: sccache is RUSTC_WRAPPER, so it sits in front of every
# rustc invocation that produces a SHIPPED binary. Bump SCCACHE_VERSION and SCCACHE_SHA together —
# upstream publishes the sum as <asset>.tar.gz.sha256 next to the release asset.
ARG SCCACHE_VERSION=0.10.0
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
ARG SCCACHE_SHA=1fbb35e135660d04a2d5e42b59c7874d39b3deb17de56330b25b713ec59f849b
RUN curl -fsSL -o /tmp/sccache.tar.gz \
"https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
&& echo "${SCCACHE_SHA} /tmp/sccache.tar.gz" | sha256sum -c - \
&& tar -xzf /tmp/sccache.tar.gz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
&& rm -f /tmp/sccache.tar.gz \
&& sccache --version
# Link x86_64 with mold — see cargo-config-mold.toml's header for the rustflags traps, and
+26 -3
View File
@@ -47,7 +47,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# BSD-2 openh264 crate, NOT FFmpeg libx264) are all LGPL-compatible.
# Sourced from the official FFmpeg GitHub mirror by release tag, NOT ffmpeg.org: the CI build network
# can't reach ffmpeg.org (curl times out) but reaches github.com fine. The `nX.Y` tag pins the version
# (n8.0 -> libavcodec 62); bump it to move FFmpeg. Immutable-tag clone, so no separate checksum needed.
# (n8.0 -> libavcodec 62); bump it to move FFmpeg — together with the commit SHA it is pinned to below.
#
# STAYING ON 8.0 THROUGH THE 2026-08-08 FFmpeg-9 BUMP IS DELIBERATE. `ffmpeg-next` moved to 9, but a
# crate major is a CEILING (ffmpeg-sys-next 9 spans libavcodec 56..63), so an 8.0 tree still compiles
@@ -57,16 +57,32 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# re-qualify the encode stack for every Ubuntu user and buy none of them anything, so it is its own
# change — and it drags NVHDR_TAG and the soname assertion below along with it.
ARG FFMPEG_TAG=n8.0
# The COMMIT that tag points at. A git tag is MUTABLE — upstream can move one, and unlike a branch
# nobody would notice — and these .so's are BUNDLED into the host .deb every Ubuntu user installs.
# The clone below asserts HEAD against this, so a moved tag fails the build loudly instead of
# shipping. Same shape as the bun/sccache sha256 pins: a mismatch stops the build, it does not
# silently "fix" itself. Bump alongside FFMPEG_TAG:
# git ls-remote --tags https://github.com/FFmpeg/FFmpeg.git 'refs/tags/<new-tag>^{}'
# Take the `^{}` line: these are ANNOTATED tags, so the bare ref is the tag OBJECT and the peeled
# `^{}` is the commit — the commit is what a clone leaves at HEAD, and what this compares against.
ARG FFMPEG_SHA=140fd653aed8cad774f991ba083e2d01e86420c7
# nv-codec-headers must MATCH the FFmpeg version: its `master` is NVENC SDK 13, which renamed
# NV_ENC_CLOCK_TIMESTAMP_SET.countingType -> countingTypeLSB and won't compile against FFmpeg 8.0's
# nvenc.c. Pin the last SDK-12 tag (has the field FFmpeg 8.0 expects). Bump alongside FFMPEG_TAG.
ARG NVHDR_TAG=n12.2.72.0
# Commit for NVHDR_TAG, asserted after checkout — see FFMPEG_SHA above for why and how to bump:
# git ls-remote --tags https://github.com/FFmpeg/nv-codec-headers.git 'refs/tags/<new-tag>^{}'
ARG NVHDR_SHA=c69278340ab1d5559c7d7bf0edf615dc33ddbba7
RUN set -eux; \
# nv-codec-headers: the NVENC/NVDEC headers FFmpeg's --enable-nvenc needs (headers only, no lib —
# the driver is dlopen'd at runtime). Installs ffnvcodec.pc under /usr/local/lib/pkgconfig.
git clone --depth 1 --branch "$NVHDR_TAG" https://github.com/FFmpeg/nv-codec-headers.git /tmp/nvhdr; \
test "$(git -C /tmp/nvhdr rev-parse HEAD)" = "$NVHDR_SHA" \
|| { echo "error: nv-codec-headers $NVHDR_TAG is not $NVHDR_SHA — tag moved upstream" >&2; exit 1; }; \
make -C /tmp/nvhdr install PREFIX=/usr/local; \
git clone --depth 1 --branch "$FFMPEG_TAG" https://github.com/FFmpeg/FFmpeg.git /tmp/ffmpeg; \
test "$(git -C /tmp/ffmpeg rev-parse HEAD)" = "$FFMPEG_SHA" \
|| { echo "error: FFmpeg $FFMPEG_TAG is not $FFMPEG_SHA — tag moved upstream" >&2; exit 1; }; \
cd /tmp/ffmpeg; \
PKG_CONFIG_PATH=/usr/local/lib/pkgconfig ./configure \
--prefix=/opt/ffmpeg \
@@ -98,9 +114,16 @@ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
# musl build: one static binary serves the Ubuntu and Fedora images alike.
# Checked by SHA-256, like the bun pin: sccache is RUSTC_WRAPPER, so it sits in front of every
# rustc invocation that produces a SHIPPED binary. Bump SCCACHE_VERSION and SCCACHE_SHA together —
# upstream publishes the sum as <asset>.tar.gz.sha256 next to the release asset.
ARG SCCACHE_VERSION=0.10.0
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
ARG SCCACHE_SHA=1fbb35e135660d04a2d5e42b59c7874d39b3deb17de56330b25b713ec59f849b
RUN curl -fsSL -o /tmp/sccache.tar.gz \
"https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
&& echo "${SCCACHE_SHA} /tmp/sccache.tar.gz" | sha256sum -c - \
&& tar -xzf /tmp/sccache.tar.gz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
&& rm -f /tmp/sccache.tar.gz \
&& sccache --version
# Link x86_64 with mold — see cargo-config-mold.toml's header for the rustflags traps, and
+26 -5
View File
@@ -11,7 +11,7 @@
FROM ubuntu:26.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
# toolchain + bindgen; nodejs runs the JS actions (checkout/cache); unzip is for the bun installer
# toolchain + bindgen; nodejs runs the JS actions (checkout/cache); unzip extracts the pinned bun zip
build-essential clang libclang-dev pkg-config cmake git curl ca-certificates nodejs unzip \
# mold: the link-phase accelerator. Linking is the one thing sccache cannot cache, and this
# image relinks the whole workspace on every job. Wired via cargo-config-mold.toml below.
@@ -34,8 +34,22 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# bun — builds the punktfunk-web console in deb.yml (which runs the web build in THIS image).
# ci.yml's web/docs jobs use the oven/bun image instead, so this is only for the deb job.
RUN curl -fsSL https://bun.sh/install | bash \
&& install -m0755 /root/.bun/bin/bun /usr/local/bin/bun \
#
# A PINNED release asset, checked by SHA-256 — never `curl https://bun.sh/install | bash`.
# build-web-deb.sh VENDORS this very binary into the punktfunk-web .deb, so the installer would be
# upstream code choosing bytes a signing job then publishes. ONE bun across the repo: same version,
# asset and sum as deb.yml and rpm.yml — bump BUN_VERSION and BUN_SHA together (the sums are in the
# release's SHASUMS256.txt). `-baseline` on purpose: it needs no AVX2, so the bun we ship starts on
# every x86-64 box — something the auto-detecting installer never promised, since it reads the
# BUILDER's CPU, not the user's.
ARG BUN_VERSION=1.3.14
ARG BUN_SHA=a063908ae08b7852ca10939bbdc6ceed3ddabce8fb9402dce83d65d73b36e6c7
RUN curl -fsSL -o /tmp/bun.zip \
"https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/bun-linux-x64-baseline.zip" \
&& echo "${BUN_SHA} /tmp/bun.zip" | sha256sum -c - \
&& unzip -q -o -j /tmp/bun.zip '*/bun' -d /tmp \
&& install -m0755 /tmp/bun /usr/local/bin/bun \
&& rm -f /tmp/bun.zip /tmp/bun \
&& bun --version
# libcuda link stub: the NVIDIA userspace library (no kernel module needed) provides
@@ -60,9 +74,16 @@ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
# musl build: one static binary serves the Ubuntu and Fedora images alike.
# Checked by SHA-256, like the bun pin: sccache is RUSTC_WRAPPER, so it sits in front of every
# rustc invocation that produces a SHIPPED binary. Bump SCCACHE_VERSION and SCCACHE_SHA together —
# upstream publishes the sum as <asset>.tar.gz.sha256 next to the release asset.
ARG SCCACHE_VERSION=0.10.0
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
ARG SCCACHE_SHA=1fbb35e135660d04a2d5e42b59c7874d39b3deb17de56330b25b713ec59f849b
RUN curl -fsSL -o /tmp/sccache.tar.gz \
"https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
&& echo "${SCCACHE_SHA} /tmp/sccache.tar.gz" | sha256sum -c - \
&& tar -xzf /tmp/sccache.tar.gz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
&& rm -f /tmp/sccache.tar.gz \
&& sccache --version
# Link x86_64 with mold (see the file's own header for the rustflags-precedence traps).
@@ -17,6 +17,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import io.unom.punktfunk.models.PendingLinkConnect
import io.unom.punktfunk.models.PendingTrust
// The touch UI's prompts, each described once — a title, a list of [DialogAction]s (primary
@@ -165,6 +166,36 @@ fun RequestAccessPrompt(
}
}
/**
* A `punktfunk://` link that named a saved host by its label or its address rather than by its
* stable id: both are guessable, and the activity is exported, so the dial happens on the user's
* tap instead of on the link's say-so. A link that names the id — every shortcut Punktfunk itself
* emits — never reaches this prompt.
*/
@Composable
fun LinkConnectPrompt(
target: PendingLinkConnect,
onConnect: () -> Unit,
onDismiss: () -> Unit,
) {
PunktfunkDialog(
title = "Open this link?",
onDismiss = onDismiss,
actions = listOf(
DialogAction("Connect", primary = true, onClick = onConnect),
DialogAction("Cancel", onClick = onDismiss),
),
) {
PromptText("A link asks to connect to ${target.host.name} (${target.host.address}).")
target.launch?.let { PromptText("It also asks the host to launch “$it”.") }
PromptText(
"It names the host by its label or address, which anything that can open a link " +
"could guess. Shortcuts made in Punktfunk name the host's id and connect " +
"without asking.",
)
}
}
/**
* The no-PIN "request access" wait: the connect is parked on the host until the operator approves
* this device. Cancel returns the UI immediately — the caller trips the per-attempt flag so a late
@@ -144,7 +144,7 @@ fun App(forceGamepadUi: Boolean = false) {
activity.pendingDeepLink = null
val parsed = DeepLinks.parse(url) as? DeepLinkResult.Parsed ?: return@LaunchedEffect
val target = DeepLinks.resolveHost(parsed.link, KnownHostStore(context).all())
val sameHost = target is HostResolution.Known && target.host.id == live.hostId
val sameHost = target is HostResolution.Record && target.host.id == live.hostId
if (!sameHost) {
Toast.makeText(
context,
@@ -80,6 +80,8 @@ internal fun ConnectGrid(
onEdit: (KnownHost) -> Unit,
onWake: (KnownHost) -> Unit,
onSpeedTest: (KnownHost) -> Unit,
/** Upload this device's recent log to the host — see the menu row's gate below. */
onSendLogs: (KnownHost) -> Unit,
onCopyLink: (KnownHost, StreamProfile?) -> Unit,
onTogglePin: (KnownHost, StreamProfile) -> Unit,
/** The experimental game-library toggle — off hides "Browse library…" everywhere. */
@@ -108,6 +110,14 @@ internal fun ConnectGrid(
if (pin == null) {
add(HostMenuItem("Network speed test") { onSpeedTest(kh) })
}
// "Send logs to host" — the same row the console's host menu carries
// (`pf-console-ui`'s `options.rs`), on the same gate: the upload authenticates with the
// streaming cert, so it needs a paired identity and a host that is answering. It belongs
// HERE too and not only in the console: a device whose console never comes up is exactly
// the one whose logs somebody needs, and the touch home was its only shell.
if (pin == null && kh.paired && kh.isOnline(discovered, reachable)) {
add(HostMenuItem("Send logs to host") { onSendLogs(kh) })
}
add(HostMenuItem("Copy link") { onCopyLink(kh, pin) })
if (profiles.isEmpty()) return@buildList
if (pin != null) {
@@ -3,12 +3,14 @@ package io.unom.punktfunk
import androidx.compose.runtime.Composable
import io.unom.punktfunk.kit.security.ClientIdentity
import io.unom.punktfunk.kit.security.KnownHost
import io.unom.punktfunk.models.PendingLinkConnect
import io.unom.punktfunk.models.PendingTrust
/**
* Everything `ConnectScreen` puts ON TOP of whichever home it drew — the trust and pairing
* ceremony, the parked "Waiting for approval…", the console's host options, the speed test, the
* edit form, the local-network rationale, and finally the connect takeover.
* ceremony, a link's connect confirmation, the parked "Waiting for approval…", the console's host
* options, the speed test, the edit form, the local-network rationale, and finally the connect
* takeover.
*
* They live together because their ORDER is the contract: this is a stack of siblings in one tree,
* so the last one drawn is the one on top, and [ConnectOverlay] is last on purpose — a dial can
@@ -33,6 +35,11 @@ internal fun ConnectPrompts(
/** The PIN ceremony completed with this host fingerprint — save as paired, then dial. */
onPaired: (PendingTrust, String) -> Unit,
onRequestAccess: (PendingTrust) -> Unit,
// ---- a link that named a saved host by a guessable reference ----------------------------
/** Non-null while such a link waits for the OK that turns it into a plain dial. */
pendingLinkConnect: PendingLinkConnect?,
onConfirmLinkConnect: (PendingLinkConnect) -> Unit,
onDismissLinkConnect: () -> Unit,
// ---- the parked no-PIN request ----------------------------------------------------------
/** Non-null while a "request access" connect sits parked on the host awaiting approval. */
awaitingHostName: String?,
@@ -89,6 +96,14 @@ internal fun ConnectPrompts(
}
}
pendingLinkConnect?.let { plc ->
LinkConnectPrompt(
target = plc,
onConnect = { onConfirmLinkConnect(plc) },
onDismiss = onDismissLinkConnect,
)
}
awaitingHostName?.let { hostLabel ->
AwaitingApprovalPrompt(hostLabel = hostLabel, onCancel = onCancelApproval)
}
@@ -38,6 +38,7 @@ import io.unom.punktfunk.kit.security.KnownHost
import io.unom.punktfunk.kit.security.KnownHostStore
import io.unom.punktfunk.kit.security.obtainIdentity
import io.unom.punktfunk.models.ActiveSession
import io.unom.punktfunk.models.PendingLinkConnect
import io.unom.punktfunk.models.PendingTrust
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.Dispatchers
@@ -284,6 +285,8 @@ fun ConnectScreen(
// A trust decision awaiting the user (first-connect TOFU / fp changed / PIN pairing / the
// request-access-or-PIN choice).
var pendingTrust by remember { mutableStateOf<PendingTrust?>(null) }
// A `punktfunk://` link that named a saved host by a guessable reference, awaiting the OK.
var pendingLinkConnect by remember { mutableStateOf<PendingLinkConnect?>(null) }
// A no-PIN "request access" connect in flight (the cancelable "Waiting for approval…" dialog).
var awaiting by remember { mutableStateOf<RequestAccessState?>(null) }
// A saved host being edited (name / address / port / MAC).
@@ -632,6 +635,22 @@ fun ConnectScreen(
if (copied) notice = message else status = message
}
// "Send logs to host" — [SendLogs], the same upload the console's host menu runs. The outcome
// is a notice either way (success and failure both name the host), because the row's whole job
// is to tell a reporter whether the bundle actually landed.
fun sendLogs(kh: KnownHost) {
val id = identity ?: run {
status = "Identity not ready yet — try again in a moment"
return
}
notice = "Sending logs to ${kh.name.ifBlank { kh.address }}…"
status = null
scope.launch {
val message = withContext(Dispatchers.IO) { SendLogs.toHost(context, id, kh) }
notice = message
}
}
// ---- punktfunk:// routing (design/client-deep-links.md §3) --------------------------------
//
// The invariant: a URL may only ever do what a click on an existing card could do, MINUS trust
@@ -673,8 +692,10 @@ fun ConnectScreen(
}
}
when (val resolved = DeepLinks.resolveHost(link, savedHosts)) {
// Known AND pinned is the one-click contract: do exactly what tapping its card does.
is HostResolution.Known -> {
// A saved record. Pinned AND named by its (unguessable) id is the one-click contract:
// do exactly what tapping its card does. Named by anything a web page could guess —
// its label, its address — the same dial waits for a tap on the confirmation.
is HostResolution.Record -> {
// A pin that contradicts the stored one is the link being stale or lying. Hard
// refusal: this is the one case where doing what the card does would be wrong.
if (link.pinConflict(resolved.host)) {
@@ -691,6 +712,10 @@ fun ConnectScreen(
)
return@LaunchedEffect
}
if (resolved is HostResolution.Confirm) {
pendingLinkConnect = PendingLinkConnect(resolved.host, profileRef, link.launch)
return@LaunchedEffect
}
connect(
resolved.host.address, resolved.host.port,
oneOffProfile = profileRef, launch = link.launch,
@@ -767,6 +792,7 @@ fun ConnectScreen(
onEdit = { kh -> editTarget = kh },
onWake = { kh -> wakeHost(kh) },
onSpeedTest = { kh -> startSpeedTest(HostCardEntry(kh, null)) },
onSendLogs = { kh -> sendLogs(kh) },
onCopyLink = { kh, pin -> copyLink(kh, pin) },
onTogglePin = { kh, p -> togglePin(kh, p) },
libraryEnabled = settings.libraryEnabled,
@@ -821,6 +847,15 @@ fun ConnectScreen(
doConnect(pt.host, pt.port, pt.name, fp, pt.profile, pt.launch)
},
onRequestAccess = { pt -> pendingTrust = null; requestAccess(pt) },
pendingLinkConnect = pendingLinkConnect,
onConfirmLinkConnect = { plc ->
pendingLinkConnect = null
connect(
plc.host.address, plc.host.port,
oneOffProfile = plc.profile, launch = plc.launch,
)
},
onDismissLinkConnect = { pendingLinkConnect = null },
awaitingHostName = awaiting?.target?.name,
onCancelApproval = {
awaiting?.cancelled?.set(true)
@@ -101,6 +101,9 @@ suspend fun connectToHost(
// Tier-A pad audio: ask for the 0xD1 plane only when a setting would render it, so a
// user with it off does not make the host provision endpoints it will never feed.
settings.padHaptics || settings.padSpeaker,
// "Keep host audio playing": the host taps its own default output rather than
// silencing it for the session. Free to ask for — an older host just ignores it.
settings.keepHostAudio,
)
}
}
@@ -874,7 +874,7 @@ class MainActivity : ComponentActivity() {
val url = deepLinkFrom(intent) ?: return false
val parsed = DeepLinks.parse(url) as? DeepLinkResult.Parsed ?: return false
val target = DeepLinks.resolveHost(parsed.link, KnownHostStore(this).all())
return target is HostResolution.Known && target.host.id == live.hostId
return target is HostResolution.Record && target.host.id == live.hostId
}
/** The host a live stream is on — see [liveStream]. */
@@ -45,6 +45,7 @@ data class SettingsOverlay(
val audioFormat: String? = null,
val micEnabled: Boolean? = null,
val echoCancel: Boolean? = null,
val keepHostAudio: Boolean? = null,
val touchMode: TouchMode? = null,
val mouseMode: MouseMode? = null,
val invertScroll: Boolean? = null,
@@ -82,6 +83,7 @@ data class SettingsOverlay(
audioFormat = audioFormat ?: base.audioFormat,
micEnabled = micEnabled ?: base.micEnabled,
echoCancel = echoCancel ?: base.echoCancel,
keepHostAudio = keepHostAudio ?: base.keepHostAudio,
touchMode = touchMode ?: base.touchMode,
mouseMode = mouseMode ?: base.mouseMode,
invertScroll = invertScroll ?: base.invertScroll,
@@ -120,6 +122,8 @@ data class SettingsOverlay(
audioFormat = if (after.audioFormat != before.audioFormat) after.audioFormat else audioFormat,
micEnabled = if (after.micEnabled != before.micEnabled) after.micEnabled else micEnabled,
echoCancel = if (after.echoCancel != before.echoCancel) after.echoCancel else echoCancel,
keepHostAudio =
if (after.keepHostAudio != before.keepHostAudio) after.keepHostAudio else keepHostAudio,
touchMode = if (after.touchMode != before.touchMode) after.touchMode else touchMode,
mouseMode = if (after.mouseMode != before.mouseMode) after.mouseMode else mouseMode,
invertScroll = if (after.invertScroll != before.invertScroll) after.invertScroll else invertScroll,
@@ -152,6 +156,7 @@ data class SettingsOverlay(
"audio_format" -> copy(audioFormat = null)
"mic_enabled" -> copy(micEnabled = null)
"echo_cancel" -> copy(echoCancel = null)
"keep_host_audio" -> copy(keepHostAudio = null)
"touch_mode" -> copy(touchMode = null)
"mouse_mode" -> copy(mouseMode = null)
"invert_scroll" -> copy(invertScroll = null)
@@ -179,6 +184,7 @@ data class SettingsOverlay(
if (audioFormat != null) add("audio_format")
if (micEnabled != null) add("mic_enabled")
if (echoCancel != null) add("echo_cancel")
if (keepHostAudio != null) add("keep_host_audio")
if (touchMode != null) add("touch_mode")
if (mouseMode != null) add("mouse_mode")
if (invertScroll != null) add("invert_scroll")
@@ -214,6 +220,7 @@ data class SettingsOverlay(
audioFormat?.let { j.put("audio_format", it) }
micEnabled?.let { j.put("mic_enabled", it) }
echoCancel?.let { j.put("echo_cancel", it) }
keepHostAudio?.let { j.put("keep_host_audio", it) }
touchMode?.let { j.put("touch_mode", it.name) }
mouseMode?.let { j.put("mouse_mode", it.storedName) }
invertScroll?.let { j.put("invert_scroll", it) }
@@ -236,6 +243,7 @@ data class SettingsOverlay(
private val KNOWN = setOf(
"width", "height", "refresh_hz", "bitrate_kbps", "render_scale", "codec",
"hdr_enabled", "compositor", "audio_channels", "audio_format", "mic_enabled", "echo_cancel",
"keep_host_audio",
"touch_mode", "mouse_mode", "invert_scroll", "gamepad", "gamepad_forwarding",
"system_buttons", "guide_gesture",
"stats_verbosity",
@@ -255,6 +263,7 @@ data class SettingsOverlay(
audioFormat = j.optStringOrNull("audio_format"),
micEnabled = j.optBooleanOrNull("mic_enabled"),
echoCancel = j.optBooleanOrNull("echo_cancel"),
keepHostAudio = j.optBooleanOrNull("keep_host_audio"),
touchMode = j.optStringOrNull("touch_mode")
?.let { n -> TouchMode.entries.firstOrNull { it.name == n } },
mouseMode = j.optStringOrNull("mouse_mode")
@@ -0,0 +1,78 @@
package io.unom.punktfunk
import android.content.Context
import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.security.ClientIdentity
import io.unom.punktfunk.kit.security.KnownHost
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
/**
* "Send logs to host" — this device's log ring ([NativeBridge.nativeRenderLogs], fed by
* `pf_client_core::logring`) posted to a paired host's `POST /api/v1/client-logs` over the same
* mTLS identity the library fetch uses. The bundle is then listed in that host's web console, on
* its Logs page, beside the host's own log.
*
* ONE implementation for both Android shells — the Skia console's host menu
* (`console.SkiaConsole`) and the touch home's card menu ([ConnectGrid]). It lived only in the
* console, which made it unreachable on exactly the devices that most need it: a phone whose
* console never comes up has no route to its own logs at all, and the touch UI is the shell a
* reporter is looking at when something is wrong. The wording is the desktop console's verbatim
* (`clients/session/src/console.rs`) so a quoted message means the same thing on every client.
*
* Blocking — call it off the main thread.
*/
object SendLogs {
/** `punktfunk-android <ver> (android <rel>; <abi>) — client log bundle`, the desktop's shape. */
fun header(context: Context): String {
val version = runCatching {
context.packageManager.getPackageInfo(context.packageName, 0).versionName
}.getOrNull() ?: "?"
return "punktfunk-android $version (android ${android.os.Build.VERSION.RELEASE}; " +
"${android.os.Build.SUPPORTED_ABIS.firstOrNull() ?: "?"}) — client log bundle"
}
/** The user-facing outcome: the success line, or "Couldn't send logs — <why>". */
fun toHost(context: Context, identity: ClientIdentity, host: KnownHost): String =
toHost(
context, identity,
addr = host.address, mgmtPort = host.effectiveMgmtPort, fpHex = host.fpHex,
hostName = host.name.ifBlank { host.address },
)
/**
* The address-and-port form, for the console — its menu addresses a `HostRow`, which carries
* the mgmt port the ADVERT taught it (fresher than the saved record's).
*/
fun toHost(
context: Context,
identity: ClientIdentity,
addr: String,
mgmtPort: Int,
fpHex: String,
hostName: String,
): String {
val err = runCatching {
val body = NativeBridge.nativeRenderLogs(header(context))
val client = io.unom.punktfunk.kit.library.mtlsHttpClient(
identity.certPem, identity.privateKeyPem, addr, fpHex,
)
val req = Request.Builder()
.url("https://$addr:$mgmtPort/api/v1/client-logs")
.post(body.toRequestBody("text/plain; charset=utf-8".toMediaType()))
.build()
client.newCall(req).execute().use { resp ->
// The host answers 201 Created, not 200 — this is a route that STORES a bundle
// (`mgmt/client_logs.rs`). Any 2xx is a success; OkHttp's own predicate spares us
// a second hand-written list of codes to get wrong.
if (resp.isSuccessful) "" else "host answered HTTP ${resp.code}"
}
}.getOrElse { it.message ?: "upload failed" }
return if (err.isEmpty()) {
"Logs sent to $hostName — download them from its web console's Logs page"
} else {
"Couldn't send logs — $err"
}
}
}
@@ -91,6 +91,17 @@ data class Settings(
* Only meaningful while [micEnabled] is on.
*/
val echoCancel: Boolean = true,
/**
* Ask the host to leave ITS OWN audio devices alone for this session
* (`CLIENT_CAP_KEEP_HOST_AUDIO`): it captures whatever its default playback device already is,
* so the speakers or headphones on the host PC keep playing while this device hears the same
* audio. Off — the default, and what every build before this did — has the host park playback
* on a silent endpoint, which is why the host goes quiet the moment a stream starts.
*
* REQUEST-only: there is no host-cap echo, so an older host ignores the ask and re-routes as it
* always did ("audio still works, the host went quiet"), never a broken session.
*/
val keepHostAudio: Boolean = false,
/**
* How much the in-stream stats overlay shows — see [StatsVerbosity]. Defaults to
* [StatsVerbosity.NORMAL] (the res/fps line + latency headline + reliability counters); the full
@@ -330,6 +341,7 @@ class SettingsStore(context: Context) {
codec = prefs.getString(K_CODEC, "auto") ?: "auto",
micEnabled = prefs.getBoolean(K_MIC, false),
echoCancel = prefs.getBoolean(K_ECHO_CANCEL, true),
keepHostAudio = prefs.getBoolean(K_KEEP_HOST_AUDIO, false),
statsVerbosity = prefs.getString(K_STATS_VERBOSITY, null)
?.let { name -> StatsVerbosity.entries.firstOrNull { it.name == name } }
// Migration from the pre-tier Boolean "stats_hud_enabled": an explicit OFF stays off;
@@ -387,6 +399,7 @@ class SettingsStore(context: Context) {
.putString(K_CODEC, s.codec)
.putBoolean(K_MIC, s.micEnabled)
.putBoolean(K_ECHO_CANCEL, s.echoCancel)
.putBoolean(K_KEEP_HOST_AUDIO, s.keepHostAudio)
.putString(K_STATS_VERBOSITY, s.statsVerbosity.name)
.putString(K_TOUCH_MODE, s.touchMode.name)
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
@@ -426,6 +439,7 @@ class SettingsStore(context: Context) {
const val K_CODEC = "codec"
const val K_MIC = "mic_enabled"
const val K_ECHO_CANCEL = "echo_cancel"
const val K_KEEP_HOST_AUDIO = "keep_host_audio"
const val K_STATS_VERBOSITY = "stats_verbosity"
/** Pre-tier Boolean the [K_STATS_VERBOSITY] enum replaced — read once for migration, never
@@ -589,7 +589,10 @@ private fun GeneralSettings(s: Settings, update: (Settings) -> Unit) {
onCheckedChange = { on -> update(s.copy(libraryEnabled = on)) },
)
}
SettingsGroup("Interface") {
// The footer is null on every device where the console works, so it costs nothing there —
// and on the ones where it doesn't, it is the only place the app admits that this switch
// is being overruled. See `SkiaConsole.unavailable`.
SettingsGroup("Interface", footer = io.unom.punktfunk.console.SkiaConsole.unavailable()) {
ToggleRow(
title = "Controller-optimized UI",
subtitle = "Swap the touch home for the console home — the host carousel and " +
@@ -841,6 +844,13 @@ private fun AudioSettings(s: Settings, update: (Settings) -> Unit, onMicChange:
"otherwise the session stays on Opus, which is already effectively " +
"transparent. The overlay shows what a session actually got.",
) { f -> update(s.copy(audioFormat = f)) }
ToggleRow(
title = "Keep host audio playing",
subtitle = "The host's speakers or headphones keep playing while you stream",
checked = s.keepHostAudio,
field = "keep_host_audio",
onCheckedChange = { on -> update(s.copy(keepHostAudio = on)) },
)
ToggleRow(
title = "Microphone",
subtitle = "Feeds this device's microphone to the host",
@@ -304,6 +304,7 @@ internal object ConsoleJson {
j.put("mouse_mode", s.mouseMode.storedName)
j.put("mic_enabled", s.micEnabled)
j.put("echo_cancel", s.echoCancel)
j.put("keep_host_audio", s.keepHostAudio)
j.put("audio_channels", s.audioChannels)
j.put("audio_format", s.audioFormat)
j.put("codec", s.codec)
@@ -361,6 +362,7 @@ internal object ConsoleJson {
?: s.mouseMode,
micEnabled = j.optBoolean("mic_enabled", s.micEnabled),
echoCancel = j.optBoolean("echo_cancel", s.echoCancel),
keepHostAudio = j.optBoolean("keep_host_audio", s.keepHostAudio),
audioChannels = j.optInt("audio_channels", s.audioChannels),
audioFormat = str("audio_format", s.audioFormat),
codec = str("codec", s.codec),
@@ -37,10 +37,8 @@ import io.unom.punktfunk.models.ActiveSession
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
@@ -132,6 +130,26 @@ object SkiaConsole {
return backendProp() != "none"
}
/**
* Why the console cannot front the gamepad UI here, or null when it can for the settings
* screen to print under the switch that asks for it.
*
* `App` gates the console on `wanted() && healthy` on top of the user's own setting, and those
* two terms are the ONLY ones that can veto "Always": the mode, the attached pad, the TV check
* and the dev flag are ORed together, so a device where the console never comes up ignores
* every one of them. Until this existed that produced a switch the app silently disobeyed
* indistinguishable, from the outside, from the switch itself being broken, and it is what a
* report of "the gamepad UI just doesn't activate, even on Always, even with a controller"
* looks like. Reads [healthy] as Compose state, so the note clears itself if it ever recovers.
*/
fun unavailable(): String? = when {
!wanted() -> "This device has no console UI in this build, so the touch layout stays up."
!healthy -> "The console UI couldn't start on this device, so the touch layout is " +
"standing in. Restart the app to try again — and if it keeps happening, send this " +
"host your logs from a saved host's ⋮ menu."
else -> null
}
private fun backendProp(): String = runCatching {
val cls = Class.forName("android.os.SystemProperties")
cls.getMethod("get", String::class.java, String::class.java)
@@ -331,10 +349,11 @@ object SkiaConsole {
}
/**
* A `punktfunk://` link while the console is up. Known-and-pinned is the one-click contract
* (the same dial the console's own Launch takes); anything that would need a trust decision
* is a notice here a link may never establish trust, and the console's Pair screen is
* reached from the host's tile, not from a URL.
* A `punktfunk://` link while the console is up. Named-by-id and pinned is the one-click
* contract (the same dial the console's own Launch takes); anything that would need a trust
* decision or that named the host by a guessable label or address is a notice here. A link
* may never establish trust, the console's Pair screen is reached from the host's tile rather
* than from a URL, and the console draws no prompt this shell could ask a question through.
*/
fun handleDeepLink(url: String) {
if (handle == 0L) return
@@ -357,7 +376,7 @@ object SkiaConsole {
}
}
when (val resolved = io.unom.punktfunk.kit.link.DeepLinks.resolveHost(link, knownHostStore.all())) {
is io.unom.punktfunk.kit.link.HostResolution.Known -> {
is io.unom.punktfunk.kit.link.HostResolution.Record -> {
val kh = resolved.host
if (link.pinConflict(kh)) {
notice("That link's fingerprint doesn't match the one pinned for ${kh.name}.")
@@ -367,6 +386,10 @@ object SkiaConsole {
notice("Pair with ${kh.name} first — a link can't establish trust.")
return
}
if (resolved is io.unom.punktfunk.kit.link.HostResolution.Confirm) {
notice("A link can only dial ${kh.name} by its id — open it from the list.")
return
}
launch(
JSONObject()
.put("addr", kh.address).put("port", kh.port).put("fp_hex", kh.fpHex)
@@ -623,11 +646,8 @@ object SkiaConsole {
}
/**
* `ConsoleCmd::SendLogs` the native log ring (`nativeRenderLogs`) posted to this
* paired host's `POST /api/v1/client-logs` over the same mTLS client the library fetch
* uses; the result comes back as a notice, in the desktop console's wording. The header
* mirrors the desktop's identity line (`punktfunk-session <ver> (<os> <arch>) client
* log bundle`).
* `ConsoleCmd::SendLogs` [io.unom.punktfunk.SendLogs], the same upload the touch home's
* card menu runs; the result comes back here as a notice.
*/
private fun sendLogs(c: JSONObject) {
val addr = c.optString("addr"); val mgmt = c.optInt("mgmt"); val fp = c.optString("fp_hex")
@@ -637,34 +657,10 @@ object SkiaConsole {
notice("Identity not ready yet — try again in a moment")
return
}
val version = appContext?.let { app ->
runCatching { app.packageManager.getPackageInfo(app.packageName, 0).versionName }.getOrNull()
} ?: "?"
val header = "punktfunk-android $version (android ${android.os.Build.VERSION.RELEASE}; " +
"${android.os.Build.SUPPORTED_ABIS.firstOrNull() ?: "?"}) — client log bundle"
val app = appContext ?: return
ioPool.execute {
val err = runCatching {
val body = NativeBridge.nativeRenderLogs(header)
val client = io.unom.punktfunk.kit.library.mtlsHttpClient(
id.certPem, id.privateKeyPem, addr, fp,
)
val req = Request.Builder()
.url("https://$addr:$mgmt/api/v1/client-logs")
.post(body.toRequestBody("text/plain; charset=utf-8".toMediaType()))
.build()
client.newCall(req).execute().use { resp ->
if (resp.code == 200) "" else "host answered HTTP ${resp.code}"
}
}.getOrElse { it.message ?: "upload failed" }
main.post {
notice(
if (err.isEmpty()) {
"Logs sent to $hostName — download them from its web console's Logs page"
} else {
"Couldn't send logs — $err"
},
)
}
val message = io.unom.punktfunk.SendLogs.toHost(app, id, addr, mgmt, fp, hostName)
main.post { notice(message) }
}
}
@@ -4,6 +4,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Settings
import androidx.compose.ui.graphics.vector.ImageVector
import io.unom.punktfunk.kit.security.KnownHost
/** Bottom-bar destinations (the immersive stream view is shown full-screen, outside the bar). */
enum class Tab(val label: String, val icon: ImageVector) {
@@ -37,6 +38,22 @@ data class PendingTrust(
enum class Kind { TRUST_NEW, FP_CHANGED, PAIR, REQUEST_ACCESS }
}
/**
* A `punktfunk://` link that named a saved host by something GUESSABLE — its display name or its
* address instead of by its stable record id, waiting for the user's OK before it dials.
*
* MainActivity is exported with a BROWSABLE `punktfunk://` filter, so any app or web page can emit
* `punktfunk://connect/Gaming%20PC?launch=steam:570`; guessing a label must not be enough to start
* a stream and boot a game. A link that names the record id (the shortcuts this app emits) still
* connects on its own. [profile] and [launch] are the link's, carried across the detour exactly as
* [PendingTrust] carries them.
*/
data class PendingLinkConnect(
val host: KnownHost,
val profile: String? = null,
val launch: String? = null,
)
/**
* A stream session that just opened, and the state the stream screen needs about it.
*
@@ -95,6 +95,11 @@ object NativeBridge {
* DualSense plane. Without it the host never sets `HOST_CAP_PAD_AUDIO` and emits nothing,
* so a captured pad's own render capabilities would have nothing to gate. */
padAudioOk: Boolean,
/** Advertise `CLIENT_CAP_KEEP_HOST_AUDIO` ask the host to tap its default playback
* device instead of parking it on a silent endpoint, so the host PC's own speakers keep
* playing. REQUEST-only (no host-cap echo): an older host ignores it and goes quiet
* exactly as it always did. */
keepHostAudio: Boolean,
): Long
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */
@@ -154,15 +154,22 @@ object DeepLinks {
/**
* Resolve a link's host reference against the local store, in the documented order: stable
* record id unique case-insensitive name `addr[:port]` literal. The `host=` parameter is
* the recovery path a self-emitted shortcut that outlived the record it was written from
* still lands on the right box (degraded to the confirmation sheet).
* record id unique case-insensitive name `addr[:port]` literal, then the `host=` recovery
* parameter a self-emitted shortcut that outlived the record it was written from still lands
* on the right box.
*
* Only the record id is UNGUESSABLE, so only the record id resolves to [HostResolution.Known],
* the silent one-click contract. A display name comes from an mDNS instance name or a user
* label ("Gaming PC"), and an address is a LAN address: any zero-permission app or web page can
* emit `punktfunk://connect/Gaming%20PC` and would otherwise start a stream (and launch a
* title) on a guess. Those all resolve to [HostResolution.Confirm] the same host, behind the
* user's OK.
*/
fun resolveHost(link: DeepLink, hosts: List<KnownHost>): HostResolution {
hosts.firstOrNull { it.id == link.hostRef }?.let { return HostResolution.Known(it) }
val byName = hosts.filter { it.name.equals(link.hostRef, ignoreCase = true) }
when (byName.size) {
1 -> return HostResolution.Known(byName[0])
1 -> return HostResolution.Confirm(byName[0])
0 -> Unit
else -> return HostResolution.Ambiguous
}
@@ -173,7 +180,7 @@ object DeepLinks {
val literal = if (looksLikeAddress(link.hostRef)) parseAddrPort(link.hostRef) else null
for ((addr, port) in listOfNotNull(literal, link.host)) {
hosts.firstOrNull { it.address == addr && it.port == port }
?.let { return HostResolution.Known(it) }
?.let { return HostResolution.Confirm(it) }
}
val fallback = literal ?: link.host ?: return HostResolution.Unresolvable
return HostResolution.Unknown(fallback.first, fallback.second, link.name, link.fp)
@@ -429,8 +436,23 @@ sealed interface DeepLinkResult {
/** What the local host store made of a link's references. */
sealed interface HostResolution {
/** A record we already trust (subject to [DeepLink.pinConflict]). */
data class Known(val host: KnownHost) : HostResolution
/** A saved record — [Known] may act on its own, [Confirm] only once the user says so. */
sealed interface Record : HostResolution {
val host: KnownHost
}
/**
* A record we already trust, named by its stable (unguessable) id: the one-click contract,
* subject to [DeepLink.pinConflict].
*/
data class Known(override val host: KnownHost) : Record
/**
* The same record, but named by something GUESSABLE its display name, its address, or the
* `host=` recovery parameter. A link may not start a stream on a guess, so this one goes to
* the confirmation the front-end shows: same dial, one tap later.
*/
data class Confirm(override val host: KnownHost) : Record
/**
* No record, but the link says where to dial: the confirmation sheet's input, from which the
@@ -70,7 +70,9 @@ class DeepLinkVectorTest {
* Resolution and emission the half the vector file can't cover, because it depends on what is in
* THIS device's host store. The rules are the one-click contract in resolution form: an id beats a
* name beats an address, an ambiguous name refuses rather than guesses, and a link whose record is
* gone still lands on the confirmation sheet via `host=`+`fp=` instead of dying.
* gone still lands on the confirmation sheet via `host=`+`fp=` instead of dying. Only the id the
* one reference nothing can guess dials on its own; a name or an address resolves to the same
* host behind a confirmation.
*/
class DeepLinkResolutionTest {
private val fp = "a".repeat(64)
@@ -86,20 +88,47 @@ class DeepLinkResolutionTest {
@Test
fun idBeatsNameBeatsAddress() {
assertEquals(desk, (resolve("punktfunk://connect/${desk.id}") as HostResolution.Known).host)
assertEquals(desk, (resolve("punktfunk://connect/desk") as HostResolution.Known).host)
assertEquals(desk, (resolve("punktfunk://connect/192.168.1.50") as HostResolution.Known).host)
assertEquals(desk, (resolve("punktfunk://connect/192.168.1.50:9777") as HostResolution.Known).host)
assertEquals(desk, (resolve("punktfunk://connect/${desk.id}") as HostResolution.Record).host)
assertEquals(desk, (resolve("punktfunk://connect/desk") as HostResolution.Record).host)
assertEquals(desk, (resolve("punktfunk://connect/192.168.1.50") as HostResolution.Record).host)
assertEquals(
desk,
(resolve("punktfunk://connect/192.168.1.50:9777") as HostResolution.Record).host,
)
// Two hosts answer to "Couch" — refuse with a notice, never pick one.
assertEquals(HostResolution.Ambiguous, resolve("punktfunk://connect/couch"))
}
/**
* The record id is a UUID nothing can guess; a display name ("Gaming PC") and a LAN address are
* guesses any web page can make. So the id and only the id is the silent one-click dial;
* everything else that finds a saved host stops at [HostResolution.Confirm].
*/
@Test
fun onlyTheRecordIdDialsWithoutAsking() {
assertEquals(HostResolution.Known(desk), resolve("punktfunk://connect/${desk.id}"))
assertEquals(HostResolution.Confirm(desk), resolve("punktfunk://connect/desk"))
assertEquals(HostResolution.Confirm(desk), resolve("punktfunk://connect/DESK"))
assertEquals(HostResolution.Confirm(desk), resolve("punktfunk://connect/192.168.1.50"))
assertEquals(HostResolution.Confirm(desk), resolve("punktfunk://connect/192.168.1.50:9777"))
// …including the `host=` recovery path, exactly as its own doc always claimed.
assertEquals(
HostResolution.Confirm(desk),
resolve("punktfunk://connect/00000000-0000-4000-8000-000000000000?host=192.168.1.50"),
)
// A launch id doesn't buy a name any authority it didn't have.
assertEquals(
HostResolution.Confirm(desk),
resolve("punktfunk://connect/desk?launch=steam:570"),
)
}
@Test
fun aStaleIdRecoversThroughTheHostParameter() {
val stale = "00000000-0000-4000-8000-000000000000"
assertEquals(
desk,
(resolve("punktfunk://connect/$stale?host=192.168.1.50") as HostResolution.Known).host,
(resolve("punktfunk://connect/$stale?host=192.168.1.50") as HostResolution.Record).host,
)
// …but a stale id is NOT a hostname: dialing "00000000-…" would be a confusing dead end
// rather than the recovery the grammar specifies.
+8 -3
View File
@@ -16,7 +16,8 @@ use ndk::native_window::NativeWindow;
use pf_client_core::console::{OverlayAction, PointerInput, SessionPhase};
use pf_client_core::menu_nav::{MenuEvent, MenuNav, MenuPulse, MenuSample, PadInfo};
use pf_console_ui::{
Console, ConsoleEntry, ConsoleHandles, ConsoleOptions, Insets, Key, SnapshotStore, Viewport,
Console, ConsoleEntry, ConsoleHandles, ConsoleOptions, InputSource, Insets, Key, SnapshotStore,
Viewport,
};
use punktfunk_core::config::GamepadPref;
use std::collections::VecDeque;
@@ -346,7 +347,11 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
}
Cmd::Menu(ev) => {
last_input = Instant::now();
if let Some(p) = console.menu(ev) {
// Discrete events are the remote/keyboard path (Kotlin routes pad
// buttons through PadSample) — with one wrinkle: a pad's SELECT also
// arrives here (SkiaConsoleShell's ▲-on-Home shortcut), briefly
// reading as keys. The next real pad press corrects the legend.
if let Some(p) = console.menu(ev, InputSource::Keys) {
shared.emit(HostEvent::Pulse(p));
}
}
@@ -454,7 +459,7 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
menu_out.clear();
nav.poll(&sample, Instant::now(), &mut menu_out);
for ev in menu_out.drain(..) {
if let Some(p) = console.menu(ev) {
if let Some(p) = console.menu(ev, InputSource::Pad) {
shared.emit(HostEvent::Pulse(p));
}
}
@@ -164,6 +164,21 @@ impl AscBackend {
/// negotiated decode size; `surface_size` the LIVE view size the layer composites into;
/// `panel_hz` the mode-table panel rate (seeds the learner);
/// `dataspace` the `ADataSpace` from the negotiated colour; `source_hz` the negotiated stream rate.
///
/// `overlay` sets the reader's gralloc ask. `true` adds `COMPOSER_OVERLAY`, letting HWC scan
/// the buffer out directly instead of paying a GPU composition pass — the right default, and
/// what every device the presenter was tuned on allocates without blinking. It is also the one
/// reader parameter that can make `AMediaCodec_start` fail AFTER a clean configure: start is
/// where ACodec dequeues (= gralloc-allocates) every codec output buffer from this reader's
/// window, with our consumer usage OR'd into the decoder's own producer bits — and an old
/// 32-bit OMX BSP (the Mi TV Stick's Amlogic gralloc) can refuse the combined
/// overlay + GPU-sampled + vendor-vdec allocation outright. `false` asks for
/// `GPU_SAMPLED_IMAGE` alone — the SurfaceTexture shape every TextureView/WebView video path
/// exercises, the most universally allocatable there is; SurfaceFlinger then GPU-composites the
/// layer (one 1080p quad — noise), and everything else about the backend is identical: real
/// latches, real fences, `setBuffer` has no overlay requirement. (`READER_MAX_IMAGES` is NOT a
/// start-time factor — consumer-side images allocate lazily during streaming — so usage is the
/// only axis a start-failure retry needs.)
#[allow(clippy::too_many_arguments)]
pub(super) fn create(
window: &NativeWindow,
@@ -174,10 +189,13 @@ impl AscBackend {
dataspace: i32,
source_hz: u32,
priority: PresentPriority,
overlay: bool,
) -> Option<AscBackend> {
let layer = Layer::create(window, surface_size)?;
let usage = ndk::hardware_buffer::HardwareBufferUsage::GPU_SAMPLED_IMAGE
| ndk::hardware_buffer::HardwareBufferUsage::COMPOSER_OVERLAY;
let mut usage = ndk::hardware_buffer::HardwareBufferUsage::GPU_SAMPLED_IMAGE;
if overlay {
usage |= ndk::hardware_buffer::HardwareBufferUsage::COMPOSER_OVERLAY;
}
let reader = match ImageReader::new_with_usage(
src_w.max(1),
src_h.max(1),
@@ -211,11 +229,12 @@ impl AscBackend {
),
};
log::info!(
"asc: backend up — {} ({}x{} @ {} Hz src, panel seed {} Hz, dataspace {:#x})",
"asc: backend up — {}, reader usage {} ({}x{} @ {} Hz src, panel seed {} Hz, dataspace {:#x})",
match priority {
PresentPriority::Latency => "latency (newest-wins)".to_string(),
PresentPriority::Smooth { buffer } => format!("smooth (buffer {buffer})"),
},
if overlay { "overlay" } else { "gpu-only" },
src_w,
src_h,
source_hz,
+296 -118
View File
@@ -74,6 +74,90 @@ pub(super) enum DecodeEvent {
Error { fatal: bool },
}
/// The decoder bring-up rungs, in order, as `(present backend, aggressive low-latency keys)`.
/// The backend is `Some(overlay)` for ASC with that reader-usage profile (see
/// [`AscBackend::create`]'s `overlay` doc), `None` for the SurfaceView presenter. See the ladder's
/// comment in [`run_async`] for why these axes, and why in this order.
///
/// Consecutive duplicates are collapsed: the `present_backend` sysprop and the low-latency toggle
/// may each already have shed what a rung was going to shed, and re-running a configuration the
/// codec just refused buys nothing but another failed `start`. The first rung is always exactly
/// what the session asked for, so a device that works is never charged for this ladder.
fn bring_up_rungs(asc_wanted: bool, low_latency: bool) -> Vec<(Option<bool>, bool)> {
let mut rungs = vec![
(asc_wanted.then_some(true), low_latency),
(asc_wanted.then_some(false), low_latency),
(None, low_latency),
(None, false),
];
rungs.dedup();
rungs
}
/// Human label for a rung's present backend, for the retry / decoder-started log lines — the
/// string a field log bundle is grepped for, so it names the reader profile, not just "ASC".
fn backend_label(backend: Option<bool>) -> &'static str {
match backend {
Some(true) => "ASurfaceControl (overlay reader)",
Some(false) => "ASurfaceControl (GPU-composited reader)",
None => "SurfaceView",
}
}
/// Put `codec` into async-notify mode, forwarding every codec callback onto `ev_tx`.
///
/// Must run BEFORE `configure()`/`start()` so we're async from the first buffer, and once per
/// bring-up rung — a codec that failed `start` is discarded, and its replacement needs its own
/// registration. Each closure only *pushes an event*: no `AMediaCodec` call happens on the codec's
/// looper thread, which is what keeps every buffer op on the decode thread that owns the codec.
///
/// `false` ⇒ the platform refused async mode; that is not something a simpler format or a
/// different output surface can fix, so the caller gives up rather than trying the next rung.
fn install_async_callbacks(codec: &mut MediaCodec, ev_tx: &mpsc::Sender<DecodeEvent>) -> bool {
let out_tx = ev_tx.clone();
let in_tx = ev_tx.clone();
let fmt_tx = ev_tx.clone();
let err_tx = ev_tx.clone();
let cb = AsyncNotifyCallback {
on_input_available: Some(Box::new(move |idx| {
let _ = in_tx.send(DecodeEvent::InputAvailable(idx));
})),
on_output_available: Some(Box::new(move |idx, info| {
let _ = out_tx.send(DecodeEvent::OutputAvailable {
index: idx,
pts_us: info.presentation_time_us().max(0) as u64,
// The `decoded` HUD point: stamp HERE, on the codec's looper thread, so the
// decode stage ends when the frame actually became available — not after the
// channel hop + whatever work the loop coalesces in front of presenting it.
decoded_ns: now_realtime_ns(),
// Its monotonic twin, from the same instant. The stats are REALTIME (they
// fold the host's clock offset in), while the cadence loop and
// `releaseOutputBufferAtTime` are both CLOCK_MONOTONIC — and the loop is fed
// and read in one domain, never converted (`punktfunk_core::phase`: a
// constant offset between domains is what its offset estimator absorbs).
decoded_mono_ns: now_monotonic_ns(),
});
})),
on_format_changed: Some(Box::new(move |_fmt| {
let _ = fmt_tx.send(DecodeEvent::FormatChanged);
})),
on_error: Some(Box::new(move |e, code, _detail| {
let fatal = !code.is_recoverable() && !code.is_transient();
if fatal {
log::error!("decode: fatal codec error — stream will stop: {e:?}");
} else {
log::warn!("decode: codec error {e:?} (recoverable)");
}
let _ = err_tx.send(DecodeEvent::Error { fatal });
})),
};
if let Err(e) = codec.set_async_notify_callback(Some(cb)) {
log::error!("decode: set_async_notify_callback failed: {e}");
return false;
}
true
}
/// The event-driven async decode loop (default; see [`run`]/[`USE_ASYNC_DECODE`]). The codec drives
/// us: an async-notify callback fires the instant an input buffer frees or a frame finishes
/// decoding, so a decoded frame is presented immediately instead of waiting out a poll interval (the
@@ -101,132 +185,177 @@ pub(super) fn run_async(
boost_thread_priority();
let mode = client.mode();
let mime = codec_mime(client.codec);
let mut codec = match create_codec(mime, decoder_name.as_deref()) {
Some(c) => c,
None => {
log::error!("decode: no {mime} decoder on this device");
return;
// HDR static metadata (ST.2086 mastering + content light level): fetched ONCE, ahead of the
// bring-up ladder, so a retry rung never pays the wait again. MediaCodec wants it BEFORE
// configure(), and the host sends a 0xCE right after the handshake, so it's typically already
// queued; wait briefly otherwise. The Surface DataSpace (applied on FormatChanged below)
// carries transfer/primaries regardless — this adds the luminance the tone-mapper needs.
let hdr_static = if client.color.is_hdr() {
match client.next_hdr_meta(Duration::from_millis(250)) {
Ok(meta) => {
log::info!("decode: HDR static metadata applied (KEY_HDR_STATIC_INFO)");
Some(android_hdr_static_info(&meta))
}
Err(_) => {
log::info!("decode: HDR session but no mastering metadata yet — DataSpace only");
None
}
}
} else {
None
};
let codec_name = codec.name().unwrap_or_default();
stats.set_decoder(&codec_name, ll_feature);
log::info!(
"decode: codec mime = {mime}, decoder = {codec_name} (async, low-latency feature: {ll_feature})"
);
// Resolve the present intent once (shared by both backends).
let priority = PresentPriority::resolve(present_priority, smooth_buffer);
// The event channel: the callbacks + feeder push, this loop pulls. `Sender` is `Send`, so the
// callback closures (each capturing a clone) satisfy the async-notify `Send` bound.
let (ev_tx, ev_rx) = mpsc::channel::<DecodeEvent>();
// Install the callbacks BEFORE configure()/start() so we're in async mode from the first buffer.
// Each just forwards an index/flag — no codec access here (the codec owns these closures).
{
let out_tx = ev_tx.clone();
let in_tx = ev_tx.clone();
let fmt_tx = ev_tx.clone();
let err_tx = ev_tx.clone();
let cb = AsyncNotifyCallback {
on_input_available: Some(Box::new(move |idx| {
let _ = in_tx.send(DecodeEvent::InputAvailable(idx));
})),
on_output_available: Some(Box::new(move |idx, info| {
let _ = out_tx.send(DecodeEvent::OutputAvailable {
index: idx,
pts_us: info.presentation_time_us().max(0) as u64,
// The `decoded` HUD point: stamp HERE, on the codec's looper thread, so the
// decode stage ends when the frame actually became available — not after the
// channel hop + whatever work the loop coalesces in front of presenting it.
decoded_ns: now_realtime_ns(),
// Its monotonic twin, from the same instant. The stats are REALTIME (they
// fold the host's clock offset in), while the cadence loop and
// `releaseOutputBufferAtTime` are both CLOCK_MONOTONIC — and the loop is fed
// and read in one domain, never converted (`punktfunk_core::phase`: a
// constant offset between domains is what its offset estimator absorbs).
decoded_mono_ns: now_monotonic_ns(),
});
})),
on_format_changed: Some(Box::new(move |_fmt| {
let _ = fmt_tx.send(DecodeEvent::FormatChanged);
})),
on_error: Some(Box::new(move |e, code, _detail| {
let fatal = !code.is_recoverable() && !code.is_transient();
if fatal {
log::error!("decode: fatal codec error — stream will stop: {e:?}");
} else {
log::warn!("decode: codec error {e:?} (recoverable)");
}
let _ = err_tx.send(DecodeEvent::Error { fatal });
})),
};
if let Err(e) = codec.set_async_notify_callback(Some(cb)) {
log::error!("decode: set_async_notify_callback failed: {e}");
return;
}
}
// Build the low-latency format (identical keys to the sync path).
let mut format = MediaFormat::new();
format.set_str("mime", mime);
format.set_i32("width", mode.width as i32);
format.set_i32("height", mode.height as i32);
format.set_i32(
"max-input-size",
(mode.width * mode.height).max(2_000_000) as i32,
);
configure_low_latency(&mut format, &codec_name, low_latency_mode);
if client.color.is_hdr() {
match client.next_hdr_meta(Duration::from_millis(250)) {
Ok(meta) => {
format.set_buffer("hdr-static-info", &android_hdr_static_info(&meta));
log::info!("decode: HDR static metadata applied (KEY_HDR_STATIC_INFO)");
}
Err(_) => {
log::info!("decode: HDR session but no mastering metadata yet — DataSpace only")
}
}
}
// Resolve the present intent once (shared by both backends).
let priority = PresentPriority::resolve(present_priority, smooth_buffer);
// The present backend. ASurfaceControl (default) drives its own `AImageReader` output surface +
// compositor layer, scheduling against the panel's real present clock; the SurfaceView presenter
// below is the fallback for API < 29, an ASC init failure, or the `present_backend=surfaceview`
// sysprop. A non-null `asc` means the codec renders into the reader, not the SurfaceView window.
let mut asc = if asc_backend_selected() {
// The negotiated colour is authoritative (PQ vs HLG, range) — not a guess the codec's
// output format later corrects; many decoders never echo `color-transfer` at all.
let initial_ds = color_dataspace(&client.color);
AscBackend::create(
&window,
mode.width as i32,
mode.height as i32,
surface_size,
panel_hz,
initial_ds,
mode.refresh_hz,
priority,
)
} else {
// ── Decoder bring-up ladder ──────────────────────────────────────────────────────────────
// `configure()` can succeed and `start()` still fail: start is where the codec negotiates
// buffers with its output consumer and allocates them, so a decoder that accepted the format
// can still refuse the surface it has to render into. Observed on a Xiaomi Mi TV Stick
// (2026-08-27; Android 11, armeabi-v7a, `OMX.amlogic.hevc.decoder.awesome2`): EVERY session
// logged `start failed: ErrorUnknown` and returned, so this thread died before feeding a single
// AU while the pump kept receiving video — the frame queue filled, the pump jumped to live once
// per `FLUSH_COOLDOWN`, and the host read that perfect 2 s keyframe cadence as a client too
// slow to keep up. Audio, input and the library all kept working, so it presented to the user
// as a permanent black screen with sound, and to us as a decoder that was never even running.
//
// A codec that failed `start` is in an error state and cannot be reconfigured, so each rung
// builds a fresh one. The rungs shed what a start can choke on, most-suspect first:
//
// 1. The ASC reader's `COMPOSER_OVERLAY` usage. Start dequeues every codec output buffer
// from the reader's window with OUR consumer usage OR'd into the decoder's own producer
// bits, and overlay + GPU-sampled + vendor-vdec in one allocation is exactly what an old
// OMX-era gralloc can refuse (see [`AscBackend::create`]'s `overlay` doc). The retry
// keeps the whole ASC backend — real latches, real fences — and asks only for the
// SurfaceTexture-shaped `GPU_SAMPLED_IMAGE` allocation every video path exercises;
// SurfaceFlinger GPU-composites the layer instead of scanning it out. Usage is the ONLY
// reader axis worth a rung: `READER_MAX_IMAGES` is not a start-time factor (consumer-side
// images allocate lazily during streaming).
// 2. The `AImageReader` entirely — an app-side BufferQueue consumer at all is the residual
// suspect (a vendor OMX component keying on queues-to-composer).
// 3. The aggressive low-latency key set.
//
// Every downstream branch here already keys off `asc.is_some()`, so a fallen-back session just
// runs the SurfaceView presenter that has always been the API < 29 / ASC-init-failure
// fallback — nothing below this block needs to know.
//
// The rung that wins is logged: on a device that needs one, that line names the real culprit,
// which no amount of host-side log reading could.
//
// ponytail: no ASC + plain-keys rung. If a field case ever shows the low-latency keys alone
// were at fault, that rung belongs between 2 and 3 — the ASC presenter is the better one and
// is worth keeping whenever it can start.
let asc_wanted = asc_backend_selected();
if !asc_wanted {
log::info!("decode: present backend = SurfaceView (present_backend sysprop)");
None
}
let rungs = bring_up_rungs(asc_wanted, low_latency_mode);
let mut brought_up: Option<(MediaCodec, Option<AscBackend>)> = None;
for (rung, &(backend, aggressive)) in rungs.iter().enumerate() {
if rung > 0 {
log::warn!(
"decode: decoder refused that configuration — retrying through {} with aggressive \
low-latency keys {}",
backend_label(backend),
if aggressive { "ON" } else { "OFF" }
);
}
let mut codec = match create_codec(mime, decoder_name.as_deref()) {
Some(c) => c,
None => {
log::error!("decode: no {mime} decoder on this device");
return;
}
};
// The decoder's *actual* resolved name (Kotlin's pick, or the platform default when it
// fell back) drives both the HUD label and which vendor low-latency keys apply.
let codec_name = codec.name().unwrap_or_default();
if rung == 0 {
stats.set_decoder(&codec_name, ll_feature);
log::info!(
"decode: codec mime = {mime}, decoder = {codec_name} (async, low-latency feature: {ll_feature})"
);
}
if !install_async_callbacks(&mut codec, &ev_tx) {
return; // the platform refused async mode outright — no rung changes that
}
// Build the low-latency format (identical keys to the sync path).
let mut format = MediaFormat::new();
format.set_str("mime", mime);
format.set_i32("width", mode.width as i32);
format.set_i32("height", mode.height as i32);
format.set_i32(
"max-input-size",
(mode.width * mode.height).max(2_000_000) as i32,
);
configure_low_latency(&mut format, &codec_name, aggressive);
if let Some(info) = hdr_static.as_ref() {
format.set_buffer("hdr-static-info", info);
}
// The present backend. ASurfaceControl (default) drives its own `AImageReader` output
// surface + compositor layer, scheduling against the panel's real present clock; the
// SurfaceView presenter below is the fallback for API < 29, an ASC init failure, the
// `present_backend=surfaceview` sysprop, or a rung that dropped it. A non-null `asc` means
// the codec renders into the reader, not the SurfaceView window.
let asc = if let Some(overlay) = backend {
// The negotiated colour is authoritative (PQ vs HLG, range) — not a guess the codec's
// output format later corrects; many decoders never echo `color-transfer` at all.
AscBackend::create(
&window,
mode.width as i32,
mode.height as i32,
surface_size.clone(),
panel_hz,
color_dataspace(&client.color),
mode.refresh_hz,
priority,
overlay,
)
} else {
None
};
// The decoder's output surface: the reader's window when ASC is active, else the SurfaceView.
let configure_window: &NativeWindow = asc.as_ref().map_or(&window, |a| a.reader_window());
if let Err(e) = codec.configure(
&format,
Some(configure_window),
MediaCodecDirection::Decoder,
) {
log::error!("decode: configure failed: {e}");
continue;
}
if let Err(e) = codec.start() {
log::error!("decode: start failed: {e}");
continue;
}
log::info!(
"decode: decoder started (async) at {}x{} through {}",
mode.width,
mode.height,
// `asc.as_ref().and(backend)`, not `backend`: an ASC rung whose backend failed to
// CREATE fell back to the SurfaceView within the rung, and this line must report what
// actually runs.
backend_label(asc.as_ref().and(backend))
);
brought_up = Some((codec, asc));
break;
}
let Some((codec, mut asc)) = brought_up else {
// Every rung refused. Say so loudly and in the shape the next reporter can act on: the
// session stays up (audio/input/library all still work), so without this line the only
// symptom is a black screen and a keyframe request every 2 s that blames the network.
log::error!(
"decode: the {mime} decoder refused EVERY configuration — this session has no video. \
Audio and input keep working, so the stream will look alive while the screen stays \
black, and the host will see a keyframe recovery request every 2 s that is this, not \
a slow link. See the `configure failed` / `start failed` lines above for the reason \
each rung gave"
);
return;
};
// The decoder's output surface: the reader's window when ASC is active, else the SurfaceView.
let configure_window: &NativeWindow = asc.as_ref().map_or(&window, |a| a.reader_window());
if let Err(e) = codec.configure(
&format,
Some(configure_window),
MediaCodecDirection::Decoder,
) {
log::error!("decode: configure failed: {e}");
return;
}
if let Err(e) = codec.start() {
log::error!("decode: start failed: {e}");
return;
}
log::info!(
"decode: decoder started (async) at {}x{}",
mode.width,
mode.height
);
// The forced TV mode switch (`is_tv` ⇒ ALWAYS strategy) is part of the experimental stack;
// off, every form factor gets the original soft seamless hint. ASC votes the rate on its own
// layer instead (the SurfaceView window shows nothing under the ASC path).
@@ -1259,3 +1388,52 @@ fn asc_present_ready(
}
stats.note_skipped(withheld); // gate-withheld frames (the reader-drop skips ride `asc.flush`)
}
#[cfg(test)]
mod tests {
use super::bring_up_rungs;
/// The ladder that turns a decoder which refuses to start from a permanent black screen into
/// a retry or two away from a picture (the 2026-08-27 Mi TV Stick case). Order and
/// de-duplication are the whole of its logic — everything else in the loop is MediaCodec I/O.
#[test]
fn rungs_shed_the_overlay_then_asc_then_the_aggressive_keys_and_never_repeat_one() {
// The default: shed the reader's COMPOSER_OVERLAY usage first (keeping ASC — the whole
// point of the middle rung), then the `AImageReader` entirely, then the aggressive keys.
assert_eq!(
bring_up_rungs(true, true),
[
(Some(true), true),
(Some(false), true),
(None, true),
(None, false)
]
);
// `present_backend=surfaceview` already shed ASC — both ASC rungs collapse away.
assert_eq!(bring_up_rungs(false, true), [(None, true), (None, false)]);
// Low-latency mode off ⇒ the keys are already the plain set; the backend is the only axis.
assert_eq!(
bring_up_rungs(true, false),
[(Some(true), false), (Some(false), false), (None, false)]
);
// Nothing left to shed: one attempt, and no pointless second `start` of the same thing.
assert_eq!(bring_up_rungs(false, false), [(None, false)]);
for asc in [true, false] {
for ll in [true, false] {
let rungs = bring_up_rungs(asc, ll);
// A device that works must pay nothing for this ladder: rung 0 is always exactly
// what the session asked for.
assert_eq!(rungs[0], (asc.then_some(true), ll));
// Every ladder ends at the most conservative configuration there is.
assert_eq!(*rungs.last().unwrap(), (None, false));
// Monotonic: a rung only ever sheds, never re-enables what an earlier one dropped
// (`Option<bool>`'s Ord: `None < Some(false) < Some(true)`), so the ladder always
// descends towards the conservative end.
assert!(rungs
.windows(2)
.all(|w| w[1].0 <= w[0].0 && w[1].1 <= w[0].1));
}
}
}
}
+10 -1
View File
@@ -108,7 +108,16 @@ pub(super) fn run_sync(
return;
}
if let Err(e) = codec.start() {
log::error!("decode: start failed: {e}");
// No bring-up ladder here, unlike the async loop: this path only runs with low-latency
// mode OFF, which is already the conservative key set, and it renders straight into the
// SurfaceView rather than an `AImageReader` — the two things that ladder sheds are both
// already shed. Name the symptom instead, because the session stays up around this
// failure (audio, input and the library all keep working) and the host sees only a
// keyframe request every 2 s that reads as a slow link.
log::error!(
"decode: start failed: {e} — this session has no video. Audio and input keep working, \
so the stream will look alive while the screen stays black"
);
return;
}
log::info!(
+6 -5
View File
@@ -178,11 +178,12 @@ fn resolve(info: &ResolvedService) -> Option<Host> {
if !proto.is_empty() && proto != PROTO {
return None; // some other DNS-SD service sharing the type — ignore
}
let addr = info
.get_addresses_v4()
.iter()
.next()
.map(|a| a.to_string())?;
// Deterministic pick from the union of per-interface answers (the host OS's responder
// contributes VPN/overlay addresses; `iter().next()` on the HashSet dialed an arbitrary
// one) — same policy as the desktop client, shared in `punktfunk_core::discovery`.
let candidates: Vec<std::net::Ipv4Addr> = info.get_addresses_v4().into_iter().collect();
let addr = punktfunk_core::discovery::pick_host_addr(&candidates, val("addr").parse().ok())?
.to_string();
let id = val("id");
let fullname = info.get_fullname();
Some(Host {
@@ -275,6 +275,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
launch: JString<'local>,
device_name: JString<'local>,
pad_audio_ok: jboolean,
keep_host_audio: jboolean,
) -> jlong {
// Every JNI string this method needs, read up front in the one `Env` scope jni 0.22 grants a
// native method; everything below is pure Rust over owned `String`s. `None` = the mandatory
@@ -438,6 +439,16 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO
} else {
0
}
// The user's "Keep host audio playing" setting: the host taps whatever its default
// playback device already is instead of parking the desktop mix on a silent
// endpoint, so the speakers on the host PC stay live. REQUEST-only — there is no
// host-cap echo — so an older host ignores the bit and re-routes exactly as it
// always did ("the host went quiet"), never a broken session.
| if keep_host_audio {
punktfunk_core::quic::CLIENT_CAP_KEEP_HOST_AUDIO
} else {
0
},
// Slice-progressive delivery, by decoder truth (Kotlin probes FEATURE_PartialFrame on
// every decoder this device would use; `debug.punktfunk.force_parts` overrides for the
@@ -24,8 +24,9 @@ struct ContentView: View {
/// connect to resolve the session's `EffectiveSettings`, and edited by the settings surface.
@ObservedObject private var profiles = ProfileStore.shared
@StateObject private var discovery = HostDiscovery()
// The dev auto-connect hook writes these three, so they stay observed here; every OTHER
// stream setting reaches a session through `EffectiveSettings`, resolved once per connect.
// The dev auto-connect hook (DEBUG-only see `autoConnectIfAsked`) writes these three, so
// they stay observed here; every OTHER stream setting reaches a session through
// `EffectiveSettings`, resolved once per connect.
@AppStorage(DefaultsKey.streamWidth) private var width = 1920
@AppStorage(DefaultsKey.streamHeight) private var height = 1080
@AppStorage(DefaultsKey.streamHz) private var hz = 60
@@ -52,6 +53,29 @@ struct ContentView: View {
/// a live session is already up. Surfaced as an informational alert (distinct from the
/// "Connection failed" one, which is for actual connect errors).
@State private var deepLinkNotice: String?
/// A `punktfunk://` deep link that named a saved host by something GUESSABLE its display
/// name, its address, or the `host=` recovery parameter instead of by its stable record id.
/// Anything that can open a URL can guess "Gaming PC", so the link's action waits for this
/// confirmation; a link that names the id (every shortcut this app emits) still runs on its own.
private struct DeepLinkConfirm {
let host: StoredHost
let launch: String?
let profile: ProfileSelection
/// A `browse` link: open the host's library instead of dialing it.
let browse: Bool
var actionTitle: String { browse ? "Open Library" : "Connect" }
var message: String {
let asked = browse
? "open \(host.displayName)'s game library"
: "connect to \(host.displayName)"
+ (launch.map { " and launch \u{201C}\($0)\u{201D}" } ?? "")
return "A link asked to \(asked). It names the host by its label or address, which "
+ "anything that can open a link could guess — a shortcut made in Punktfunk names "
+ "the host's id and opens without asking."
}
}
@State private var deepLinkConfirm: DeepLinkConfirm?
#if os(iOS)
/// Owns the Live Activity for the running session (Lock Screen / Dynamic Island). Driven from
/// the session model's published state below; iPhone/iPad only.
@@ -193,6 +217,29 @@ struct ContentView: View {
} message: {
Text(deepLinkNotice ?? "")
}
// A link that named a saved host by a guessable reference: the dial (or the library)
// happens on the user's word rather than on the link's.
.alert(
"Open this link?",
isPresented: deepLinkConfirmPresented,
presenting: deepLinkConfirm
) { confirm in
Button(confirm.actionTitle) { runDeepLinkConfirm(confirm) }
Button("Cancel", role: .cancel) {}
} message: { confirm in
Text(confirm.message)
}
}
/// The confirmed link's action: exactly what a `.known` (id-referenced) link would have done,
/// one tap later.
private func runDeepLinkConfirm(_ confirm: DeepLinkConfirm) {
deepLinkConfirm = nil
if confirm.browse {
libraryTarget = LibraryTarget(host: confirm.host, profile: confirm.profile)
} else {
connect(confirm.host, launchID: confirm.launch, profile: confirm.profile)
}
}
private var driven: some View {
@@ -482,6 +529,12 @@ struct ContentView: View {
set: { if !$0 { deepLinkNotice = nil } })
}
private var deepLinkConfirmPresented: Binding<Bool> {
Binding(
get: { deepLinkConfirm != nil && !consolePromptShowing },
set: { if !$0 { deepLinkConfirm = 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 {
@@ -558,6 +611,20 @@ struct ContentView: View {
},
])
}
if let confirm = deepLinkConfirm {
return GamepadPrompt(
id: "link-confirm",
title: "Open this link?",
message: confirm.message,
actions: [
GamepadPromptAction(id: "go", title: confirm.actionTitle, isPrimary: true) {
runDeepLinkConfirm(confirm)
},
GamepadPromptAction(id: "cancel", title: "Cancel", isCancel: true) {
deepLinkConfirm = nil
},
])
}
if let notice = deepLinkNotice {
return GamepadPrompt(
id: "cant-open",
@@ -654,11 +721,13 @@ struct ContentView: View {
/// (design/client-deep-links.md): a stable id, a unique host name or an `addr[:port]`, with
/// `fp`/`host` recovery parameters and a one-off `profile`.
///
/// The security posture is the parser's plus three rules that live here, and none of them
/// The security posture is the parser's plus four rules that live here, and none of them
/// bends: a URL never pairs and never trusts on its own (an unknown host becomes a
/// confirmation, not a connect), never preempts a live session (same host focus, different
/// host say so; NEVER tear one down on a background tap), and carries only references a
/// profile it can't honor refuses with a notice rather than streaming with the wrong settings.
/// confirmation, not a connect), never dials on a GUESSABLE reference (only the stable record
/// id connects unattended a label or an address becomes a confirmation), never preempts a
/// live session (same host focus, different host say so; NEVER tear one down on a
/// background tap), and carries only references a profile it can't honor refuses with a
/// notice rather than streaming with the wrong settings.
private func handleDeepLink(_ url: URL) {
let link: DeepLink
do {
@@ -703,8 +772,12 @@ struct ContentView: View {
return
}
}
switch link.resolveHost(in: store.hosts) {
case .known(let host):
let resolution = link.resolveHost(in: store.hosts)
switch resolution {
// A saved record. `.known` (named by its unguessable id) dials straight away; `.confirm`
// (named by its label or its address, which anything that can open a URL could guess)
// takes the same dial one tap later.
case .known(let host), .confirm(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."
@@ -718,10 +791,19 @@ struct ContentView: View {
}
return // deep-linked to the host we're already on nothing to do
}
if case .confirm = resolution {
deepLinkConfirm = DeepLinkConfirm(
host: host, launch: link.launch, profile: selection, browse: false)
return
}
connect(host, launchID: link.launch, profile: selection)
case .unknown(let address, let port, let name, let fp):
// Never a silent connect: hand the address, claimed name and pin to the add sheet so
// the user makes the trust decision with their eyes on it.
// Never a silent connect an unsaved host is a trust decision, and a link is not
// where it gets made. This only NAMES what the link pointed at; adding the host is a
// deliberate trip to the + button, where the fingerprint is on screen. (Linux, Android
// and Windows instead pre-fill their trust prompt from the link; the outcome is the
// same nothing connects until a person looks at it but the sheet is not seeded
// here, so don't read this as doing that.)
guard model.phase == .idle else {
deepLinkNotice = "Already streaming. End that session first."
return
@@ -765,8 +847,10 @@ struct ContentView: View {
return
}
}
switch link.resolveHost(in: store.hosts) {
case .known(let host):
let resolution = link.resolveHost(in: store.hosts)
switch resolution {
// Same rule as a connect link: only the record id opens on the link's own say-so.
case .known(let host), .confirm(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."
@@ -780,6 +864,11 @@ struct ContentView: View {
}
return // browsing the host we're already streaming nothing to do
}
if case .confirm = resolution {
deepLinkConfirm = DeepLinkConfirm(
host: host, launch: nil, profile: selection, browse: true)
return
}
libraryTarget = LibraryTarget(host: host, profile: selection)
case .unknown(let address, _, let name, _):
deepLinkNotice = "\(name ?? address) isn't saved on this device yet. "
@@ -1425,7 +1514,12 @@ struct ContentView: View {
/// touching the saved host list. PUNKTFUNK_COMPOSITOR=kwin|gamescope| overrides the
/// compositor preference and PUNKTFUNK_REMOTE_GAMEPAD=xbox360|dualsense the virtual
/// pad type (same names as the host env knobs). (IPv4/hostname only.)
///
/// DEBUG-ONLY, and compiled out of a release build: it streams to whatever host an
/// environment variable names with the trust prompt auto-confirmed, which is a dev lever
/// (`swift run`, the shot harness), never something a shipped app should answer to.
private func autoConnectIfAsked() {
#if DEBUG
guard let target = ProcessInfo.processInfo.environment["PUNKTFUNK_AUTOCONNECT"],
!target.isEmpty, model.phase == .idle
else { return }
@@ -1460,5 +1554,6 @@ struct ContentView: View {
effective.bitrateKbps = v
}
model.connect(to: host, effective: effective, gamepad: pad, autoTrust: true)
#endif
}
}
@@ -399,7 +399,7 @@ final class SessionModel: ObservableObject {
let hz = UInt32(clamping: effective.refreshHz)
let compositor = PunktfunkConnection.Compositor(
rawValue: UInt32(clamping: effective.compositor)) ?? .auto
let bitrateKbps = UInt32(clamping: effective.bitrateKbps)
var bitrateKbps = UInt32(clamping: effective.bitrateKbps)
let audioChannels = UInt8(clamping: effective.audioChannels)
// The audio format this session ASKS for the user's choice, at every channel count.
//
@@ -419,6 +419,15 @@ final class SessionModel: ObservableObject {
let (audioRateHz, audioBits) = audioFormat.wire
let hdrEnabled = effective.hdrEnabled
let preferredCodec = PunktfunkConnection.codecByte(effective.codec)
// PyroWave is always Automatic bitrate (ABR overhaul RFC §5.2): a fixed kbps is
// ill-defined for the all-intra codec (bpp is the operating point) and used to bypass
// the host's operator ceiling send 0 and let the host pin its per-mode rate. Gated
// like the advertisement below: a device that failed the Metal probe never offers the
// codec, falls back to H.26x, and the user's rate must survive there. The stored
// setting is untouched, so switching codecs back restores it.
if preferredCodec == PunktfunkConnection.codecPyroWave, MetalWaveletDecoder.supported {
bitrateKbps = 0
}
let pin = host.pinnedSHA256
// Capability gate (main-actor screen APIs): only advertise HDR when this display can
// actually present it, so the host sends a proper SDR stream to an SDR display rather than
@@ -502,14 +511,20 @@ final class SessionModel: ObservableObject {
// the pointer and forwards shape/state, which StreamView draws as the real
// NSCursor. Capture-mode sessions keep today's composited pointer.
#if os(macOS)
let clientCaps: UInt8 =
let presentCaps: UInt8 =
(MouseInputMode(rawValue: effective.mouseMode) ?? .capture) == .desktop ? 0x01 : 0
#else
// iOS/tvOS run the stage-4 deadline presenter, whose link thread feeds
// reportPhase advertise the vsync-aware presenter (0x02, CLIENT_CAP_PHASE_LOCK).
// macOS stays without it: the stage-2 arrival presenter has no latch grid.
let clientCaps: UInt8 = 0x02
let presentCaps: UInt8 = 0x02
#endif
// "Keep host audio playing": the host taps its default playback device instead of
// parking it on a silent endpoint, so the speakers on the host PC stay live. Pure
// REQUEST no host-cap echo so an older host simply goes quiet as it always did.
let clientCaps =
presentCaps
| (effective.keepHostAudio ? PunktfunkConnection.clientCapKeepHostAudio : 0)
let result = Result { try PunktfunkConnection(
host: host.address, port: host.port,
width: width, height: height, refreshHz: hz,
@@ -106,6 +106,10 @@ enum SettingsFields {
.init(name: "echo_cancel", key: DefaultsKey.echoCancel,
overlay: \.echoCancel, effective: \.echoCancel)
}
static var keepHostAudio: SettingsField<Bool> {
.init(name: "keep_host_audio", key: DefaultsKey.keepHostAudio,
overlay: \.keepHostAudio, effective: \.keepHostAudio)
}
static var touchMode: SettingsField<String> {
.init(name: "touch_mode", key: DefaultsKey.touchMode,
overlay: \.touchMode, effective: \.touchMode)
@@ -201,6 +205,7 @@ extension SettingsView {
base.audioFormat = audioFormat
base.micEnabled = micEnabled
base.echoCancel = echoCancel
base.keepHostAudio = keepHostAudio
base.gamepadType = gamepadType
base.gamepadForwarding = gamepadForwarding
base.statsVerbosity = statsVerbosityRaw
@@ -256,11 +256,25 @@ extension SettingsView {
/// The automatic-bitrate toggle + manual slider (and the >1 Gbps warning) rows.
@ViewBuilder private var bitrateRows: some View {
described("Uses the host's default, 20 Mbps. Off to set it yourself.",
field: "bitrate_kbps") {
Toggle("Automatic bitrate", isOn: automaticBitrate)
// PyroWave is always Automatic (ABR overhaul RFC §5.2): the session sends 0 and the
// host pins a per-mode rate, so a live rate control here would change nothing. Same
// support gate as the codec picker offering the option; the stored rate is untouched,
// so switching the codec back restores it.
if effective.codec == "pyrowave", MetalWaveletDecoder.supported {
described("PyroWave sets its own rate from the stream mode — a fixed bitrate "
+ "doesn't apply.",
field: "bitrate_kbps") {
Toggle("Automatic bitrate", isOn: .constant(true))
.disabled(true)
}
} else {
described("Uses the host's default, 20 Mbps. Off to set it yourself.",
field: "bitrate_kbps") {
Toggle("Automatic bitrate", isOn: automaticBitrate)
}
}
if effective.bitrateKbps != 0 {
if effective.codec != "pyrowave" || !MetalWaveletDecoder.supported,
effective.bitrateKbps != 0 {
HStack(spacing: 12) {
Slider(value: bitrateSlider, in: 0...1) {
Text("Bitrate")
@@ -613,6 +627,11 @@ extension SettingsView {
}
}
}
described("The host's speakers or headphones keep playing while you stream — "
+ "needs a host on 0.32+",
field: "keep_host_audio") {
Toggle("Keep host audio playing", isOn: scoped(SettingsFields.keepHostAudio))
}
#if os(macOS)
// Which speaker THIS Mac plays through is this device's audio routing (tier G).
if !inProfileScope {
@@ -71,6 +71,7 @@ struct SettingsView: View {
@AppStorage(DefaultsKey.fullscreenWhileStreaming) var fullscreenWhileStreaming = true
@AppStorage(DefaultsKey.micEnabled) var micEnabled = true
@AppStorage(DefaultsKey.echoCancel) var echoCancel = true
@AppStorage(DefaultsKey.keepHostAudio) var keepHostAudio = false
@AppStorage(DefaultsKey.audioChannels) var audioChannels = 2
@AppStorage(DefaultsKey.audioFormat) var audioFormat = AudioFormatChoice.opus.rawValue
@AppStorage(DefaultsKey.codec) var codec = "auto"
@@ -450,15 +451,24 @@ struct SettingsView: View {
title: "Render scale",
options: RenderScale.presets.map { (label: RenderScale.label($0), tag: $0) },
selection: $renderScale)
TVSelectionRow(
title: "Bitrate",
options: SettingsOptions.bitrateOptions(current: bitrateKbps),
selection: $bitrateKbps)
if bitrateKbps > 1_000_000 {
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
.font(.geist(20, relativeTo: .caption)) // TV-legible caption size
.foregroundStyle(.orange)
.multilineTextAlignment(.center)
// PyroWave is always Automatic (ABR overhaul RFC §5.2): the session sends 0
// and the host pins a per-mode rate. tvOS has no codec picker, so this only
// fires on a codec synced from another device but the row must not offer a
// rate the session ignores. The stored value is kept.
if codec == "pyrowave", MetalWaveletDecoder.supported {
tvCaption("PyroWave sets its own rate from the stream mode — the bitrate "
+ "setting doesn't apply.")
} else {
TVSelectionRow(
title: "Bitrate",
options: SettingsOptions.bitrateOptions(current: bitrateKbps),
selection: $bitrateKbps)
if bitrateKbps > 1_000_000 {
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
.font(.geist(20, relativeTo: .caption)) // TV-legible caption size
.foregroundStyle(.orange)
.multilineTextAlignment(.center)
}
}
TVSelectionRow(
title: "10-bit HDR",
@@ -1,11 +1,12 @@
// This client's persistent punktfunk/1 identity: a self-signed certificate + key (PEM),
// generated once and stored in the data-protection Keychain (with a legacy file-keychain
// fallback for unsigned builds see `query(dataProtection:)`). The certificate's fingerprint is how
// hosts recognize this client after PIN pairing losing the key un-pairs this Mac from
// every host, so the pair is presented on every connect but never regenerated once
// stored. That invariant drives the error handling below: a Keychain that *refuses
// access* (locked, ACL denied) is an error, not a first run minting a replacement
// would silently shadow the durable identity and break every existing pairing.
// generated once and stored in the data-protection Keychain, this-device-only (with a legacy
// file-keychain fallback for unsigned builds see `query(dataProtection:)`). The certificate's
// fingerprint is how hosts recognize this client after PIN pairing losing the key un-pairs this
// Mac from every host, so the pair is presented on every connect but never regenerated once
// stored (and never leaves this device: see `add`). That invariant drives the error handling
// below: a Keychain that *refuses access* (locked, ACL denied) is an error, not a first run
// minting a replacement would silently shadow the durable identity and break every existing
// pairing.
import Foundation
import PunktfunkKit
@@ -115,6 +116,16 @@ final class ClientIdentityStore: @unchecked Sendable {
if case .denied(errSecMissingEntitlement) = result {
return read(dataProtection: false)
}
// An item added before the this-device-only switch keeps the accessibility class it was
// added with it would keep riding backups forever, because the identity is never
// regenerated. Re-stamp it on the way past (best-effort: a refusal just leaves the old
// class, and the identity still reads).
if case .found = result {
SecItemUpdate(
Self.query(dataProtection: true) as CFDictionary,
[kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly]
as CFDictionary)
}
return result
}
@@ -141,9 +152,12 @@ final class ClientIdentityStore: @unchecked Sendable {
else { return errSecParam }
var add = Self.query(dataProtection: true)
add[kSecValueData as String] = data
// After-first-unlock so a background reconnect can still read it; the access-group
// entitlement (not a per-binary ACL) gates it, so it survives rebuilds prompt-free.
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
// After-first-unlock so a background reconnect can still read it, THIS DEVICE ONLY so it
// never rides an encrypted backup or a device migration: this key is the whole credential
// a host pairs with, and a restored backup would silently re-pair the restoring device
// with every host. The access-group entitlement (not a per-binary ACL) gates it, so it
// still survives rebuilds prompt-free.
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let status = SecItemAdd(add as CFDictionary, nil)
guard status == errSecMissingEntitlement else { return status }
// Ad-hoc / unsigned build: persist to the legacy file keychain instead.
@@ -1446,6 +1446,13 @@ public final class PunktfunkConnection {
/// auto-selected. Decoded by the Metal wavelet decoder, not VideoToolbox.
public static let codecPyroWave: UInt8 = UInt8(PUNKTFUNK_CODEC_PYROWAVE)
/// `clientCaps` bit: ask the host to leave ITS OWN audio devices alone for this session
/// it taps whatever its default playback device already is instead of parking the desktop
/// mix on a silent endpoint, so the host PC's speakers keep playing and this device hears
/// the same audio. REQUEST-only, no host-cap echo: an older host ignores it and goes quiet
/// exactly as it always did, so it is safe to set unconditionally from the user's setting.
public static let clientCapKeepHostAudio: UInt8 = UInt8(PUNKTFUNK_CLIENT_CAP_KEEP_HOST_AUDIO)
/// The `codec` SETTING (a `DefaultsKey.codec` / profile-overlay string) as a soft-preference
/// byte; `0` = Automatic, i.e. the host decides. Lives here beside the bits so the settings
/// string is mapped to the wire in exactly one place a session and a speed test that
@@ -327,16 +327,22 @@ public struct DeepLink: Equatable, Sendable {
}
/// Resolve this link's host reference against the local store, in the documented order:
/// stable record id unique case-insensitive name `addr[:port]` literal. The `host=`
/// parameter is the recovery path a self-emitted shortcut that outlived the record it was
/// written from still lands on the right box (degraded to the confirmation sheet).
/// stable record id unique case-insensitive name `addr[:port]` literal, then the `host=`
/// recovery path a self-emitted shortcut that outlived the record it was written from still
/// lands on the right box.
///
/// Only the record id is UNGUESSABLE, so only the record id resolves to `.known`, the silent
/// one-click contract. A display name is an mDNS instance name or a user label ("Gaming PC")
/// and an address is a LAN address: anything that can open a URL can guess those, and a guess
/// must not be able to start a stream (or launch a title). They resolve to `.confirm` the
/// same host, behind the user's OK.
public func resolveHost(in hosts: [StoredHost]) -> HostResolution {
let reference = hostRef.lowercased()
if let match = hosts.first(where: { $0.id.uuidString.lowercased() == reference }) {
return .known(match)
}
let byName = hosts.filter { !$0.name.isEmpty && $0.name.lowercased() == reference }
if byName.count == 1 { return .known(byName[0]) }
if byName.count == 1 { return .confirm(byName[0]) }
if byName.count > 1 { return .ambiguous }
// `addr[:port]` literal, then the `host=` recovery parameter both matched the way every
// other per-host lookup in the client matches. The literal is only considered when the
@@ -347,7 +353,7 @@ public struct DeepLink: Equatable, Sendable {
if let match = hosts.first(where: {
$0.address == candidate.address && $0.port == candidate.port
}) {
return .known(match)
return .confirm(match)
}
}
guard let target = literal ?? host else { return .unresolvable }
@@ -356,8 +362,13 @@ public struct DeepLink: Equatable, Sendable {
/// What the local host store made of a link's references.
public enum HostResolution: Equatable, Sendable {
/// A record we already have (subject to `pinConflict`).
/// A record we already have, named by its stable (unguessable) id: the one-click contract
/// (subject to `pinConflict`).
case known(StoredHost)
/// The same record, named by something GUESSABLE its display name, its address, or the
/// `host=` recovery parameter. A link may not act on a guess, so this one waits for the
/// user's confirmation; past that it is the `.known` path exactly.
case confirm(StoredHost)
/// No record, but the link says where to dial: the confirmation sheet's input, from which
/// the normal pairing flow proceeds under the user's eyes. Never an auto-connect.
case unknown(address: String, port: UInt16, name: String?, fp: String?)
@@ -89,6 +89,13 @@ public enum DefaultsKey {
/// speaker/mic or mic channel also bypasses it (the voice processor only follows the
/// system default devices) see SessionAudio's topology note.
public static let echoCancel = "punktfunk.echoCancel"
/// Ask the host to leave ITS OWN audio devices alone for this session
/// (`PUNKTFUNK_CLIENT_CAP_KEEP_HOST_AUDIO`): it captures whatever its default playback
/// device already is, so the speakers/headphones on the host PC keep playing while this
/// device hears the same audio. Off (the default) is today's behaviour the host parks
/// playback on a silent endpoint and goes quiet for the session. Best-effort: an older
/// host ignores the ask and re-routes as it always did.
public static let keepHostAudio = "punktfunk.keepHostAudio"
public static let speakerUID = "punktfunk.speakerUID"
public static let micUID = "punktfunk.micUID"
/// macOS: which input channel of the chosen mic device feeds the host. 0 = "Auto" (sum every
@@ -33,6 +33,7 @@ public struct EffectiveSettings: Equatable, Sendable {
public var audioFormat = AudioFormatChoice.opus.rawValue
public var micEnabled = true
public var echoCancel = true
public var keepHostAudio = false
public var touchMode = "trackpad"
public var mouseMode = "capture"
public var invertScroll = false
@@ -101,6 +102,7 @@ public struct EffectiveSettings: Equatable, Sendable {
audioFormat = str(DefaultsKey.audioFormat, audioFormat)
micEnabled = bool(DefaultsKey.micEnabled, micEnabled)
echoCancel = bool(DefaultsKey.echoCancel, echoCancel)
keepHostAudio = bool(DefaultsKey.keepHostAudio, keepHostAudio)
touchMode = str(DefaultsKey.touchMode, touchMode)
mouseMode = str(DefaultsKey.mouseMode, mouseMode)
invertScroll = bool(DefaultsKey.invertScroll, invertScroll)
@@ -183,6 +185,7 @@ public struct EffectiveSettings: Equatable, Sendable {
if let v = overlay.audioFormat { s.audioFormat = v }
if let v = overlay.micEnabled { s.micEnabled = v }
if let v = overlay.echoCancel { s.echoCancel = v }
if let v = overlay.keepHostAudio { s.keepHostAudio = v }
if let v = overlay.touchMode { s.touchMode = v }
if let v = overlay.mouseMode { s.mouseMode = v }
if let v = overlay.invertScroll { s.invertScroll = v }
@@ -115,6 +115,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
public var audioFormat: String?
public var micEnabled: Bool?
public var echoCancel: Bool?
public var keepHostAudio: Bool?
public var touchMode: String?
public var mouseMode: String?
public var invertScroll: Bool?
@@ -161,6 +162,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
case audioFormat = "audio_format"
case micEnabled = "mic_enabled"
case echoCancel = "echo_cancel"
case keepHostAudio = "keep_host_audio"
case touchMode = "touch_mode"
case mouseMode = "mouse_mode"
case invertScroll = "invert_scroll"
@@ -199,6 +201,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
audioFormat = str(.audioFormat)
micEnabled = bool(.micEnabled)
echoCancel = bool(.echoCancel)
keepHostAudio = bool(.keepHostAudio)
touchMode = str(.touchMode)
mouseMode = str(.mouseMode)
invertScroll = bool(.invertScroll)
@@ -239,6 +242,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
try c.encodeIfPresent(audioFormat, forKey: AnyKey(Key.audioFormat.rawValue))
try c.encodeIfPresent(micEnabled, forKey: AnyKey(Key.micEnabled.rawValue))
try c.encodeIfPresent(echoCancel, forKey: AnyKey(Key.echoCancel.rawValue))
try c.encodeIfPresent(keepHostAudio, forKey: AnyKey(Key.keepHostAudio.rawValue))
try c.encodeIfPresent(touchMode, forKey: AnyKey(Key.touchMode.rawValue))
try c.encodeIfPresent(mouseMode, forKey: AnyKey(Key.mouseMode.rawValue))
try c.encodeIfPresent(invertScroll, forKey: AnyKey(Key.invertScroll.rawValue))
@@ -297,6 +301,7 @@ public enum OverlayField {
case "audio_format": overlay.audioFormat = nil
case "mic_enabled": overlay.micEnabled = nil
case "echo_cancel": overlay.echoCancel = nil
case "keep_host_audio": overlay.keepHostAudio = nil
case "touch_mode": overlay.touchMode = nil
case "mouse_mode": overlay.mouseMode = nil
case "invert_scroll": overlay.invertScroll = nil
@@ -337,6 +342,7 @@ public enum OverlayField {
case "audio_format": return o.audioFormat != nil
case "mic_enabled": return o.micEnabled != nil
case "echo_cancel": return o.echoCancel != nil
case "keep_host_audio": return o.keepHostAudio != nil
case "touch_mode": return o.touchMode != nil
case "mouse_mode": return o.mouseMode != nil
case "invert_scroll": return o.invertScroll != nil
@@ -207,7 +207,8 @@ final class SharedFoundationTests: XCTestCase {
}
/// Resolution order id beats a unique name beats an address plus the two refusals a
/// front-end must surface rather than guess through.
/// front-end must surface rather than guess through, and the rule that keeps a guessable
/// reference from dialing: only the record id resolves to `.known`.
func testDeepLinkHostResolution() throws {
let desk = StoredHost(
id: UUID(uuidString: "11111111-2222-4333-8444-555555555555")!,
@@ -222,14 +223,21 @@ final class SharedFoundationTests: XCTestCase {
XCTAssertEqual(
try resolve("punktfunk://connect/11111111-2222-4333-8444-555555555555"), .known(desk))
XCTAssertEqual(try resolve("punktfunk://connect/desk"), .known(desk))
// The id is a UUID nothing can guess; a display name and a LAN address are guesses any web
// page can make. So the id and only the id dials unattended; everything else that finds
// a saved host stops at the confirmation.
XCTAssertEqual(try resolve("punktfunk://connect/desk"), .confirm(desk))
XCTAssertEqual(try resolve("punktfunk://connect/DESK"), .confirm(desk))
XCTAssertEqual(try resolve("punktfunk://connect/couch"), .ambiguous)
XCTAssertEqual(try resolve("punktfunk://connect/192.168.1.50:9777"), .known(desk))
// A stale id with the recovery parameter: the address finds the record anyway.
XCTAssertEqual(try resolve("punktfunk://connect/192.168.1.50:9777"), .confirm(desk))
XCTAssertEqual(
try resolve("punktfunk://connect/desk?launch=steam:570"), .confirm(desk))
// A stale id with the recovery parameter: the address finds the record anyway and, being
// an address, behind the confirmation exactly as its own doc always said.
XCTAssertEqual(
try resolve(
"punktfunk://connect/00000000-0000-4000-8000-000000000000?host=192.168.1.50"),
.known(desk))
.confirm(desk))
// Nothing local matches: the sheet gets the address, the claimed name and the pin which
// is what makes a first connect verified rather than blind trust-on-first-use.
XCTAssertEqual(
+88 -20
View File
@@ -50,7 +50,7 @@ mod cli {
punktfunk the Punktfunk client, headless
punktfunk discover [--json] [--timeout SECS]
punktfunk pair <host[:port]> [--pin N] [--name LABEL]
punktfunk pair <host[:port]> [--pin N|-] [--name LABEL]
punktfunk hosts list [--probe] [--json]
punktfunk hosts add <host[:port]> [--name LABEL] [--fp HEX]
punktfunk hosts forget <host-ref>
@@ -58,7 +58,7 @@ punktfunk — the Punktfunk client, headless
punktfunk library <host-ref> [--json]
punktfunk launch <host-ref> [--game ID] [--profile REF] [--request-access]
[--exec] [--fullscreen]
punktfunk open <punktfunk://…>
punktfunk open <punktfunk://…> [--yes]
punktfunk reachable <host-ref>
punktfunk speed-test <host-ref>
punktfunk profiles list [--json]
@@ -98,7 +98,10 @@ address with `punktfunk hosts add` and it shows in `hosts list --probe`."
punktfunk pair <host[:port]> enrol this device with a host (PIN ceremony)
--pin N the PIN the host is showing; without it the command asks, and
refuses (exit 6) when there is no terminal to ask on
refuses (exit 6) when there is no terminal to ask on. The value
sits on argv, which every local user can read (/proc/*/cmdline)
--pin - read the PIN from stdin instead (one line) what a script or
another program should use, so the secret never hits argv
--name LABEL the label the host files this device under
(default: this machine's name)
@@ -187,7 +190,12 @@ Same parser and same refusal rules as clicking the link in a shell: a
contradicted fingerprint refuses and says so, an ambiguous name refuses
rather than guessing, and an unknown host is never trusted from a URL
that is a decision for a person, at a surface that can show the fingerprint
(exit 6 points at `punktfunk pair`). --exec as in launch."
(exit 6 points at `punktfunk pair`). --exec as in launch.
A link that names its host by the stable record id opens straight away. One
that names it by label or address is a guess anything could make, so it asks
first; --yes answers for a script, and without a terminal it refuses (exit 6)
rather than opening unasked."
}
"reachable" => {
"\
@@ -272,6 +280,11 @@ from the config directory for a true factory reset."
/// Resolve a host reference the way every other surface does: stable id, then a unique
/// name, then `addr[:port]` (design/client-deep-links.md §2). Sharing `resolve_host` is
/// what keeps `punktfunk launch desk` and `punktfunk://connect/desk` from disagreeing.
///
/// [`HostResolution::Confirm`] — a guessable reference — is accepted WITHOUT a prompt here,
/// and only here: this reference is an argument the user typed in their own terminal, so
/// there is nobody else to confirm it with. The guessable-reference rule exists for URLs
/// handed to us by someone else; that path is `open`, which does ask.
fn resolve(reference: &str) -> Result<(KnownHosts, usize), u8> {
let known = KnownHosts::load();
let link = DeepLink {
@@ -279,7 +292,7 @@ from the config directory for a true factory reset."
..Default::default()
};
match deeplink::resolve_host(&link, &known) {
HostResolution::Known(i) => Ok((known, i)),
HostResolution::Known(i) | HostResolution::Confirm(i) => Ok((known, i)),
HostResolution::Ambiguous => {
eprintln!(
"more than one saved host is called \"{reference}\" — use its address or id"
@@ -459,31 +472,34 @@ from the config directory for a true factory reset."
})
}
/// `pair <host[:port]> [--pin N]` — the SPAKE2 ceremony. Without `--pin` it prompts, which
/// `pair <host[:port]> [--pin N|-]` — the SPAKE2 ceremony. Without `--pin` it prompts, which
/// is the interactive shape; with one it is scriptable. Refuses rather than prompting when
/// stdin isn't a terminal and no PIN was given: a pairing that silently blocks a CI job
/// forever is worse than an exit code.
///
/// `--pin -` reads the PIN from stdin instead. A value on argv is readable by every local
/// user (`/proc/*/cmdline` is world-readable on every distro we target) and the PIN is the
/// only secret binding the ceremony to the operator's intent, so programmatic callers — the
/// Decky backend among them — pipe it in rather than spelling it on the command line.
fn pair(args: &[String]) -> u8 {
let Some(target) = positional(args, 0) else {
eprintln!("usage: punktfunk pair <host[:port]> [--pin N]");
eprintln!("usage: punktfunk pair <host[:port]> [--pin N|-]");
return UNRESOLVED;
};
let (addr, port) = split_host_port(&target);
let pin = match value(args, "--pin") {
Some(p) => p,
let pin = match value(args, "--pin").as_deref() {
Some("-") => read_pin(None),
Some(p) => Some(p.to_string()),
None if is_tty() => read_pin(Some(&addr)),
None => {
if !is_tty() {
eprintln!("no --pin and no terminal to ask on");
return NEEDS_INTERACTION;
}
eprint!("PIN shown on {addr}: ");
let mut line = String::new();
if std::io::stdin().read_line(&mut line).is_err() {
return NEEDS_INTERACTION;
}
line.trim().to_string()
eprintln!("no --pin and no terminal to ask on");
return NEEDS_INTERACTION;
}
};
let Some(pin) = pin else {
eprintln!("no PIN on stdin");
return NEEDS_INTERACTION;
};
let identity = match trust::load_or_create_identity() {
Ok(i) => i,
Err(e) => {
@@ -898,6 +914,31 @@ from the config directory for a true factory reset."
);
match outcome {
Ok(PlanOutcome::Connect(plan)) => run_plan(*plan, has(args, "--exec"), false),
// The link named the host by something GUESSABLE — its label, its address — rather
// than by its record id. A URL handed to us by someone else may not dial on a guess,
// so a person says yes first. `--yes` is the scripted escape (and the only way in
// without a terminal to ask on).
Ok(PlanOutcome::ConfirmConnect(plan)) => {
if !has(args, "--yes") {
if !is_tty() {
eprintln!(
"that link names {} by label or address, not by its id — re-run with \
--yes to open it",
plan.host.name
);
return NEEDS_INTERACTION;
}
eprint!("Connect to {} ({})? [y/N] ", plan.host.name, plan.host.addr);
let mut line = String::new();
if std::io::stdin().read_line(&mut line).is_err()
|| !line.trim().eq_ignore_ascii_case("y")
{
eprintln!("cancelled");
return OK;
}
}
run_plan(*plan, has(args, "--exec"), false)
}
// A URL may never pair or trust on its own — that is a decision for a person, at a
// surface that can show them the fingerprint.
Ok(PlanOutcome::ConfirmUnknown(u)) => {
@@ -1049,7 +1090,11 @@ from the config directory for a true factory reset."
..Default::default()
};
let (addr, port) = match deeplink::resolve_host(&link, &known) {
HostResolution::Known(i) => (known.hosts[i].addr.clone(), known.hosts[i].port),
// Nothing is dialled and no title is launched, so a guessable reference needs no
// confirmation — it only picks which address to send one probe packet to.
HostResolution::Known(i) | HostResolution::Confirm(i) => {
(known.hosts[i].addr.clone(), known.hosts[i].port)
}
_ => split_host_port(&reference),
};
if punktfunk_core::client::NativeClient::probe(&addr, port, PROBE_TIMEOUT) {
@@ -1220,6 +1265,19 @@ from the config directory for a true factory reset."
std::io::IsTerminal::is_terminal(&std::io::stdin())
}
/// One line of PIN from stdin — prompted when we're asking a person, silent for `--pin -`
/// (a pipe from another program). `None` on a read error or an empty line (EOF), which the
/// caller turns into [`NEEDS_INTERACTION`] rather than sending an empty PIN to the host.
fn read_pin(prompt_for: Option<&str>) -> Option<String> {
if let Some(addr) = prompt_for {
eprint!("PIN shown on {addr}: ");
}
let mut line = String::new();
std::io::stdin().read_line(&mut line).ok()?;
let pin = line.trim();
(!pin.is_empty()).then(|| pin.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1250,6 +1308,16 @@ from the config directory for a true factory reset."
Some("10.0.0.1".into())
);
assert_eq!(positional(&argv(&["--json"]), 0), None);
// `--pin -` (the PIN comes down stdin, never argv): the lone dash is that flag's
// VALUE, not the host to pair with.
assert_eq!(
positional(&argv(&["--pin", "-", "desk"]), 0),
Some("desk".into())
);
assert_eq!(
value(&argv(&["desk", "--pin", "-"]), "--pin"),
Some("-".into())
);
}
#[test]
+18 -4
View File
@@ -407,11 +407,17 @@ def _cli_argv() -> list[str] | None:
return [str(sibling)] if sibling.exists() else None
async def _run_cli(args: list[str], timeout: float = 20.0) -> tuple[int, str, str]:
async def _run_cli(
args: list[str], timeout: float = 20.0, stdin_text: str | None = None
) -> tuple[int, str, str]:
"""Run the headless CLI, returning ``(returncode, stdout, stderr)``. SEPARATE pipes: stdout
is the machine interface (JSON/TSV) and stderr carries the log lines, and merging them would
corrupt every payload. ``(-1, "", "")`` when no client is installed or the call times out.
``stdin_text`` is written to the child and the pipe closed the way a secret reaches the CLI,
because argv does not qualify: ``/proc/*/cmdline`` is world-readable, so every process on the
Deck can read a flag's value. See :meth:`Plugin.pair`.
The same ``_flatpak_env`` repair the client runs needed applies here unchanged Decky's
PyInstaller ``LD_LIBRARY_PATH`` leak breaks the flatpak's libcurl whatever binary inside the
sandbox is being started."""
@@ -422,10 +428,12 @@ async def _run_cli(args: list[str], timeout: float = 20.0) -> tuple[int, str, st
try:
proc = await asyncio.create_subprocess_exec(
*prefix, *args,
stdin=asyncio.subprocess.PIPE if stdin_text is not None else None,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
env=_flatpak_env(),
)
out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout)
payload = stdin_text.encode() if stdin_text is not None else None
out, err = await asyncio.wait_for(proc.communicate(payload), timeout=timeout)
rc = proc.returncode if proc.returncode is not None else -1
return (
rc,
@@ -761,21 +769,27 @@ class Plugin:
return await _cli_json(["hosts", "list", "--probe", "--json"], timeout=30.0)
async def pair(self, addr: str, port: int, pin: str, name: str = "Steam Deck") -> dict:
"""The PIN ceremony (``punktfunk pair <addr:port> --pin N --name LABEL``).
"""The PIN ceremony (``punktfunk pair <addr:port> --pin - --name LABEL``).
The operator arms pairing on the host, which shows a 4-digit PIN; entering it here
verifies the host end to end and pins its fingerprint, so every later connect is silent.
``{ok: True}``, or ``{ok: False, error}`` where ``refused`` is a wrong PIN or a host
that isn't armed, and ``unreachable`` is a host that never answered.
The PIN goes down the child's STDIN (``--pin -``), never on argv: it is the only secret
binding the ceremony to the operator's intent, and a value on the command line is readable
by every local process for as long as the call runs (~100 s here) long enough for anyone
on the Deck to complete the pairing with their own keypair instead.
The budget is generous because the ceremony waits on a person at the other end."""
rc, out, err = await _run_cli(
[
"pair", f"{addr}:{int(port)}",
"--pin", str(pin).strip(),
"--pin", "-",
"--name", name,
],
timeout=100.0,
stdin_text=f"{str(pin).strip()}\n",
)
if rc == 0:
fp = ""
+116 -9
View File
@@ -155,6 +155,10 @@ pub enum AppMsg {
ConsoleExited(Option<String>),
/// Request-access Cancel: the child was killed; release busy quietly.
CancelPending,
/// Upload the client log ring to this paired host (`logring::send_to_host`); the
/// outcome lands as a Toast either way. The mgmt port rides along, resolved like
/// OpenLibrary's.
SendLogs(ConnectRequest, Option<u16>),
/// The speed-test dialog resolved (either way) — release `busy`.
SpeedTestDone,
ShowPreferences,
@@ -266,6 +270,7 @@ impl SimpleComponent for AppModel {
HostsOutput::Pair(req) => AppMsg::Pair(req),
HostsOutput::SpeedTest(req) => AppMsg::SpeedTest(req),
HostsOutput::Library(req, mgmt) => AppMsg::OpenLibrary(req, mgmt),
HostsOutput::SendLogs(req, mgmt) => AppMsg::SendLogs(req, mgmt),
HostsOutput::Toast(msg) => AppMsg::Toast(msg),
});
@@ -423,6 +428,44 @@ impl SimpleComponent for AppModel {
}
}
AppMsg::SpeedTest(req) => self.speed_test(req, &sender),
AppMsg::SendLogs(req, mgmt_port) => {
// Blocking network (the library agent's 5 s connect / 10 s global budgets) —
// a worker thread, with the outcome routed back as a Toast. Wording is the
// console's verbatim, so a quoted message means the same thing everywhere.
let identity = self.identity.clone();
let pin = req.fp_hex.as_deref().and_then(trust::parse_hex32);
let mgmt = mgmt_port.unwrap_or(pf_client_core::library::DEFAULT_MGMT_PORT);
self.toast(&format!("Sending logs to {}", req.name));
let out = sender.input_sender().clone();
std::thread::Builder::new()
.name("punktfunk-sendlogs".into())
.spawn(move || {
let header = format!(
"punktfunk-client {} ({} {}) — client log bundle",
env!("CARGO_PKG_VERSION"),
std::env::consts::OS,
std::env::consts::ARCH,
);
let msg = match pf_client_core::logring::send_to_host(
&req.addr, mgmt, &identity, pin, &header,
) {
Ok(id) => {
tracing::info!(host = %req.name, id, "client logs uploaded");
format!(
"Logs sent to {} — download them from its web console's \
Logs page",
req.name
)
}
Err(e) => {
tracing::warn!(host = %req.name, error = %e, "client log upload failed");
format!("Couldn't send logs — {e}")
}
};
let _ = out.send(AppMsg::Toast(msg));
})
.ok();
}
AppMsg::SpeedTestDone => self.busy = false,
AppMsg::OpenLibrary(req, mgmt_port) => {
crate::ui_library::open(self, &sender, req, mgmt_port);
@@ -612,10 +655,10 @@ impl AppModel {
}
/// Route a `punktfunk://` URL (design/client-deep-links.md §4.1). Parsing, host/profile
/// resolution and every refusal rule live in the shared brain (`plan_from_link`); this is
/// only the GTK end of it — turn the outcome into the same messages a card click raises,
/// so a link gets the identical wake, trust and error surfaces and NOT a second connect
/// path of its own.
/// resolution and every refusal rule — including "only a stable record id may dial
/// unattended" — live in the shared brain (`plan_from_link`); this is only the GTK end of
/// it: turn the outcome into the same messages a card click raises, so a link gets the
/// identical wake, trust and error surfaces and NOT a second connect path of its own.
fn open_deep_link(&mut self, url: &str, sender: &ComponentSender<AppModel>) {
use pf_client_core::deeplink;
use pf_client_core::orchestrate::{plan_from_link, PlanOutcome};
@@ -660,6 +703,51 @@ impl AppModel {
AppMsg::Connect(req)
});
}
Ok(PlanOutcome::ConfirmConnect(plan)) => {
// The link named this (saved, pinned) host by its LABEL or its ADDRESS rather
// than by its record id. `x-scheme-handler/punktfunk` is registered by our
// .desktop, so any web page can hand us such a URL and both of those are
// guessable — the dial waits for a person. Deliberately not the PIN ceremony
// below: this host is already pinned, and re-pairing it would throw that away.
if self.busy {
return self.toast("A session is already running — end it first.");
}
let req = ConnectRequest {
name: plan.host.name.clone(),
addr: plan.host.addr.clone(),
port: plan.host.port,
fp_hex: plan.host.fp_hex.clone(),
pair_optional: false,
launch: plan.launch.clone().map(|id| (id.clone(), id)),
mac: plan.host.mac.clone(),
profile: plan.profile_override.clone(),
};
let mut body = format!("A link asks to connect to {} ({}).", req.name, req.addr);
if let Some((id, _)) = &req.launch {
body.push_str(&format!("\n\nIt also asks the host to launch “{id}”."));
}
body.push_str(
"\n\nIt names the host by its label or address, which anything that can \
open a link could guess. A link that names the host's id connects without \
asking.",
);
let dialog = adw::AlertDialog::new(Some("Open this link?"), Some(&body));
dialog.add_responses(&[("cancel", "Cancel"), ("connect", "Connect")]);
dialog.set_response_appearance("connect", adw::ResponseAppearance::Suggested);
dialog.set_close_response("cancel");
let sender = sender.clone();
let wake = plan.wake;
dialog.connect_response(Some("connect"), move |_, _| {
// The same two messages the `Connect` arm raises, so the confirmed link
// gets the identical wake / trust / error surfaces a card click gets.
sender.input(if wake {
AppMsg::WakeConnect(req.clone())
} else {
AppMsg::Connect(req.clone())
});
});
dialog.present(Some(&self.window));
}
Ok(PlanOutcome::ConfirmUnknown(unknown)) => {
// Known-but-unpinned, or not known at all: the link may not pair and may not
// trust on its own, so it opens the ordinary ceremony under the user's eyes —
@@ -945,11 +1033,30 @@ fn clear_steam_sdl_device_filter() {
}
pub fn run() -> glib::ExitCode {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
)
.init();
// The fmt layer as before, plus the in-process ring (`pf_client_core::logring`, DEBUG+ regardless
// of RUST_LOG) that "Send logs to host" uploads — the env filter scopes the stderr layer
// only, because the ring exists precisely for the diagnostics nobody enabled before the
// bug happened. The spawned session's own stderr joins the ring too (`orchestrate` pipes
// it through `logring::forward_child_stderr`), so a bundle from this shell carries the
// stream's trail, not just the launcher's.
{
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::Layer;
tracing_subscriber::registry()
.with(
// The default (stdout) writer, exactly as `fmt().init()` had it.
tracing_subscriber::fmt::layer().with_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info".into()),
),
)
.with(
pf_client_core::logring::RingLayer
.with_filter(tracing_subscriber::filter::LevelFilter::DEBUG),
)
.init();
}
// Steam launches its shortcuts with SDL_GAMECONTROLLER_IGNORE_DEVICES naming every
// physical pad Steam Input has virtualized; the Settings controller list needs the
// real devices (same rationale as the session binary).
+22
View File
@@ -94,6 +94,8 @@ pub enum CardOutput {
Pair(ConnectRequest),
SpeedTest(ConnectRequest),
Library(ConnectRequest),
/// Upload this device's recent log ring to the host (`logring::send_to_host`).
SendLogs(ConnectRequest),
/// Open the host edit sheet (name, profile binding, pinned cards, clipboard).
Edit {
fp_hex: String,
@@ -333,6 +335,13 @@ impl relm4::factory::FactoryComponent for HostCard {
Box::new(move || CardOutput::Library(req.clone())),
);
}
{
let req = req.clone();
add(
"send-logs",
Box::new(move || CardOutput::SendLogs(req.clone())),
);
}
{
let (fp, name) = (k.fp_hex.clone(), k.name.clone());
add(
@@ -520,6 +529,13 @@ impl relm4::factory::FactoryComponent for HostCard {
look.append(Some("Browse library\u{2026}"), Some("card.library"));
}
look.append(Some("Test network speed\u{2026}"), Some("card.speed"));
// The same row the console's host menu carries, on the same gate: the
// upload authenticates with the paired identity, and an offline host
// could only ever toast an error. The bundle lands on the host's web
// console (Logs page) beside the host's own log.
if k.paired && *online {
look.append(Some("Send logs to host"), Some("card.send-logs"));
}
// An explicit wake only when offline and a MAC is known.
if !online && !k.mac.is_empty() {
look.append(Some("Wake host"), Some("card.wake"));
@@ -729,6 +745,8 @@ pub enum HostsOutput {
SpeedTest(ConnectRequest),
/// With the advertised mgmt port when a live advert carries one.
Library(ConnectRequest, Option<u16>),
/// With the mgmt port resolved the same way as [`HostsOutput::Library`]'s.
SendLogs(ConnectRequest, Option<u16>),
}
impl SimpleComponent for HostsPage {
@@ -1020,6 +1038,10 @@ impl SimpleComponent for HostsPage {
let mgmt = self.mgmt_port_for(&req);
let _ = sender.output(HostsOutput::Library(req, mgmt));
}
CardOutput::SendLogs(req) => {
let mgmt = self.mgmt_port_for(&req);
let _ = sender.output(HostsOutput::SendLogs(req, mgmt));
}
CardOutput::Edit { fp_hex, name } => self.edit_host_dialog(&sender, &fp_hex, &name),
CardOutput::Forget { fp_hex, name } => self.forget_dialog(&sender, &fp_hex, &name),
CardOutput::Wake { mac, addr } => crate::wol::wake(&mac, addr.parse().ok()),
+35
View File
@@ -640,6 +640,9 @@ fn commit_profile(active: &StreamProfile, touched: &Touched, values: &Settings)
if touched.has("enable_444") {
o.enable_444 = Some(values.enable_444);
}
if touched.has("ten_bit_sdr") {
o.ten_bit_sdr = Some(values.ten_bit_sdr);
}
if touched.has("compositor") {
o.compositor = Some(values.compositor.clone());
}
@@ -649,6 +652,9 @@ fn commit_profile(active: &StreamProfile, touched: &Touched, values: &Settings)
if touched.has("audio_format") {
o.audio_format = Some(values.audio_format.clone());
}
if touched.has("keep_host_audio") {
o.keep_host_audio = Some(values.keep_host_audio);
}
if touched.has("mic_enabled") {
o.mic_enabled = Some(values.mic_enabled);
}
@@ -1281,6 +1287,13 @@ pub fn show_scoped(
only, and only where the host can encode it.",
)
.build();
let ten_bit_sdr_row = adw::SwitchRow::builder()
.title("10-bit SDR")
.subtitle(
"Smoother gradients without HDR \u{2014} 10-bit encoding precision. Needs an \
NVIDIA host; HDR takes over when it engages.",
)
.build();
let decoder_row = ChoiceRow::new(
&dialog,
inline,
@@ -1466,6 +1479,10 @@ pub fn show_scoped(
w.set_sensitive(surround_row.selected() == 0);
surround_row.connect_changed(move |i| w.set_sensitive(i == 0));
}
let keep_host_audio_row = adw::SwitchRow::builder()
.title("Keep host audio playing")
.subtitle("The host's speakers or headphones keep playing while you stream — needs a host on 0.32+")
.build();
let mic_row = adw::SwitchRow::builder()
.title("Stream microphone")
.subtitle("Sends your microphone to the host's virtual mic — Ctrl+Alt+Shift+V mutes it mid-stream")
@@ -1711,10 +1728,12 @@ pub fn show_scoped(
wake_row.set_active(s.auto_wake);
inhibit_row.set_active(s.inhibit_shortcuts);
invert_row.set_active(s.invert_scroll);
keep_host_audio_row.set_active(s.keep_host_audio);
mic_row.set_active(s.mic_enabled);
echo_row.set_active(s.echo_cancel);
hdr_row.set_active(s.hdr_enabled);
chroma_row.set_active(s.enable_444);
ten_bit_sdr_row.set_active(s.ten_bit_sdr);
surround_row.set_selected(index::surround(s));
audio_format_row.set_selected(index::audio_format(s));
// `set_selected` never fires the changed hook, so mirror the stereo gate here — the same
@@ -1970,6 +1989,12 @@ pub fn show_scoped(
toggle!(vrr_row, "allow_vrr", o.allow_vrr.is_some(), allow_vrr);
toggle!(hdr_row, "hdr_enabled", o.hdr_enabled.is_some(), hdr_enabled);
toggle!(chroma_row, "enable_444", o.enable_444.is_some(), enable_444);
toggle!(
ten_bit_sdr_row,
"ten_bit_sdr",
o.ten_bit_sdr.is_some(),
ten_bit_sdr
);
toggle!(
fullscreen_row,
"fullscreen_on_stream",
@@ -1988,6 +2013,12 @@ pub fn show_scoped(
o.invert_scroll.is_some(),
invert_scroll
);
toggle!(
keep_host_audio_row,
"keep_host_audio",
o.keep_host_audio.is_some(),
keep_host_audio
);
toggle!(mic_row, "mic_enabled", o.mic_enabled.is_some(), mic_enabled);
toggle!(
echo_row,
@@ -2059,6 +2090,7 @@ pub fn show_scoped(
quality_group.add(codec_row.widget());
quality_group.add(&hdr_row);
quality_group.add(&chroma_row);
quality_group.add(&ten_bit_sdr_row);
// Decoder and GPU are facts about THIS device's hardware — never per profile (tier G).
if !profile_mode {
quality_group.add(decoder_row.widget());
@@ -2097,6 +2129,7 @@ pub fn show_scoped(
let audio_group = group("", "Applies from the next session.");
audio_group.add(surround_row.widget());
audio_group.add(audio_format_row.widget());
audio_group.add(&keep_host_audio_row);
// The speaker/mic endpoint pickers below are this device's audio routing (tier G) — they
// render only in the defaults scope; the surround/format + mic-uplink rows above are
// profileable.
@@ -2242,10 +2275,12 @@ pub fn show_scoped(
if want_speaker != pf_client_core::pad_audio::speaker_active(&s.pad_speaker) {
s.pad_speaker = if want_speaker { "pad" } else { "off" }.to_string();
}
s.keep_host_audio = keep_host_audio_row.is_active();
s.mic_enabled = mic_row.is_active();
s.echo_cancel = echo_row.is_active();
s.hdr_enabled = hdr_row.is_active();
s.enable_444 = chroma_row.is_active();
s.ten_bit_sdr = ten_bit_sdr_row.is_active();
s.audio_channels = match surround_row.selected() {
1 => 6,
2 => 8,
-5
View File
@@ -39,11 +39,6 @@ punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] }
serde_json = { version = "1", optional = true }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# The log ring normalizes `log`-crate events off the bridge's "log" shim target
# (`ring_layer.rs`) so the vendored decoder's per-frame DEBUG chatter can be gated by its real
# module path; both are already in the graph through tracing-subscriber's default features.
tracing-log = "0.2"
log = "0.4"
# This crate carries NO toolkit, deliberately: it is the renderer the shells spawn, and the
# `--no-default-features` build is what a minimal/embedded image installs. GTK4/libadwaita/relm4
+24 -5
View File
@@ -23,7 +23,6 @@
#[cfg(all(any(target_os = "linux", windows), feature = "ui"))]
mod console;
mod ring_layer;
/// The session control socket: a line-per-connection unix socket other same-user
/// processes use to poke the RUNNING stream — today two verbs, `guide` and `qam`, which
@@ -370,6 +369,22 @@ mod session_main {
to (PyroWave carries 4:4:4 on any GPU, if the link can take it)."
);
}
// …and the HDR promise, same discipline: `VIDEO_CAP_HDR` invites a PQ stream, and
// a Windows box with no HDR10 swapchain whose video processor cannot tone-map
// PQ→sRGB shows that stream as garbage — the D3D11VA Blt accepts the colorspaces
// and renders green where the conversion is missing (Arc A370M field report,
// 2026-08-26). `ten_bit_sdr` is deliberately NOT gated on this: a 10-bit SDR
// stream is no tonemap, and every hardware rung decodes P010.
let hdr_enabled =
settings.hdr_enabled && pf_client_core::video::hdr_presentable(vulkan.as_ref());
if settings.hdr_enabled && !hdr_enabled {
tracing::warn!(
"HDR requested but this device cannot present a PQ stream (no HDR10 \
swapchain, and the video processor reports no PQsRGB conversion) \
asking for SDR instead. Advertising it would paint the stream green: \
the driver accepts the tonemap it cannot do and renders garbage."
);
}
SessionParams {
host: addr,
port,
@@ -415,7 +430,11 @@ mod session_main {
// 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: pf_client_core::video::video_caps_for(settings.hdr_enabled, want_444),
video_caps: pf_client_core::video::video_caps_for(
hdr_enabled,
settings.ten_bit_sdr,
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
@@ -424,8 +443,7 @@ mod session_main {
// defaults; `PUNKTFUNK_CLIENT_PEAK_NITS` (read in the session pump) pins one
// manually on either OS and wins over both.
#[cfg(windows)]
display_hdr: settings
.hdr_enabled
display_hdr: hdr_enabled
.then(|| pf_client_core::video_d3d11::display_hdr_volume(window_pos()))
.flatten(),
#[cfg(not(windows))]
@@ -443,6 +461,7 @@ mod session_main {
pad_haptics: settings.pad_haptics,
pad_speaker: settings.pad_speaker.clone(),
clipboard,
keep_host_audio: settings.keep_host_audio,
// The Settings preference (auto → VAAPI where it exists; the presenter
// demotes to software on boxes whose Vulkan can't import the dmabufs).
// PUNKTFUNK_DECODER still overrides inside the decoder for bisects.
@@ -642,7 +661,7 @@ mod session_main {
),
)
.with(
crate::ring_layer::RingLayer
pf_client_core::logring::RingLayer
.with_filter(tracing_subscriber::filter::LevelFilter::DEBUG),
)
.init();
-144
View File
@@ -1,144 +0,0 @@
//! Thin `tracing` layer feeding `pf_client_core::logring` — the source for the console's
//! "Send logs to host" action. Captures at DEBUG+ regardless of `RUST_LOG` (its own filter is
//! applied at install), mirroring the host's `log_capture::RingLayer`: the whole point is that
//! a field report carries the diagnostics nobody thought to enable beforehand.
//!
//! …which is exactly why it also has to keep OUT the chatter that would evict them. The ring
//! holds 4096 lines. The vendored H.265 parser (`cros_codecs`, behind `pf-bitstream`) DEBUG-logs
//! its DPB bookkeeping — "Retaining pic POC", "Stored picture", "Set reference", "Bumping POC",
//! one `find_short_term_ref_by_poc` per reference — a dozen lines PER FRAME, so at 120 fps the
//! ring turns over in about three seconds. The 2026-08-17 field bundle from a Steam Deck read
//! `… 2037456 older lines evicted from the ring …` followed by 3.5 s of DPB chatter: the whole
//! 27-minute session, including the 10 s `audio playback buffer_ms= underruns=` line three
//! investigation rounds had been waiting for, was gone. A field ring that a healthy decoder can
//! flush is worse than no ring, because it looks like diagnostics and carries none.
use std::fmt::Write as _;
use tracing::field::{Field, Visit};
use tracing_subscriber::layer::Context;
/// Targets whose DEBUG/TRACE output is steady-state per-frame chatter, not diagnostics. The ring
/// keeps their INFO-and-up. Prefix-matched on module-path boundaries, so `cros_codecs::codec::…`
/// is gated and a hypothetical `cros_codecs_probe` is not. Same shape as the host's
/// `log_capture::NOISY_DEBUG_TARGETS`.
const NOISY_DEBUG_TARGETS: &[&str] = &["cros_codecs"];
fn is_noisy_debug(target: &str) -> bool {
NOISY_DEBUG_TARGETS.iter().any(|t| {
target
.strip_prefix(t)
.is_some_and(|rest| rest.is_empty() || rest.starts_with("::"))
})
}
pub(crate) struct RingLayer;
impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for RingLayer {
fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
// Events from `log`-crate dependencies (the vendored decoder among them) arrive through
// the tracing-log bridge under the shim target "log", with the record's real module path
// tucked into `log.target=`. Normalize back to the real metadata so the noise gate below
// and the target column both see `cros_codecs::…` — under the shim target every bridged
// event is indistinguishable from every other, and the field bundle's target column read
// `log` for two million lines.
use tracing_log::NormalizeEvent;
let normalized = event.normalized_metadata();
let meta = normalized.as_ref().unwrap_or_else(|| event.metadata());
if *meta.level() > tracing::Level::INFO && is_noisy_debug(meta.target()) {
return;
}
struct V(String);
impl Visit for V {
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
if field.name() == "message" {
// The message leads; fields follow. Events put it first anyway, so
// this is belt-and-braces against odd macro orderings.
let rest = std::mem::take(&mut self.0);
let _ = write!(self.0, "{value:?}");
self.0.push_str(&rest);
} else if !field.name().starts_with("log.") {
// `log.target`/`log.module_path`/`log.file`/`log.line` are the bridge's own
// bookkeeping — already surfaced through the normalized target above, and
// 150 bytes of repeated path per line otherwise.
let _ = write!(self.0, " {}={:?}", field.name(), value);
}
}
}
let mut v = V(String::new());
event.record(&mut v);
pf_client_core::logring::note(format!(
"{} {:5} {} {}",
pf_client_core::logring::wallclock(),
meta.level().as_str(),
meta.target(),
v.0
));
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The gate is a prefix match on module-path boundaries, nothing looser.
#[test]
fn noisy_gate_matches_the_crate_and_its_modules_only() {
assert!(is_noisy_debug("cros_codecs"));
assert!(is_noisy_debug("cros_codecs::codec::h265::dpb"));
assert!(!is_noisy_debug("cros_codecs_probe"));
assert!(!is_noisy_debug("pf_bitstream::h265"));
assert!(!is_noisy_debug("pf_client_core::audio"));
}
/// End to end through the bridge: a `log::debug!` from the vendored decoder's module path
/// must NOT reach the ring, its `warn!` must (under its real target, without the bridge's
/// bookkeeping fields), and a DEBUG event from our own audio module — the very line the gate
/// exists to protect — must land.
///
/// The ring is process-global, so the assertions look for lines this test wrote (unique
/// markers) rather than at the ring's size, and the subscriber is installed only for the
/// duration of the test.
#[test]
fn bridged_decoder_debug_is_dropped_and_the_audio_line_survives() {
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::Layer;
let sub = tracing_subscriber::registry()
.with(RingLayer.with_filter(tracing_subscriber::filter::LevelFilter::DEBUG));
// The bridge may already be installed by another test in this binary; either way the
// `log` max level has to admit DEBUG for the planted records to be dispatched at all.
let _ = tracing_log::LogTracer::builder()
.with_max_level(log::LevelFilter::Debug)
.init();
log::set_max_level(log::LevelFilter::Debug);
let _guard = tracing::subscriber::set_default(sub);
let marker = format!("ringgate-{}", std::process::id());
log::debug!(target: "cros_codecs::codec::h265::dpb", "Retaining pic POC {marker}-dpb: true");
log::warn!(target: "cros_codecs::codec::h265::parser", "{marker}-parser-warn");
tracing::debug!(target: "pf_client_core::audio", buffer_ms = 15u32, "audio playback {marker}-audio");
let text = pf_client_core::logring::render("test");
assert!(
!text.contains(&format!("{marker}-dpb")),
"decoder DPB DEBUG chatter must not reach the ring"
);
let warn_line = text
.lines()
.find(|l| l.contains(&format!("{marker}-parser-warn")))
.expect("decoder WARN must be kept");
assert!(
warn_line.contains("cros_codecs::codec::h265::parser"),
"bridged events must carry their real target, not the `log` shim: {warn_line}"
);
assert!(
!warn_line.contains("log.target="),
"bridge bookkeeping fields must be dropped: {warn_line}"
);
let audio_line = text
.lines()
.find(|l| l.contains(&format!("{marker}-audio")))
.expect("our own DEBUG audio line must survive");
assert!(audio_line.contains("pf_client_core::audio"));
assert!(audio_line.contains("buffer_ms=15"));
}
}
+57
View File
@@ -15,6 +15,11 @@ use windows_reactor::*;
const MENU_CONNECT: &str = "Connect";
const MENU_LIBRARY: &str = "Browse library\u{2026}";
const MENU_SPEED: &str = "Test network speed\u{2026}";
/// Upload this device's recent log ring to the host (`logring::send_to_host`), where the web
/// console's Logs page lists it beside the host's own log. Paired + online only — the same
/// gate as the console UI's row, because the upload authenticates with the paired identity
/// and an offline host could only ever report an error.
const MENU_SEND_LOGS: &str = "Send logs to host";
const MENU_WAKE: &str = "Wake host";
/// One entry for every per-host property (name, address, MAC, clipboard sharing) — the
/// Apple client's add/edit sheet. A menu item per field read as clutter and buried the ones
@@ -758,6 +763,10 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
items.push(menu_item(MENU_LIBRARY));
}
items.push(menu_item(MENU_SPEED));
// See [`MENU_SEND_LOGS`] for the gate.
if k.paired && online {
items.push(menu_item(MENU_SEND_LOGS));
}
// An explicit wake only when the host is offline and we have a MAC.
if can_wake {
items.push(menu_item(MENU_WAKE));
@@ -848,6 +857,54 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
svc.set_screen.call(Screen::Library);
}
MENU_WAKE => crate::wol::wake(&target.mac, target.addr.parse().ok()),
MENU_SEND_LOGS => {
// Blocking network (the library agent's 5 s connect / 10 s global
// budgets) — a worker thread, with the outcome routed to the
// status line. Wording is the console's verbatim, so a quoted
// message means the same thing everywhere.
let identity = svc.ctx.identity.clone();
let target = target.clone();
let set_status = svc.set_status.clone();
set_status.call(format!("Sending logs to {}", target.name));
let _ = std::thread::Builder::new()
.name("punktfunk-sendlogs".into())
.spawn(move || {
let header = format!(
"punktfunk-client {} ({} {}) — client log bundle",
env!("CARGO_PKG_VERSION"),
std::env::consts::OS,
std::env::consts::ARCH,
);
let pin = target
.fp_hex
.as_deref()
.and_then(crate::trust::parse_hex32);
let mgmt = target
.mgmt_port
.unwrap_or(pf_client_core::library::DEFAULT_MGMT_PORT);
let msg = match pf_client_core::logring::send_to_host(
&target.addr,
mgmt,
&identity,
pin,
&header,
) {
Ok(id) => {
tracing::info!(host = %target.name, id, "client logs uploaded");
format!(
"Logs sent to {} — download them from its web \
console's Logs page",
target.name
)
}
Err(e) => {
tracing::warn!(host = %target.name, error = %e, "client log upload failed");
format!("Couldn't send logs — {e}")
}
};
set_status.call(msg);
});
}
MENU_SPEED => {
*svc.ctx.shared.target.lock().unwrap() = target.clone();
// New run: invalidate any still-in-flight probe, reset the screen.
+127 -52
View File
@@ -326,6 +326,13 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
// later instance over WM_COPYDATA) and this poll pulls them onto the UI thread. Thread-fed
// state must be root state, like the pad count below.
let (deep_link, set_deep_link) = cx.use_async_state(Option::<String>::None);
// A link that named its host by something GUESSABLE (its label, its address, the `host=`
// recovery parameter) rather than by the stable record id: `Some(plan)` arms the "Open this
// link?" confirmation built at the bottom of this function. The plan is byte-for-byte the one
// an id-referenced link carries, so confirming runs the identical dial one click later. Root
// state like every other dialog flag in this shell.
let (link_confirm, set_link_confirm) =
cx.use_async_state(Option::<Box<pf_client_core::orchestrate::ConnectPlan>>::None);
cx.use_effect((), {
let set_deep_link = set_deep_link.clone();
move || {
@@ -363,16 +370,18 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
let (library, set_library) = cx.use_async_state(library::LibraryState::default());
// Continuous LAN discovery (spawned once).
// Route an arriving link. Parsing, host and profile resolution and every refusal rule live
// in the shared brain (`plan_from_link`); this is only the WinUI end — turn the outcome into
// the same call a tile click makes, so a link gets the identical wake, trust and error
// surfaces rather than a second connect path of its own.
// Route an arriving link. Parsing, host and profile resolution and every refusal rule
// including "only a stable record id may dial unattended" — live in the shared brain
// (`plan_from_link`); this is only the WinUI end — turn the outcome into the same call a tile
// click makes, so a link gets the identical wake, trust and error surfaces rather than a
// second connect path of its own.
cx.use_effect(deep_link.clone(), {
let (ctx, set_screen, set_status, set_deep_link) = (
let (ctx, set_screen, set_status, set_deep_link, set_link_confirm) = (
ctx.clone(),
set_screen.clone(),
set_status.clone(),
set_deep_link.clone(),
set_link_confirm.clone(),
);
let screen_now = screen.clone();
move || {
@@ -403,41 +412,16 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
);
use pf_client_core::orchestrate::PlanOutcome;
match plan {
Ok(PlanOutcome::Connect(p)) => {
let target = Target {
name: p.host.name.clone(),
addr: p.host.addr.clone(),
port: p.host.port,
fp_hex: p.host.fp_hex.clone(),
pair_optional: false,
mac: p.host.mac.clone(),
mgmt_port: p.host.mgmt_port,
profile: p.profile_override.clone(),
launch: None, // routed explicitly below (initiate_launch*)
};
// With a MAC it takes the dial first wake path, so a sleeping host wakes
// instead of erroring — exactly what clicking its tile would do. The
// link's `launch=` id must reach the session (`--launch`) — this arm used
// to drop it, so a game link opened a plain desktop session.
match (p.launch.clone(), p.wake && !target.mac.is_empty()) {
(Some(id), true) => {
connect::initiate_launch_waking(
&ctx,
target,
id,
&set_screen,
&set_status,
);
}
(Some(id), false) => {
connect::initiate_launch(&ctx, target, id, &set_screen, &set_status);
}
(None, true) => {
connect::initiate_waking(&ctx, target, &set_screen, &set_status)
}
(None, false) => connect::initiate(&ctx, target, &set_screen, &set_status),
}
}
Ok(PlanOutcome::Connect(p)) => dial_link(&ctx, &p, &set_screen, &set_status),
// The link named a saved, pinned host by its LABEL or its ADDRESS rather than
// by its record id. This app registers the `punktfunk` scheme (AppxManifest's
// windows.protocol / the installer's URL Protocol key), so any web page can
// hand us such a URL, and both of those references are guessable — it may not
// dial on its own. Arm the confirmation instead; OK runs `dial_link` on the
// very same plan, so the confirmed link and an id-referenced one are one code
// path. Deliberately NOT the PIN ceremony below: this host is already pinned,
// and re-pairing it would throw that pin away.
Ok(PlanOutcome::ConfirmConnect(p)) => set_link_confirm.call(Some(p)),
// Known but never pinned, or not known at all: a link may not pair and may not
// trust on its own, so it opens the ordinary PIN ceremony seeded with what the
// link CLAIMED — name shown as claimed, the fingerprint pre-filling the pin so
@@ -729,19 +713,110 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
Screen::Stream => stream::session_page(ctx, &hud),
};
// The "Open this link?" confirmation for a guessable-reference link (see `link_confirm`).
// It lives at ROOT, not on a page: a link can arrive over WM_COPYDATA while any screen is
// up, and a WinUI ContentDialog is a popup rather than a visual child, so it rides above
// whatever is showing. Same discipline as the shell's other dialogs — ALWAYS MOUNTED, with
// `is_open` doing the arming, in a stable trailing slot (unmounting a ContentDialog trips
// the reactor backend's phantom-child bookkeeping; see hosts.rs's forget confirmation).
let link_dialog: Element = {
let pending = link_confirm;
// Name the host AND the game, because that is the whole point of asking: it's what
// lets someone tell their own shortcut from a link a web page just handed them.
let content = pending
.as_ref()
.map(|p| {
let mut s = format!(
"A link asks to connect to {} ({}).",
p.host.name, p.host.addr
);
if let Some(id) = &p.launch {
s.push_str(&format!(
"\n\nIt also asks the host to launch \u{201c}{id}\u{201d}."
));
}
s.push_str(
"\n\nIt names the host by its label or address, which anything that can open \
a link could guess. Shortcuts made in Punktfunk name the host's id and \
connect without asking.",
);
s
})
.unwrap_or_default();
let (ctx2, ss, st, sc) = (
ctx.clone(),
set_screen.clone(),
set_status.clone(),
set_link_confirm.clone(),
);
ContentDialog::new("Open this link?")
.content(content)
.primary_button_text("Connect")
.close_button_text("Cancel")
.is_open(pending.is_some())
.on_closed(move |r: ContentDialogResult| {
sc.call(None);
// Cancel (and Escape, which WinUI also reports as `None`) does nothing at all.
if r == ContentDialogResult::Primary
&& let Some(plan) = &pending
{
dial_link(&ctx2, plan, &ss, &st);
}
})
.into()
};
// The Stream screen is a plain status card (the session child owns the real stream window);
// it's shown without the navigation entrance tween. Everything else slides + fades in.
if matches!(screen, Screen::Stream) {
return body;
let page: Element = if matches!(screen, Screen::Stream) {
body
} else {
let offset = (1.0 - progress) * 22.0;
border(body)
.opacity(progress)
.margin(Thickness {
left: 0.0,
top: offset,
right: 0.0,
bottom: 0.0,
})
.into()
};
grid(vec![page, link_dialog]).into()
}
/// Run a resolved link plan: the same four calls a host tile's click makes, so a link gets the
/// identical wake, trust and error surfaces rather than a second connect path of its own. Shared
/// by the two outcomes that dial — `PlanOutcome::Connect` (the link named the stable record id)
/// and a confirmed `PlanOutcome::ConfirmConnect` — so the confirmation is one click in front of
/// this, never a second implementation of it.
fn dial_link(
ctx: &Arc<AppCtx>,
plan: &pf_client_core::orchestrate::ConnectPlan,
set_screen: &AsyncSetState<Screen>,
set_status: &AsyncSetState<String>,
) {
let target = Target {
name: plan.host.name.clone(),
addr: plan.host.addr.clone(),
port: plan.host.port,
fp_hex: plan.host.fp_hex.clone(),
pair_optional: false,
mac: plan.host.mac.clone(),
mgmt_port: plan.host.mgmt_port,
profile: plan.profile_override.clone(),
launch: None, // routed explicitly below (initiate_launch*)
};
// With a MAC it takes the dial first wake path, so a sleeping host wakes instead of
// erroring — exactly what clicking its tile would do. The link's `launch=` id must reach
// the session (`--launch`) — this used to drop it, so a game link opened a plain desktop
// session.
match (plan.launch.clone(), plan.wake && !target.mac.is_empty()) {
(Some(id), true) => {
connect::initiate_launch_waking(ctx, target, id, set_screen, set_status);
}
(Some(id), false) => connect::initiate_launch(ctx, target, id, set_screen, set_status),
(None, true) => connect::initiate_waking(ctx, target, set_screen, set_status),
(None, false) => connect::initiate(ctx, target, set_screen, set_status),
}
let offset = (1.0 - progress) * 22.0;
border(body)
.opacity(progress)
.margin(Thickness {
left: 0.0,
top: offset,
right: 0.0,
bottom: 0.0,
})
.into()
}
+33
View File
@@ -487,9 +487,11 @@ struct OverrideFlags {
codec: bool,
hdr_enabled: bool,
enable_444: bool,
ten_bit_sdr: bool,
compositor: bool,
audio_channels: bool,
audio_format: bool,
keep_host_audio: bool,
mic_enabled: bool,
echo_cancel: bool,
touch_mode: bool,
@@ -523,9 +525,11 @@ impl OverrideFlags {
codec: o.codec.is_some(),
hdr_enabled: o.hdr_enabled.is_some(),
enable_444: o.enable_444.is_some(),
ten_bit_sdr: o.ten_bit_sdr.is_some(),
compositor: o.compositor.is_some(),
audio_channels: o.audio_channels.is_some(),
audio_format: o.audio_format.is_some(),
keep_host_audio: o.keep_host_audio.is_some(),
mic_enabled: o.mic_enabled.is_some(),
echo_cancel: o.echo_cancel.is_some(),
touch_mode: o.touch_mode.is_some(),
@@ -919,6 +923,9 @@ pub(crate) fn settings_page(
let hdr_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.hdr_enabled, |s, on| {
s.hdr_enabled = on
});
let ten_bit_sdr_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.ten_bit_sdr, |s, on| {
s.ten_bit_sdr = on
});
let chroma_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.enable_444, |s, on| {
s.enable_444 = on
});
@@ -1066,6 +1073,10 @@ pub(crate) fn settings_page(
let format_combo = setting_combo(ctx, scope, (rev, set_rev), af_names, af_i, |s, i| {
s.audio_format = AUDIO_FORMATS[i].0.to_string();
});
let keep_host_audio_toggle =
setting_toggle(ctx, scope, (rev, set_rev), s.keep_host_audio, |s, on| {
s.keep_host_audio = on
});
let mic_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.mic_enabled, |s, on| {
s.mic_enabled = on
});
@@ -1231,6 +1242,17 @@ pub(crate) fn settings_page(
bandwidth. Requires an NVIDIA host (NVENC) or the PyroWave \
codec \u{2014} other encoders stream 4:2:0.",
),
described_overridable(
(rev, set_rev),
scope,
"ten_bit_sdr",
"10-bit SDR",
over.ten_bit_sdr,
ten_bit_sdr_toggle,
"Smoother gradients without HDR \u{2014} the picture is encoded at \
10-bit precision. Needs an NVIDIA host; HDR takes over when it \
engages.",
),
],
None,
));
@@ -1541,6 +1563,17 @@ pub(crate) fn settings_page(
rate; the stats overlay names what the session actually got.",
)
}),
Some(described_overridable(
(rev, set_rev),
scope,
"keep_host_audio",
"Keep host audio playing",
over.keep_host_audio,
keep_host_audio_toggle,
"The host\u{2019}s own speakers or headphones keep playing while you \
stream \u{2014} both ends hear the same audio. Needs a host on 0.32 \
or newer.",
)),
// The endpoint picks are facts about THIS device's hardware — never
// per profile, like Decoder/GPU.
(!profile_mode)
+9 -23
View File
@@ -21,32 +21,18 @@
#[allow(dead_code)]
mod logfile;
// The hand-off itself, verbatim the shell's `--console` flag — including the CREATE_NO_WINDOW
// the console-subsystem session binary needs from a GUI parent. Kept in one file precisely
// because the two copies of this hand-off had already drifted apart on that flag.
#[cfg(windows)]
#[path = "../couch.rs"]
#[allow(dead_code)]
mod couch;
#[cfg(windows)]
fn main() {
logfile::init();
// The session binary ships beside us in the package; fall back to PATH for a dev run.
let session = std::env::current_exe()
.ok()
.map(|e| e.with_file_name("punktfunk-session.exe"))
.filter(|p| p.exists())
.unwrap_or_else(|| "punktfunk-session".into());
let mut cmd = std::process::Command::new(session);
cmd.arg("--browse");
if !std::env::args().any(|a| a == "--windowed") {
cmd.arg("--fullscreen");
}
cmd.stderr(std::process::Stdio::piped());
let run = cmd.spawn().and_then(|mut child| {
if let Some(stderr) = child.stderr.take() {
logfile::forward_child_stderr(stderr);
}
child.wait()
});
match run {
Ok(st) => std::process::exit(st.code().unwrap_or(0)),
Err(_) => std::process::exit(1),
}
couch::run_browse();
}
/// The workspace builds on Linux/macOS too; there is nothing to launch there.
+67
View File
@@ -0,0 +1,67 @@
//! The couch/HTPC hand-off: run the session binary's `--browse` mode (the complete
//! controller-driven client — host list, discovery, PIN pairing, settings, Wake-on-LAN,
//! library) and mirror its exit code.
//!
//! Shared by BOTH couch entry points — `punktfunk-console.exe`, which needs its own
//! executable because an MSIX `<Application>` cannot pass arguments, and this shell's
//! `--console` flag — so the spawn flags below are stated once. They were stated in neither
//! until 2026-08-27, which is why both Start-menu tiles opened a black console window that
//! then sat behind the couch UI for the whole session.
use std::path::PathBuf;
use std::process::{Command, Stdio};
/// The session binary: installed next to us (the MSIX layout and dev `target\…` runs both
/// land on the sibling), else `PATH`.
pub(crate) fn session_binary() -> PathBuf {
if let Ok(exe) = std::env::current_exe() {
let sibling = exe.with_file_name("punktfunk-session.exe");
if sibling.exists() {
return sibling;
}
}
"punktfunk-session".into()
}
/// Run `punktfunk-session --browse` (fullscreen unless `--windowed`) and exit with the
/// child's code, so whatever supervises this process sees the real result. Never returns.
pub(crate) fn run_browse() -> ! {
use std::os::windows::process::CommandExt as _;
// `punktfunk-session` keeps the CONSOLE subsystem for its stdout contract, and both couch
// entry points are GUI processes with no console to lend it — so without this flag Windows
// mints one, and the couch UI comes up in front of a black terminal window.
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
let mut cmd = Command::new(session_binary());
cmd.arg("--browse");
// A couch UI is fullscreen unless explicitly told otherwise.
if !std::env::args().any(|a| a == "--windowed") {
cmd.arg("--fullscreen");
}
cmd.stdin(Stdio::null())
// Nothing here parses the stdout contract (no `--json-status`), but `match_window`
// reports the settled window size on stdout REGARDLESS — and with no console the
// handle it would inherit is invalid, which panics the child mid-stream on the first
// report. A sink that goes nowhere is the difference between quiet and a crash.
.stdout(Stdio::null())
// Piped through the log tee: a couch launch (Start-menu tile, Steam shortcut) has no
// console either, so the session's whole receive/decode/present log would otherwise
// evaporate exactly when a user hits something worth reporting.
.stderr(Stdio::piped())
.creation_flags(CREATE_NO_WINDOW);
// Spawn (not `status()`) so the stderr pipe can be drained into the client log.
let run = cmd.spawn().and_then(|mut child| {
if let Some(stderr) = child.stderr.take() {
crate::logfile::forward_child_stderr(stderr);
}
child.wait()
});
match run {
Ok(st) => std::process::exit(st.code().unwrap_or(0)),
Err(e) => {
eprintln!("could not start the console UI: {e}");
std::process::exit(1);
}
}
}
+5 -2
View File
@@ -146,8 +146,10 @@ impl Write for Tee {
}
/// Forward a spawned child's stderr into the [`Tee`], line-buffered so its lines never
/// interleave mid-line with the shell's own. Returns immediately; the thread dies with the
/// pipe (child exit).
/// interleave mid-line with the shell's own — and into the client log ring, so a "Send logs
/// to host" bundle from this shell carries the session's whole receive/decode/present trail
/// (the file half of exactly that rationale is this module's opening doc). Returns
/// immediately; the thread dies with the pipe (child exit).
pub(crate) fn forward_child_stderr(stderr: impl io::Read + Send + 'static) {
let _ = std::thread::Builder::new()
.name("punktfunk-session-log".into())
@@ -157,6 +159,7 @@ pub(crate) fn forward_child_stderr(stderr: impl io::Read + Send + 'static) {
let mut tee = Tee;
while matches!(reader.read_line(&mut line), Ok(n) if n > 0) {
let _ = tee.write_all(line.as_bytes());
pf_client_core::logring::note(line.trim_end().to_string());
line.clear();
}
});
+29 -29
View File
@@ -22,6 +22,9 @@
#[cfg(windows)]
mod app;
// The `--console` couch hand-off, shared verbatim with `punktfunk-console.exe`.
#[cfg(windows)]
mod couch;
// `punktfunk://` activation: single instance, hand-off, and the positional URL parse
// (design/client-deep-links.md §4.2).
#[cfg(windows)]
@@ -63,14 +66,32 @@ fn main() {
// Everything logs to stderr AND `%LOCALAPPDATA%\punktfunk\logs\client.log` (see [`logfile`]):
// a GUI/MSIX launch has no console, so without the file the client side of any field report
// simply doesn't exist. ANSI off — the file is what users send, keep it grep-clean.
// Plus the in-process ring (`pf_client_core::logring`, DEBUG+ regardless of RUST_LOG) that
// "Send logs to host" uploads — the env filter scopes the visible layer only: the ring
// exists precisely for the diagnostics nobody enabled before the bug happened. The spawned
// session's stderr joins the ring in `logfile::forward_child_stderr`, so a bundle carries
// the stream's trail too.
logfile::init();
tracing_subscriber::fmt()
.with_ansi(false)
.with_writer(logfile::tee)
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
)
.init();
{
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::Layer;
tracing_subscriber::registry()
.with(
tracing_subscriber::fmt::layer()
.with_ansi(false)
.with_writer(logfile::tee)
.with_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info".into()),
),
)
.with(
pf_client_core::logring::RingLayer
.with_filter(tracing_subscriber::filter::LevelFilter::DEBUG),
)
.init();
}
if let Some(p) = logfile::path() {
tracing::info!(path = %p.display(), "client log file (rotated at 10 MB, one .old kept)");
}
@@ -114,28 +135,7 @@ fn main() {
// pairing, settings and Wake-on-LAN, all controller-driven. We just exec it and mirror
// its exit code, so anything supervising this process sees the real result.
if flag("--console") {
let mut cmd = std::process::Command::new(spawn::session_binary());
cmd.arg("--browse");
// A couch UI is fullscreen unless explicitly told otherwise.
if !flag("--windowed") {
cmd.arg("--fullscreen");
}
// Spawn (not `status()`) so the session's stderr rides the log tee — a couch launch
// (Start-menu tile, Steam shortcut) has no console to inherit either.
cmd.stderr(std::process::Stdio::piped());
let run = cmd.spawn().and_then(|mut child| {
if let Some(stderr) = child.stderr.take() {
logfile::forward_child_stderr(stderr);
}
child.wait()
});
match run {
Ok(st) => std::process::exit(st.code().unwrap_or(0)),
Err(e) => {
eprintln!("could not start the console UI: {e}");
std::process::exit(1);
}
}
couch::run_browse();
}
// Windowed (default): the WinUI 3 app owns host selection, settings, and pairing.
+3 -12
View File
@@ -7,6 +7,9 @@
//! `{"ready":true}`, banner from the `{"error"|"ended": …}` line, `trust_rejected`
//! routed to the re-pair PIN ceremony, `stats:` lines to the session status page.
// The session binary's location: ONE resolver, shared with the couch entry points
// (`crate::couch`), which the standalone `punktfunk-console.exe` bin includes by path.
use crate::couch::session_binary;
use std::io::BufRead as _;
use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex};
@@ -116,18 +119,6 @@ pub(crate) fn silent_exit_banner(code: i32) -> Option<String> {
})
}
/// The session binary: installed next to the shell (the MSIX layout and dev
/// `target\…` runs both land on the sibling), else `PATH`.
pub(crate) fn session_binary() -> std::path::PathBuf {
if let Ok(exe) = std::env::current_exe() {
let sibling = exe.with_file_name("punktfunk-session.exe");
if sibling.exists() {
return sibling;
}
}
"punktfunk-session".into()
}
/// Spawn the session binary for a connect with `fp_hex` pinned and feed its lifecycle to
/// `on_event` from a reader thread. The child is parked in `slot` so Disconnect/Cancel
/// can kill it. `launch` carries a library title id for the host to launch during the
+52
View File
@@ -1187,4 +1187,56 @@ mod tests {
Some(PlanError::NoFrame)
);
}
/// A truncated access unit never panics the decode thread.
///
/// `plan_au` degrades every malformation it knows about to [`PlanWarning::TruncatedAu`]
/// or [`PlanError`], and `pf-vkdecode` re-validates OBU ranges on top — but the AV1
/// `obu_size` bound lives in the vendored parser, and until PROVENANCE.md deviation 14
/// it was missing: an AU cut mid-OBU leaves a final OBU declaring more payload than
/// remains, and the unchecked slice aborted the calling thread. That reaches all three
/// native rungs, which re-export this planner, and is exactly the shape
/// `PUNKTFUNK_AU_FAULT=truncate` injects.
///
/// The contract asserted here is the crate's stated posture, not a specific verdict:
/// a short AU is a plan error or a warning, and whatever plans do come back stay
/// inside the bytes handed in.
#[test]
fn a_truncated_access_unit_is_a_plan_error_not_a_panic() {
let mut planned = 0usize;
let mut rejected = 0usize;
for packet in IvfIterator::new(AV1_25FPS).take(12) {
for denom in [2usize, 3, 4, 8] {
let cut = packet.len() - packet.len() / denom;
// A fresh planner per cut: the claim is that a short unit fails cleanly on
// its own terms, not that a planner carries state across one.
let mut planner = Av1Planner::new();
match planner.plan_au(&packet[..cut]) {
Ok(plans) => {
planned += 1;
for plan in &plans {
for tile in &plan.tiles {
assert!(
tile.data.start <= tile.data.end && tile.data.end <= cut,
"tile range {:?} escapes a {cut}-byte truncated unit",
tile.data
);
}
}
}
Err(_) => rejected += 1,
}
}
}
assert!(
planned + rejected == 48,
"every cut must reach a verdict; got {planned} planned + {rejected} rejected"
);
assert!(
rejected > 0,
"no truncated unit was rejected - the test proves nothing"
);
}
}
+174 -12
View File
@@ -1921,19 +1921,15 @@ mod tests {
}
}
fn synth_sps(o: &SpsOpts) -> Vec<u8> {
let mut s = BitSink::new();
s.bits(4, 0); // sps_video_parameter_set_id
s.bits(3, 0); // sps_max_sub_layers_minus1
s.bit(1); // sps_temporal_id_nesting_flag
// profile_tier_level(1, 0): general_profile_space u(2), tier u(1),
// profile_idc u(5), 32 compatibility flags, progressive/interlaced/
// non-packed/frame-only, 43 constraint/reserved bits (all zero for every
// profile branch the parser takes), inbld/reserved bit, level u(8).
/// profile_tier_level()'s general block: general_profile_space u(2), tier u(1),
/// profile_idc u(5), 32 compatibility flags, progressive/interlaced/non-packed/
/// frame-only, 43 constraint/reserved bits (all zero for every profile branch the
/// parser takes), inbld/reserved bit, level u(8). The per-sub-layer tail follows
/// only when max_sub_layers_minus1 > 0.
fn ptl_general(s: &mut BitSink, profile_idc: u8, level_idc: u32) {
s.bits(2, 0);
s.bit(0);
s.bits(5, u32::from(o.profile_idc));
s.bits(5, u32::from(profile_idc));
s.bits(32, 0);
s.bit(1); // general_progressive_source_flag
s.bit(0); // general_interlaced_source_flag
@@ -1942,7 +1938,15 @@ mod tests {
s.bits(31, 0);
s.bits(12, 0); // 43 zero bits total
s.bit(0); // general_inbld_flag / reserved
s.bits(8, o.level_idc); // general_level_idc
s.bits(8, level_idc); // general_level_idc
}
fn synth_sps(o: &SpsOpts) -> Vec<u8> {
let mut s = BitSink::new();
s.bits(4, 0); // sps_video_parameter_set_id
s.bits(3, 0); // sps_max_sub_layers_minus1
s.bit(1); // sps_temporal_id_nesting_flag
ptl_general(&mut s, o.profile_idc, o.level_idc);
s.ue(0); // sps_seq_parameter_set_id
s.ue(o.chroma_format_idc);
@@ -3579,4 +3583,162 @@ mod tests {
assert!(plan.picture.is_idr);
assert!(plan.warnings.is_empty(), "{:?}", plan.warnings);
}
// ------- vendored-parser bounds regressions (deviations 9-11) -------
const VPS_NUT: u8 = 32;
const SPS_NUT: u8 = 33;
const PPS_NUT: u8 = 34;
/// Trailing bits, so the parser keeps reading past the guarded field instead of
/// stopping short — a truncated NALU would be an error for the wrong reason.
fn padded(mut s: BitSink) -> Vec<u8> {
for _ in 0..64 {
s.bits(8, 0xff);
}
s.finish()
}
/// Deviation 9: `{vps,sps}_max_sub_layers_minus1` is u(3), so 7 is representable,
/// but 7.4.3.1/7.4.3.2 stop at 6 — and the sub-layer arrays the parser then walks
/// are six and seven deep. Both param sets are now a parse error, not a panic.
/// This is also what keeps the planner's own
/// `max_num_reorder_pics[max_sub_layers_minus1]` reads in bounds.
#[test]
fn a_param_set_claiming_eight_sub_layers_is_a_parse_error_not_a_panic() {
let mut s = BitSink::new();
s.bits(4, 0); // vps_video_parameter_set_id
s.bit(1); // vps_base_layer_internal_flag
s.bit(1); // vps_base_layer_available_flag
s.bits(6, 0); // vps_max_layers_minus1
s.bits(3, 7); // vps_max_sub_layers_minus1 — one past the spec's 6
s.bit(1); // vps_temporal_id_nesting_flag
s.bits(16, 0xffff); // vps_reserved_0xffff_16bits
ptl_general(&mut s, 1, 120);
let vps = h265_nalu(VPS_NUT, &padded(s));
assert!(matches!(
H265Planner::new().plan_au(&vps),
Err(PlanError::Parse(_))
));
let mut s = BitSink::new();
s.bits(4, 0); // sps_video_parameter_set_id
s.bits(3, 7); // sps_max_sub_layers_minus1
s.bit(1); // sps_temporal_id_nesting_flag
ptl_general(&mut s, 1, 120);
let sps = h265_nalu(SPS_NUT, &padded(s));
assert!(matches!(
H265Planner::new().plan_au(&sps),
Err(PlanError::Parse(_))
));
}
/// The PPS fields ahead of the two this section attacks, all zero.
fn pps_prefix() -> BitSink {
let mut s = BitSink::new();
s.ue(0); // pps_pic_parameter_set_id
s.ue(0); // pps_seq_parameter_set_id
s.bits(2, 0); // dependent_slice_segments_enabled / output_flag_present
s.bits(3, 0); // num_extra_slice_header_bits
s.bits(2, 0); // sign_data_hiding_enabled / cabac_init_present
s.ue(0); // num_ref_idx_l0_default_active_minus1
s.ue(0); // num_ref_idx_l1_default_active_minus1
s.se(0); // init_qp_minus26
s.bits(3, 0); // constrained_intra_pred / transform_skip / cu_qp_delta_enabled
s.se(0); // pps_cb_qp_offset
s.se(0); // pps_cr_qp_offset
s.bits(4, 0); // chroma_qp_offsets / weighted_pred / weighted_bipred / bypass
s
}
/// The PPS fields after the tile block, all zero, plus rbsp_trailing_bits().
fn pps_tail(mut s: BitSink) -> Vec<u8> {
s.bits(2, 0); // loop_filter_across_slices / deblocking_filter_control_present
s.bits(2, 0); // pps_scaling_list_data_present / lists_modification_present
s.ue(0); // log2_parallel_merge_level_minus2
s.bits(2, 0); // slice_segment_header_extension / pps_extension_present
s.finish()
}
/// Deviation 10: equation 7-42 subtracts `scaling_list_pred_matrix_id_delta` from
/// matrixId in u32. Unbounded, it underflows into an out-of-bounds read of the
/// six-entry scaling lists; 7.4.5 caps it at matrixId / (sizeId == 3 ? 3 : 1).
#[test]
fn a_scaling_list_predicting_from_a_negative_matrix_is_a_parse_error_not_a_panic() {
let mut s = pps_prefix();
s.bits(2, 0); // tiles_enabled / entropy_coding_sync_enabled
s.bits(2, 0); // loop_filter_across_slices / deblocking_filter_control_present
s.bit(1); // pps_scaling_list_data_present_flag
s.bit(0); // scaling_list_pred_mode_flag[0][0]
s.ue(1); // scaling_list_pred_matrix_id_delta[0][0] — refMatrixId = 0 - 1
let mut au = synth_sps(&SpsOpts::default());
au.extend(h265_nalu(PPS_NUT, &padded(s)));
assert!(matches!(
H265Planner::new().plan_au(&au),
Err(PlanError::Parse(_))
));
}
/// Deviation 11: the tile counts were bounded by the picture's CTB size only, so a
/// wide-enough SPS let them run past the width and height arrays. Table A.8 caps
/// them at 20 columns and 22 rows for every level.
#[test]
fn a_pps_with_more_tiles_than_any_level_allows_is_a_parse_error_not_a_panic() {
// 2048x2048 luma with 64x64 CTBs: 32 CTBs each way, so the picture bound
// alone would admit 31 tile columns and rows.
let sps = SpsOpts {
width: 2048,
height: 2048,
..Default::default()
};
for (columns, rows) in [(25, 0), (0, 25)] {
let mut s = pps_prefix();
s.bit(1); // tiles_enabled_flag
s.bit(0); // entropy_coding_sync_enabled_flag
s.ue(columns); // num_tile_columns_minus1
s.ue(rows); // num_tile_rows_minus1
s.bit(1); // uniform_spacing_flag
let mut au = synth_sps(&sps);
au.extend(h265_nalu(PPS_NUT, &padded(s)));
assert!(
matches!(H265Planner::new().plan_au(&au), Err(PlanError::Parse(_))),
"{columns} columns / {rows} rows must be refused"
);
}
}
/// The tile ceiling's two downstream sites, both reached from one slice header:
/// the entry-point maximum multiplied the two tile counts in u8 (20 x 22 overflows
/// it), and `entry_point_offset_minus1` is 32 deep however large that maximum is.
#[test]
fn a_slice_claiming_more_entry_points_than_the_header_holds_is_a_parse_error_not_a_panic() {
let sps = SpsOpts {
width: 2048,
height: 2048,
..Default::default()
};
let mut s = pps_prefix();
s.bit(1); // tiles_enabled_flag
s.bit(0); // entropy_coding_sync_enabled_flag
s.ue(19); // num_tile_columns_minus1 — Table A.8's ceiling, and legal here
s.ue(21); // num_tile_rows_minus1
s.bit(1); // uniform_spacing_flag
s.bit(0); // loop_filter_across_tiles_enabled_flag
let mut au = synth_sps(&sps);
au.extend(h265_nalu(PPS_NUT, &pps_tail(s)));
let mut s = BitSink::new();
s.bit(1); // first_slice_segment_in_pic_flag
s.bit(0); // no_output_of_prior_pics_flag
s.ue(0); // slice_pic_parameter_set_id
s.ue(2); // slice_type: I
s.se(0); // slice_qp_delta
s.ue(35); // num_entry_point_offsets — 440 tiles would allow it, 32 slots do not
au.extend(h265_nalu(IDR_W_RADL, &padded(s)));
assert!(matches!(
H265Planner::new().plan_au(&au),
Err(PlanError::Parse(_))
));
}
}
+131
View File
@@ -105,5 +105,136 @@ in the future."
AV1 has its own `read_su`, and the H.26x `se(v)` callers all pass positive widths — so
it is left to upstream rather than widened into this deviation.
9. `src/codec/h265/parser.rs``parse_vps` and `parse_sps`: reject
`{vps,sps}_max_sub_layers_minus1 > 6` immediately after the read. The element is
`u(3)`, so 7 is representable, but 7.4.3.1 and 7.4.3.2 both bound it at 6 — and
every array the parser then walks with it is sized for the spec, not for the field:
`profile_tier_level()`'s `sub_layer_*` flags are `[_; 6]` and the sub-layer ordering
arrays are `[_; 7]`. A ~20-byte VPS NALU with the field set to 7 panicked inside
`parse_profile_tier_level` with an index-out-of-bounds before any picture was
decoded, on every reconnect. Bound at the spec's 6, not at the arrays' 5/6, because
the two agree there — a conformant stream is never refused. This is also what keeps
punktfunk's own `sps.max_num_reorder_pics[max_sub_layers_minus1]` and
`sps.max_dec_pic_buffering_minus1[max_sub_layers_minus1]` reads in bounds
(`pf-bitstream` h265.rs, and the `pf-vaadec` / `pf-dxvadec` / `pf-vkdecode` picture
builders downstream of it) — they all take their `Sps` from this parser, so the
parse-time check is the single choke point and none of them needs its own guard.
Regression-tested in `pf-bitstream`
(`a_param_set_claiming_eight_sub_layers_is_a_parse_error_not_a_panic`).
**Report upstream — not yet filed.**
10. `src/codec/h265/parser.rs``parse_scaling_list_data`: read
`scaling_list_pred_matrix_id_delta` with `read_ue_max(matrixId / factor)` instead of
an unbounded `read_ue`, which is exactly the range 7.4.5 permits (`0` to
`matrixId / ( sizeId == 3 ? 3 : 1 )`). Equation 7-42 subtracts
`delta * factor` from `matrixId` in `u32`: unbounded it underflows — a debug panic
on the subtraction, and in release a `~4e9` index into the six-entry
`scaling_list_{4x4,8x8,16x16,32x32}`. Reachable from a PPS with
`pps_scaling_list_data_present_flag` set, or the equivalent SPS flag. `factor` moved
a few lines up so the bound and equation 7-42 share one definition; the same bound
also makes `delta * factor` unable to overflow. Regression-tested in `pf-bitstream`
(`a_scaling_list_predicting_from_a_negative_matrix_is_a_parse_error_not_a_panic`).
**Report upstream — not yet filed.**
11. `src/codec/h265/parser.rs` — the tile syntax, three edits, all one defect. `parse_pps`
bounded `num_tile_{columns,rows}_minus1` by the picture only
(`pic_{width,height}_in_ctbs_y - 1`, which reaches 2110 on a legal SPS) while using
them to index `column_width_minus1` / `row_height_minus1`. A PPS on a 2048x2048 SPS
asking for 26 tile columns panicked. Annex A is the real bound: A.4.1 requires
`num_tile_columns_minus1 < MaxTileCols` and `num_tile_rows_minus1 < MaxTileRows`,
and Table A.8 peaks at 20 and 22 (levels 6, 6.1, 6.2). So:
- new `MAX_TILE_COLUMNS` / `MAX_TILE_ROWS` consts (20, 22), and the counts are read
with `min(picture bound, const - 1)`;
- the arrays grew from `[u32; 19]` / `[u32; 21]` to those consts. Upstream sized them
one short of Table A.8 — the code stores the running remainder in
`column_width_minus1[num_tile_columns_minus1]`, so the last tile needs a slot —
which means bounding at the arrays would have refused a conformant 20-column
stream. Growing by one entry each costs nothing and lets the guard be the spec's
number rather than an implementation artefact. The upstream test asserting
`[0; 19]` / `[0; 21]` follows the consts now.
Raising the ceiling to the spec's exposed two further panics downstream, in
`parse_slice_header`'s entry-point block (both reachable before this change too, at
the arrays' old 19x21 ceiling):
- the `num_entry_point_offsets` maximum computed
`(num_tile_columns_minus1 + 1) * (num_tile_rows_minus1 + 1) - 1` in `u8`, which
overflows above 256 tiles — 20x22 is 440. Widened to `u32`, matching the sibling
branch two lines down;
- `num_entry_point_offsets` was then bounded by that maximum while
`entry_point_offset_minus1` is `[u32; 32]`, so a slice claiming 35 entry points
indexed past it. Clamped to the array, the same way deviation 7 handles the
long-term arrays. 7.4.7.1 puts no 32-entry cap on the element, so this refuses a
conformant stream with more than 32 entry points — notably 4K wavefront
(`entropy_coding_sync_enabled_flag`) streams, which carry one offset per CTB row.
An error beats a panic, but the real fix is upstream sizing that array from the
stream.
Regression-tested in `pf-bitstream`
(`a_pps_with_more_tiles_than_any_level_allows_is_a_parse_error_not_a_panic`,
`a_slice_claiming_more_entry_points_than_the_header_holds_is_a_parse_error_not_a_panic`).
**Report upstream — not yet filed.**
12. `src/codec/av1/parser.rs``parse_tile_info`: the two non-uniform tile loops
(`uniform_tile_spacing_flag == 0`) run until `start_sb` reaches `sb_cols` / `sb_rows`
while filling `width_in_sbs_minus_1` / `height_in_sbs_minus_1`, which are
`MAX_TILE_COLS` / `MAX_TILE_ROWS` (64) deep. Each iteration advances `start_sb` by at
least one superblock, so a frame wide or tall enough — 4096 mi columns is 256
superblocks — walks 256 entries into a 64-entry array. The uniform branch already
checks `tile_cols > MAX_TILE_COLS` after the fact and is genuinely bounded before it
(`tile_cols_log2 <= max_log2_tile_cols <= 6`); the non-uniform branch had neither.
Guarded at the top of each loop body, returning the same
`"Invalid tile_{cols,rows} {n}"` the uniform branch does. 64 is the spec's own
ceiling (`MAX_TILE_COLS` / `MAX_TILE_ROWS` in 3, and a conformance requirement on
`TileCols` / `TileRows` in 5.11.1), so it is both the array bound and the legal one.
Regression-tested in the file's own test module
(`more_non_uniform_tiles_than_the_spec_allows_is_a_parse_error_not_a_panic`).
**Report upstream — not yet filed.**
13. `src/codec/h264/parser.rs``parse_sps`: reject a picture whose macroblock count
overflows `u32`, with the `checked_mul` idiom the frame-crop validation a few lines
below already uses. `max_dpb_frames()` computes
`max_dpb_mbs / (width_mb * height_mb)` (A.3.1); both dimensions are `ue(v)` read into
`u16`, so each reaches 65536 macroblocks and their product reaches 2^33. In debug
that is a multiply-overflow panic, in release it wraps — 65536 x 65536 wraps to
exactly zero — and the division that follows panics on a zero divisor. `max_dpb_frames()`
returns `usize`, not `Result`, and the DPB and `max_num_order_frames()` both call it,
so the check belongs at the parse boundary where an `Err` is available. Bounded at
the arithmetic limit rather than Table A-1's `MaxFS`: the level tables are the only
range H.264 gives these elements, this parser enforces no other level conformance at
parse time, and hardware decoders routinely accept a stream whose level_idc
understates its resolution. Regression-tested in the file's own test module
(`a_picture_whose_macroblock_count_overflows_is_a_parse_error_not_a_panic`).
**Report upstream — not yet filed.**
14. `src/codec/av1/parser.rs``read_obu`: bound `obu_size` against the buffer before it is
used to slice. `obu_size` is a leb128 read out of the stream (`read_leb128()? as usize`,
so anything up to `u32::MAX`) and nothing ties it to the bytes actually present; the OBU
was then built with an unchecked `&data[start_offset..start_offset + obu_size]`. Any
access unit whose last OBU declares more payload than remains — a truncated AU, or simply
an over-declared size — panicked with `range end index .. out of range for slice of length
..`. That is a bounds check, not arithmetic, so it panics in release too (the workspace
leaves `overflow-checks` off, which is why the parser's other unchecked accumulations
merely wrap), and it aborts whichever thread is decoding.
Blast radius is every native AV1 rung: `pf-vkdecode`, `pf-dxvadec` and `pf-vaadec` are all
re-exports of `pf_bitstream::av1::Av1Planner`, whose `plan_au` hands raw access-unit bytes
straight to this function. Reachable from the project's own `PUNKTFUNK_AU_FAULT=truncate`
injector — whose `FaultMode::Truncate` docs reason only about Annex-B, where a NALU carries
no length, while AV1 OBUs do — and from any AU delivered short over the wire.
This was a gap in an otherwise consistent posture rather than a missing idea: `plan_au`
degrades every *other* malformation to `PlanWarning::TruncatedAu` or `PlanError::Parse`,
and `pf-vkdecode` re-validates `obu.end > au.len()` one layer up. Guarded with
`checked_add` plus a length compare, returning the same `String` error the rest of the
parser uses; the computed end is reused for `bytes_used` so the slice and the advance can
no longer disagree. Regression-tested in the file's own test module
(`an_obu_declaring_more_bytes_than_are_present_is_a_parse_error_not_a_panic`, which
reproduces the original panic exactly when the guard is reverted) and at the planner
boundary in `pf-bitstream`
(`av1::tests::a_truncated_access_unit_is_a_plan_error_not_a_panic`).
**Not filed upstream.**
Re-sync procedure: fetch the AOSP tree, re-apply this trim, diff `codec/` +
`bitstream_utils.rs` (expect near-zero conflicts), update the commit pin above.
@@ -1850,10 +1850,25 @@ impl Parser {
assert!(reader.0.position() % 8 == 0);
let start_offset: usize = (reader.0.position() / 8).try_into().unwrap();
// `obu_size` was read off the wire as a leb128 and is bounded only by `u32::MAX`; nothing
// ties it to how many bytes are actually present. Bound it against the buffer BEFORE it is
// used to slice, or a truncated (or simply over-declared) OBU panics with `range end index
// .. out of range` — an abort of whatever thread is decoding. See PROVENANCE.md deviation 14.
let obu_end = start_offset
.checked_add(obu_size)
.ok_or::<String>("obu_size overflows the access unit offset".into())?;
if obu_end > data.len() {
return Err(format!(
"obu_size {} overruns the access unit: {} bytes present after the OBU header",
obu_size,
data.len().saturating_sub(start_offset)
));
}
log::debug!(
"Identified OBU type {:?}, data size: {}, obu_size: {}",
header.obu_type,
start_offset + obu_size,
obu_end,
obu_size
);
@@ -1872,8 +1887,8 @@ impl Parser {
Ok(ObuAction::Process(Obu {
header,
data: Cow::from(&data[start_offset..start_offset + obu_size]),
bytes_used: start_offset + obu_size,
data: Cow::from(&data[start_offset..obu_end]),
bytes_used: obu_end,
}))
}
@@ -2361,6 +2376,10 @@ impl Parser {
let mut i = 0;
while start_sb < sb_cols {
if i >= MAX_TILE_COLS {
return Err(format!("Invalid tile_cols {}", i + 1));
}
self.mi_col_starts[i] = start_sb << sb_shift;
let max_width = std::cmp::min(sb_cols - start_sb, max_tile_width_sb);
@@ -2387,6 +2406,10 @@ impl Parser {
let mut start_sb = 0;
let mut i = 0;
while start_sb < sb_rows {
if i >= MAX_TILE_ROWS {
return Err(format!("Invalid tile_rows {}", i + 1));
}
self.mi_row_starts[i] = start_sb << sb_shift;
let max_height = std::cmp::min(sb_rows - start_sb, max_tile_height_sb);
ti.height_in_sbs_minus_1[i] = r.read_ns(max_height.try_into().unwrap())?;
@@ -4291,4 +4314,80 @@ mod tests {
}
}
}
/// punktfunk deviation 12: the non-uniform tile loops are bounded by the frame's
/// superblock count, not by the 64-entry `width_in_sbs_minus_1` /
/// `height_in_sbs_minus_1` they fill, so a frame made of one-superblock tiles
/// walked off both. MAX_TILE_COLS / MAX_TILE_ROWS are the spec's own ceiling.
#[test]
fn more_non_uniform_tiles_than_the_spec_allows_is_a_parse_error_not_a_panic() {
use crate::codec::av1::parser::{SequenceHeaderObu, TileInfo};
use crate::codec::av1::reader::Reader;
use std::rc::Rc;
// All zeroes: uniform_tile_spacing_flag = 0, then every ns() read decodes to
// a one-superblock tile.
let data = [0u8; 128];
// 4096 mi columns is 256 superblocks, so the column loop runs 256 times.
let mut parser = Parser::default();
parser.sequence_header = Some(Rc::new(SequenceHeaderObu::default()));
parser.mi_cols = 4096;
parser.mi_rows = 4096;
let err = parser
.parse_tile_info(&mut Reader::new(&data), &mut TileInfo::default())
.unwrap_err();
assert!(err.starts_with("Invalid tile_cols"), "{err}");
// Four superblocks wide: the column loop finishes, the row loop overruns.
let mut parser = Parser::default();
parser.sequence_header = Some(Rc::new(SequenceHeaderObu::default()));
parser.mi_cols = 64;
parser.mi_rows = 4096;
let err = parser
.parse_tile_info(&mut Reader::new(&data), &mut TileInfo::default())
.unwrap_err();
assert!(err.starts_with("Invalid tile_rows"), "{err}");
}
/// An OBU whose declared `obu_size` runs past the bytes present is a parse error,
/// not a panic (PROVENANCE.md deviation 14).
///
/// `obu_size` is a leb128 read straight out of the stream and bounded only by
/// `u32::MAX`; nothing ties it to the length of the buffer handed in. Cutting a real
/// access unit mid-OBU therefore leaves a final OBU declaring more payload than
/// remains, and the unchecked slice used to abort the calling thread with
/// `range end index .. out of range for slice of length ..`.
#[test]
fn an_obu_declaring_more_bytes_than_are_present_is_a_parse_error_not_a_panic() {
let mut overruns = 0usize;
for packet in IvfIterator::new(STREAM_TEST_25_FPS).take(8) {
// Three cuts per unit so the walk is guaranteed to land inside an OBU
// rather than exactly on a boundary.
for denom in [2usize, 3, 4] {
let cut = packet.len() - packet.len() / denom;
let mut parser = Parser::default();
let mut consumed = 0usize;
while consumed < cut {
match parser.read_obu(&packet[..cut][consumed..]) {
Ok(ObuAction::Process(obu)) => consumed += obu.bytes_used,
Ok(ObuAction::Drop(n)) => consumed += usize::try_from(n).unwrap(),
Err(e) => {
if e.contains("overruns the access unit") {
overruns += 1;
}
break;
}
}
}
}
}
assert!(
overruns > 0,
"no cut reached the obu_size bound - the test proves nothing"
);
}
}
@@ -2102,6 +2102,13 @@ impl Parser {
sps.pic_height_in_map_units_minus1 = r.read_ue()?;
sps.frame_mbs_only_flag = r.read_bit()?;
// max_dpb_frames() divides MaxDpbMbs by the frame's macroblock count (A.3.1).
// ue(v) admits 65536 macroblocks in each direction, and the u32 product of the
// two wraps to zero long before that.
let _ = (sps.width() / 16)
.checked_mul(sps.height() / 16)
.ok_or::<String>("Invalid picture size in macroblocks".into())?;
if !sps.frame_mbs_only_flag {
sps.mb_adaptive_frame_field_flag = r.read_bit()?;
}
@@ -3059,4 +3066,43 @@ mod tests {
assert_eq!(MaxLongTermFrameIdx::Idx(24), 24);
assert!(MaxLongTermFrameIdx::Idx(24) < 25);
}
/// punktfunk deviation 13: `max_dpb_frames()` divides MaxDpbMbs by the frame's
/// macroblock count (A.3.1), a u32 product that wraps to zero at the widest
/// picture `ue(v)` admits — 65536 x 65536 macroblocks is exactly 2^32. The SPS is
/// refused at parse time now, so the division always has a divisor.
#[test]
fn a_picture_whose_macroblock_count_overflows_is_a_parse_error_not_a_panic() {
use crate::codec::h264::nalu_writer::NaluWriter;
let mut buf = Vec::<u8>::new();
{
let mut w = NaluWriter::new(&mut buf, true);
w.write_header(3, 7).unwrap(); // nal_ref_idc = 3, SPS
w.write_u(8, 66u32).unwrap(); // profile_idc: Baseline, so no chroma block
w.write_u(8, 0u32).unwrap(); // constraint flags + reserved_zero_2bits
w.write_u(8, 51u32).unwrap(); // level_idc: 5.1
w.write_ue(0u32).unwrap(); // seq_parameter_set_id
w.write_ue(0u32).unwrap(); // log2_max_frame_num_minus4
w.write_ue(2u32).unwrap(); // pic_order_cnt_type: 2, nothing follows
w.write_ue(1u32).unwrap(); // max_num_ref_frames
w.write_f(1, 0u32).unwrap(); // gaps_in_frame_num_value_allowed_flag
w.write_ue(65535u32).unwrap(); // pic_width_in_mbs_minus1
w.write_ue(65535u32).unwrap(); // pic_height_in_map_units_minus1
w.write_f(1, 1u32).unwrap(); // frame_mbs_only_flag
w.write_f(1, 1u32).unwrap(); // direct_8x8_inference_flag
w.write_f(1, 0u32).unwrap(); // frame_cropping_flag
w.write_f(1, 0u32).unwrap(); // vui_parameters_present_flag
w.write_f(1, 1u32).unwrap(); // rbsp_stop_one_bit
while !w.aligned() {
w.write_f(1, 0u32).unwrap();
}
}
let mut cursor = Cursor::new(buf.as_slice());
let nalu = Nalu::next(&mut cursor).unwrap();
assert!(matches!(nalu.header.type_, NaluType::Sps));
let err = Parser::default().parse_sps(&nalu).unwrap_err();
assert!(err.starts_with("Invalid picture size"), "{err}");
}
}
@@ -42,6 +42,11 @@ const MAX_SHORT_TERM_REF_PIC_SETS: usize = 65;
// 7.4.3.2.1:
const MAX_LONG_TERM_REF_PIC_SETS: usize = 32;
// Table A.8: MaxTileCols and MaxTileRows peak at 20 and 22 at level 6.2, and A.4.1
// makes those a bitstream conformance requirement for every level.
const MAX_TILE_COLUMNS: usize = 20;
const MAX_TILE_ROWS: usize = 22;
// From table 7-5.
const DEFAULT_SCALING_LIST_0: [u8; 16] = [16; 16];
@@ -1239,10 +1244,10 @@ pub struct Pps {
pub uniform_spacing_flag: bool,
/// `column_width_minus1[ i ]` plus 1 specifies the width of the i-th tile
/// column in units of CTBs.
pub column_width_minus1: [u32; 19],
pub column_width_minus1: [u32; MAX_TILE_COLUMNS],
/// `row_height_minus1[ i ]` plus 1 specifies the height of the i-th tile row
/// in units of CTBs.
pub row_height_minus1: [u32; 21],
pub row_height_minus1: [u32; MAX_TILE_ROWS],
/// When set, specifies that in-loop filtering operations may be performed
/// across tile boundaries in pictures referring to the PPS. When not set,
/// specifies that in-loop filtering operations are not performed across
@@ -2238,6 +2243,15 @@ impl Parser {
..Default::default()
};
// u(3) can say 7, but 7.4.3.1 stops at 6 — and 7 walks off every sub-layer
// array below, starting with profile_tier_level()'s six-deep flags.
if vps.max_sub_layers_minus1 > 6 {
return Err(format!(
"Invalid max_sub_layers_minus1 {}",
vps.max_sub_layers_minus1
));
}
r.skip_bits(16)?; // vps_reserved_0xffff_16bits
let ptl = &mut vps.profile_tier_level;
@@ -2606,12 +2620,16 @@ impl Parser {
// in Table 7-5 and Table 7-6 for i = 0..Min( 63, ( 1 << ( 4 + (
// sizeId << 1 ) ) ) 1 ).
if !scaling_list_pred_mode_flag {
let scaling_list_pred_matrix_id_delta: u32 = r.read_ue()?;
// Equation 7-42's factor. 7.4.5 bounds the delta by
// matrixId / factor, which is what keeps refMatrixId at or above
// zero — unbounded it underflows into an out-of-bounds read.
let factor: u32 = if size_id == 3 { 3 } else { 1 };
let scaling_list_pred_matrix_id_delta: u32 =
r.read_ue_max(matrix_id as u32 / factor)?;
if scaling_list_pred_matrix_id_delta == 0 {
Self::fill_default_scaling_list(sl, size_id, matrix_id);
} else {
// Equation 7-42
let factor = if size_id == 3 { 3 } else { 1 };
let ref_matrix_id =
matrix_id as u32 - scaling_list_pred_matrix_id_delta * factor;
if size_id == 0 {
@@ -3104,6 +3122,14 @@ impl Parser {
..Default::default()
};
// See parse_vps(): 7.4.3.2 bounds this at 6, u(3) does not.
if sps.max_sub_layers_minus1 > 6 {
return Err(format!(
"Invalid max_sub_layers_minus1 {}",
sps.max_sub_layers_minus1
));
}
Self::parse_profile_tier_level(
&mut sps.profile_tier_level,
&mut r,
@@ -3485,8 +3511,16 @@ impl Parser {
// A mix of the rbsp data and the algorithm in 6.5.1
if pps.tiles_enabled_flag {
pps.num_tile_columns_minus1 = r.read_ue_max(sps.pic_width_in_ctbs_y - 1)?;
pps.num_tile_rows_minus1 = r.read_ue_max(sps.pic_height_in_ctbs_y - 1)?;
// 7.4.3.3.1 bounds these by the picture, Table A.8 bounds them by the
// level — and the level cap is the one the arrays below are sized for.
pps.num_tile_columns_minus1 = r.read_ue_max(std::cmp::min(
sps.pic_width_in_ctbs_y - 1,
MAX_TILE_COLUMNS as u32 - 1,
))?;
pps.num_tile_rows_minus1 = r.read_ue_max(std::cmp::min(
sps.pic_height_in_ctbs_y - 1,
MAX_TILE_ROWS as u32 - 1,
))?;
pps.uniform_spacing_flag = r.read_bit()?;
if !pps.uniform_spacing_flag {
pps.column_width_minus1[usize::from(pps.num_tile_columns_minus1)] =
@@ -4122,12 +4156,21 @@ impl Parser {
let max = if !pps.tiles_enabled_flag && pps.entropy_coding_sync_enabled_flag {
sps.pic_height_in_ctbs_y - 1
} else if pps.tiles_enabled_flag && !pps.entropy_coding_sync_enabled_flag {
u32::from((pps.num_tile_columns_minus1 + 1) * (pps.num_tile_rows_minus1 + 1) - 1)
// Widened: Table A.8 permits 20 x 22 tiles, whose product does not fit
// the u8 the tile counts are stored in.
(u32::from(pps.num_tile_columns_minus1) + 1)
* (u32::from(pps.num_tile_rows_minus1) + 1)
- 1
} else {
(u32::from(pps.num_tile_columns_minus1) + 1) * sps.pic_height_in_ctbs_y - 1
};
hdr.num_entry_point_offsets = r.read_ue_max(max)?;
// 7.4.7.1 puts no 32-entry cap on num_entry_point_offsets, but
// entry_point_offset_minus1 is that deep, so the array is the real bound.
hdr.num_entry_point_offsets = r.read_ue_max(std::cmp::min(
max,
hdr.entry_point_offset_minus1.len() as u32 - 1,
))?;
if hdr.num_entry_point_offsets > 0 {
hdr.offset_len_minus1 = r.read_ue_max(31)?;
for i in 0..hdr.num_entry_point_offsets as usize {
@@ -4189,6 +4232,8 @@ mod tests {
use crate::codec::h265::parser::NaluType;
use crate::codec::h265::parser::Parser;
use crate::codec::h265::parser::SliceType;
use crate::codec::h265::parser::MAX_TILE_COLUMNS;
use crate::codec::h265::parser::MAX_TILE_ROWS;
const STREAM_BEAR: &[u8] = include_bytes!("test_data/bear.h265");
const STREAM_BEAR_NUM_NALUS: usize = 35;
@@ -4718,8 +4763,8 @@ mod tests {
assert_eq!(pps.num_tile_rows_minus1, 0);
assert_eq!(pps.num_tile_columns_minus1, 0);
assert!(pps.uniform_spacing_flag);
assert_eq!(pps.column_width_minus1, [0; 19]);
assert_eq!(pps.row_height_minus1, [0; 21]);
assert_eq!(pps.column_width_minus1, [0; MAX_TILE_COLUMNS]);
assert_eq!(pps.row_height_minus1, [0; MAX_TILE_ROWS]);
assert!(pps.loop_filter_across_slices_enabled_flag);
assert!(pps.loop_filter_across_tiles_enabled_flag);
assert!(!pps.deblocking_filter_control_present_flag);
+4 -2
View File
@@ -667,7 +667,8 @@ pub fn open_virtual_output(
pub fn open_idd_push(
target: pf_frame::dxgi::WinCaptureTarget,
preferred: Option<(u32, u32, u32)>,
client_10bit: bool,
want_hdr: bool,
ten_bit_sdr: bool,
want_444: bool,
pyrowave: bool,
keepalive: Box<dyn Send>,
@@ -678,7 +679,8 @@ pub fn open_idd_push(
idd_push::IddPushCapturer::open(
target,
preferred,
client_10bit,
want_hdr,
ten_bit_sdr,
want_444,
pyrowave,
keepalive,
+28 -1
View File
@@ -390,14 +390,41 @@ float4 main(float4 pos : SV_POSITION, float2 uv : TEXCOORD0) : SV_TARGET {
}
";
/// The 10-bit **SDR** pass PS ([`HdrRgb10Converter::new_sdr_expand`]) — full-res, samples the
/// 8-bit BGRA slot and writes the SAME sRGB values into the packed 10-bit target. No colour math
/// on purpose: the UNORM sample→write roundtrip IS the 8→10 expansion (code 255/255 lands on
/// 1023/1023), and the transfer stays sRGB/BT.709 exactly as the 8-bit SDR path treats BGRA —
/// the depth gain is the ENCODER's (Main10 coding precision), not the source's.
const SDR_RGB10_PS: &str = r"
Texture2D<float4> tx : register(t0);
SamplerState sm : register(s0);
float4 main(float4 pos : SV_POSITION, float2 uv : TEXCOORD0) : SV_TARGET {
return float4(tx.Sample(sm, uv).rgb, 1.0);
}
";
impl HdrRgb10Converter {
/// The HDR pass: FP16 scRGB in, PQ-encoded BT.2020 RGB out.
pub(crate) fn new(device: &ID3D11Device) -> Result<Self> {
Self::from_ps(
device,
HDR_RGB10_PS.replace("#include_common", HDR_P010_COMMON),
)
}
/// The 10-bit **SDR** pass: BGRA in, the same sRGB values out at 10-bit UNORM (see
/// [`SDR_RGB10_PS`]). Identical plumbing — only the pixel shader differs — so the two
/// depth paths share the VS/sampler/draw and cannot drift.
pub(crate) fn new_sdr_expand(device: &ID3D11Device) -> Result<Self> {
Self::from_ps(device, SDR_RGB10_PS.to_string())
}
fn from_ps(device: &ID3D11Device, src: String) -> Result<Self> {
// SAFETY: every call is a `?`-checked D3D11 method on the live `device` borrow, over
// fully-initialized stack descriptors and live `Option` out-params; `compile_shader`
// receives `s!()` literals (its contract). Each created COM interface owns its own
// reference, and no raw pointer outlives the call that produced it.
unsafe {
let src = HDR_RGB10_PS.replace("#include_common", HDR_P010_COMMON);
let vsb = compile_shader(HDR_VS, s!("main"), s!("vs_5_0"))?;
let psb = compile_shader(&src, s!("main"), s!("ps_5_0"))?;
let mut vs = None;
+246 -24
View File
@@ -416,13 +416,20 @@ pub struct IddPushCapturer {
/// display's HDR mode flipped). Stamped into the header + each delivery so the driver re-attaches
/// (and so stale-ring publishes are rejected).
generation: u32,
/// The CLIENT's advertised 10-bit capability (= negotiated `bit_depth >= 10`). Gates the
/// composition depth: a 10-bit client PROACTIVELY enables advanced color at `open` (HDR without a
/// manual toggle); an SDR-only client forces it OFF and the descriptor poller PINS it there, so a
/// client that advertised SDR ("HDR off") is never handed the in-band PQ upgrade the pixel-format-
/// driven encoder would otherwise stamp from an HDR composition. (An HDR-negotiated H.26x session
/// still follows a host-side "Use HDR" flip; all clients decode Main10 + auto-detect PQ from the VUI.)
client_10bit: bool,
/// The session negotiated **HDR** (client advertised `VIDEO_CAP_HDR` and the handshake said
/// yes — no longer merely `bit_depth >= 10`, which the 10-bit SDR path below also reaches).
/// Gates the composition depth: an HDR session PROACTIVELY enables advanced color at `open`
/// (HDR without a manual toggle); any other session forces it OFF and the descriptor poller
/// PINS it there, so a client that did not ask for HDR is never handed the in-band PQ
/// upgrade the pixel-format-driven encoder would otherwise stamp from an HDR composition.
/// (An HDR-negotiated H.26x session still follows a host-side "Use HDR" flip; all clients
/// decode Main10 + auto-detect PQ from the VUI.)
want_hdr: bool,
/// The session negotiated 10-bit WITHOUT HDR (`OutputFormat::ten_bit_sdr`): the BGRA slot is
/// expanded 8→10 bit into the packed RGB10 output ([`PixelFormat::Rgb10a2Sdr`]) so NVENC
/// encodes Main10 under the ordinary BT.709 SDR VUI. The display's colour state is never
/// touched — `want_hdr` above stays false, advanced colour stays pinned off.
ten_bit_sdr: bool,
/// The DISPLAY's CURRENT HDR state (from `advanced_color_enabled`) — the user can flip "Use HDR" in
/// Windows mid-session. Drives the ring format (HDR → FP16 surfaces, SDR → BGRA) and the conversion.
/// Polled in the capture loop; a change recreates the ring (see [`Self::recreate_ring`]).
@@ -502,6 +509,17 @@ pub struct IddPushCapturer {
/// ever read (µs) while the host starved since then. Rolled at every fresh frame.
offered_at_fresh: u64,
max_hb_age_us: u64,
/// The damage witness feeding [`stall::StallEvidence::cursor_moved_px`]: the OS cursor's
/// last sampled position (`None` until `GetCursorPos` first succeeds), the px accumulated
/// since the last fresh frame, and one pending sample's delta held back a call so the
/// stall-ending frame's own cursor move never counts into the gap it ended (the fold
/// happens at the top of the NEXT `try_consume`, by which point this call demonstrably
/// did not consume a fresh frame — a fresh frame resets both). Sampled at most every
/// [`Self::CURSOR_WITNESS_INTERVAL`]; user32 reads, never the display-config lock.
cursor_last: Option<(i32, i32)>,
cursor_gap_px: u32,
cursor_pending_px: u32,
cursor_sampled_at: Instant,
/// The Phase A.2 micro-probe engine (refcounted process singleton) — its window read rides
/// every stall report so the verdict matrix can name the disturbance class. `None` when
/// `PUNKTFUNK_STALL_PROBES=0` opted the box out (the engine costs standing threads); the
@@ -532,6 +550,11 @@ pub struct IddPushCapturer {
/// session negotiated 4:4:4 — the full-chroma twin of [`Self::hdr_p010_conv`]. Rebuilt with the
/// ring on a mode/HDR flip.
hdr_rgb10_conv: Option<HdrRgb10Converter>,
/// BGRA slot → packed 10-bit RGB by plain 8→10 expansion (`HdrRgb10Converter::new_sdr_expand`
/// — same sRGB values at UNORM precision), used on a 10-bit **SDR** session
/// ([`Self::ten_bit_sdr`], both 4:2:0 and 4:4:4 — NVENC does the CSC/subsampling either
/// way). Built lazily like its HDR twin above.
sdr_rgb10_conv: Option<HdrRgb10Converter>,
last_seq: u64,
last_present: Option<(ID3D11Texture2D, PixelFormat)>,
status_logged: bool,
@@ -655,8 +678,8 @@ impl IddPushCapturer {
/// `Nv12` (BT.709 8-bit limited), or full-chroma `Bgra` passthrough on a 4:4:4 session (NVENC
/// CSCs RGB→YUV444 itself, following the BT.709 VUI — the one path that deliberately pays the
/// SM-side CSC, because the video processor can only produce subsampled output). The
/// composition depth DOES follow the session's negotiated `client_10bit` — pinned at open
/// (`open.rs`, the `!client_10bit` force-off and the 10-bit enable) and re-pinned every sample
/// composition depth DOES follow the session's negotiated `want_hdr` — pinned at open
/// (`open.rs`, the `!want_hdr` force-off and the 10-bit enable) and re-pinned every sample
/// by [`Self::poll_display_hdr`], because a PQ stream sent to a client that advertised SDR-only
/// lands on an SDR desktop and blows out. (The older note here claimed the opposite — that the
/// advertised `VIDEO_CAP_10BIT` was ignored because clients under-report it. That reasoning
@@ -684,6 +707,11 @@ impl IddPushCapturer {
return (DXGI_FORMAT_R10G10B10A2_UNORM, PixelFormat::Rgb10a2);
}
(DXGI_FORMAT_P010, PixelFormat::P010)
} else if self.ten_bit_sdr {
// 10-bit SDR (either chroma): the BGRA slot expanded 8→10 into packed RGB. The
// format is the SDR twin of `Rgb10a2` — NVENC ingests it as ABGR10 and encodes
// Main10 under the BT.709 VUI its CSC follows (the SDR 4:4:4 precedent below).
(DXGI_FORMAT_R10G10B10A2_UNORM, PixelFormat::Rgb10a2Sdr)
} else if self.want_444 {
(DXGI_FORMAT_B8G8R8A8_UNORM, PixelFormat::Bgra)
} else {
@@ -816,9 +844,10 @@ impl IddPushCapturer {
self.video_conv = None; // converters are sized + HDR-specific → rebuild at the new mode
self.hdr_p010_conv = None;
self.hdr_rgb10_conv = None;
self.sdr_rgb10_conv = None;
// The PyroWave CSC is mode-baked too (BgraToYuvPlanes picks different SDR vs HDR shaders
// and R8/R8G8 vs R16/R16G16 outputs). Without this, a display_hdr flip (Downgrade point D:
// client_10bit=true but HDR couldn't enable at open) reused the stale SDR converter against
// want_hdr=true but HDR couldn't enable at open) reused the stale SDR converter against
// the freshly HDR-formatted pyro ring — every frame corrupted. `ensure_pyro_conv` only
// builds when None, so it must be reset here like its siblings.
self.pyro_conv = None;
@@ -859,12 +888,12 @@ impl IddPushCapturer {
// is never recreated at the wrong format):
// - a PyroWave session: its encoder was opened for fixed plane formats (R8 SDR / R16 HDR),
// so it can't follow a flip the way H.26x re-inits do;
// - ANY SDR-negotiated session (`!client_10bit`, either codec): a host-side flip to HDR
// - ANY SDR-negotiated session (`!want_hdr`, either codec): a host-side flip to HDR
// must not promote the stream to P010 PQ behind a client that advertised SDR-only.
// An HDR-negotiated H.26x session is NOT pinned — it still follows a host "Use HDR" flip in
// either direction (its encoder re-inits on the depth change).
if (self.pyrowave || !self.client_10bit) && now.hdr != self.client_10bit {
let want = self.client_10bit;
if (self.pyrowave || !self.want_hdr) && now.hdr != self.want_hdr {
let want = self.want_hdr;
// A display that refuses the pin refuses it on every 250 ms sample — past
// [`Self::HDR_PIN_EAGER`] consecutive failures the write+read-back re-fires only on
// every [`Self::HDR_PIN_RETRY_EVERY`]th sample (~4 s), instead of 4 CCD writes +
@@ -876,7 +905,7 @@ impl IddPushCapturer {
|| self.desc_seq % Self::HDR_PIN_RETRY_EVERY == 0
{
// OBSERVE the flip; never assert it. This used to discard `set_advanced_color`'s
// `bool` and then write `now.hdr = self.client_10bit` — substituting the DESIRED
// `bool` and then write `now.hdr = self.want_hdr` — substituting the DESIRED
// state for the observed one, which broke in both directions on a display that
// cannot be flipped (the state this file already logs as "Downgrade point D" at
// open):
@@ -907,7 +936,7 @@ impl IddPushCapturer {
observed_hdr = ?observed,
set_advanced_color_returned = requested,
pyrowave = self.pyrowave,
client_10bit = self.client_10bit,
want_hdr = self.want_hdr,
"IDD push: could not pin the display to the NEGOTIATED depth — following what \
it actually composes instead (a physical display forcing HDR, or a driver that \
refuses the flip). The stream's depth will not match the negotiation; the \
@@ -1113,6 +1142,12 @@ impl IddPushCapturer {
self.height,
)?);
}
} else if self.ten_bit_sdr {
// 10-bit SDR (4:2:0 AND 4:4:4): one full-res 8→10 expansion pass to packed RGB;
// NVENC does the RGB→YUV CSC + any subsampling under the BT.709 VUI.
if self.sdr_rgb10_conv.is_none() {
self.sdr_rgb10_conv = Some(HdrRgb10Converter::new_sdr_expand(&self.device)?);
}
} else if self.want_444 {
// Full-chroma passthrough — no conversion resources to build.
} else if self.video_conv.is_none() {
@@ -1441,11 +1476,47 @@ impl IddPushCapturer {
}
}
/// Damage-witness sample cadence — coarse enough to cost nothing (two user32 reads at
/// 125 Hz worst case), fine enough that a ≥150 ms hole gets many samples.
const CURSOR_WITNESS_INTERVAL: Duration = Duration::from_millis(8);
/// The damage witness (see the `cursor_*` field docs): fold the PREVIOUS call's pending
/// delta into the gap accumulator — if that call had consumed a fresh frame, the fresh-frame
/// bookkeeping would have zeroed the pending, so whatever survives belongs to the gap — then
/// take a fresh rate-limited `GetCursorPos` sample into the pending slot. The one-call lag is
/// what keeps the stall-ending frame's own cursor move out of the gap it ended.
///
/// `GetCursorPos` is global, not per-display: a delta of 0 therefore proves the cursor sat
/// still EVERYWHERE (the demotion direction is strict), while a delta > 0 on a
/// parallel-displays host may be a sibling display's motion — that direction only ever
/// upholds today's CONTENT-SILENCE labeling, never worsens it.
// ponytail: no per-target rect filter (needs a cached CCD rect); add one if parallel-display
// hosts ever show false CONTENT-SILENCE convictions from sibling-cursor motion.
fn sample_cursor_witness(&mut self) {
self.cursor_gap_px = self.cursor_gap_px.saturating_add(self.cursor_pending_px);
self.cursor_pending_px = 0;
if self.cursor_sampled_at.elapsed() < Self::CURSOR_WITNESS_INTERVAL {
return;
}
self.cursor_sampled_at = Instant::now();
let mut pos = POINT::default();
// SAFETY: plain FFI; `pos` is a valid out-param for this synchronous call.
if unsafe { GetCursorPos(&mut pos) }.is_ok() {
if let Some((x, y)) = self.cursor_last {
self.cursor_pending_px = pos.x.abs_diff(x).saturating_add(pos.y.abs_diff(y));
}
self.cursor_last = Some((pos.x, pos.y));
}
}
fn try_consume(&mut self) -> Result<Option<CapturedFrame>> {
self.log_driver_status_once();
// The secure-desktop guard first: while UAC/Winlogon is up there may be NO fresh frames
// at all — this edge is what brings them back.
self.poll_secure_desktop();
// The stall damage witness — before any early return, so gaps of every shape accumulate
// cursor motion (or certify its absence).
self.sample_cursor_witness();
// Follow the display: a "Use HDR" flip recreates the ring at the matching format.
self.poll_display_hdr();
// Recover-or-drop (GB1): if a descriptor change triggered a recreate but no fresh frame has resumed
@@ -1631,6 +1702,17 @@ impl IddPushCapturer {
let (y_rtv, uv_rtv) = rtvs.as_ref().expect("P010 out slot has plane RTVs");
conv.convert(&self.context, src, y_rtv, uv_rtv, self.width, self.height)?;
}
} else if self.ten_bit_sdr {
// 10-bit SDR: BGRA slot → packed 10-bit RGB, a plain 8→10 expansion (same
// sRGB values at UNORM precision). NVENC ingests it as ABGR10 and encodes
// Main10 under the BT.709 VUI — the CSC and any subsampling are NVENC's,
// exactly like the SDR 4:4:4 passthrough below, one bit-depth up.
if let Some(conv) = self.sdr_rgb10_conv.as_ref() {
let src = blended.as_ref().map(|(_, srv)| srv).unwrap_or(&slot_srv);
let (_, _, rtv) = out.as_ref().expect("out ring");
let rtv = rtv.as_ref().expect("Rgb10a2Sdr out slot has an RTV");
conv.convert(&self.context, src, rtv, self.width, self.height)?;
}
} else if self.want_444 {
// SDR 4:4:4: pass the BGRA slot through untouched — NVENC ingests full-chroma
// RGB and CSCs to YUV 4:4:4 itself (per the always-written BT.709 VUI). Plain
@@ -1709,6 +1791,9 @@ impl IddPushCapturer {
.map(|(from, p)| p.window(from, now)),
etw,
etw_counts,
// The gap accumulator only — this call's own pending sample (the stall-ending
// frame's move) is still unfolded and gets discarded by the reset below.
cursor_moved_px: self.cursor_last.map(|_| self.cursor_gap_px),
};
self.stall_watch.report(&stall, now, &evidence);
}
@@ -1734,6 +1819,10 @@ impl IddPushCapturer {
self.offered_at_fresh = offered;
}
self.max_hb_age_us = 0;
// Damage witness rolls with the other per-gap trackers; the pending sample is the
// ending frame's own move — discarded, never folded (see `sample_cursor_witness`).
self.cursor_gap_px = 0;
self.cursor_pending_px = 0;
}
// Build the frame. For PyroWave the encode input is the Y plane
// (`texture`) + the CbCr plane & fence in `pyro`; signal the shared fence
@@ -2137,13 +2226,20 @@ mod tests {
}
/// Feed a [`StallWatch`] fresh frames at the given offsets (ms from a common origin) and
/// return what each `note_fresh` produced.
fn watch_run(offsets_ms: &[u64]) -> Vec<Option<Stall>> {
/// return what each `note_fresh` produced, paired with the metronome's read for the stalls
/// (fed as non-damage-idle, the way `report` feeds every display-evidence stall).
fn watch_run(offsets_ms: &[u64]) -> Vec<Option<(Stall, Option<Duration>)>> {
let base = Instant::now();
let mut w = StallWatch::new();
offsets_ms
.iter()
.map(|ms| w.note_fresh(base + Duration::from_millis(*ms)))
.map(|ms| {
let at = base + Duration::from_millis(*ms);
w.note_fresh(at).map(|s| {
let period = w.cycle(at, false);
(s, period)
})
})
.collect()
}
@@ -2160,9 +2256,9 @@ mod tests {
t.push(604);
let out = watch_run(&t);
assert!(out[..20].iter().all(Option::is_none));
let stall = out[20].as_ref().expect("hole after active flow is a stall");
let (stall, period) = out[20].as_ref().expect("hole after active flow is a stall");
assert_eq!(stall.gap.as_millis(), 300);
assert!(stall.metronomic.is_none(), "one stall is not a cycle");
assert!(period.is_none(), "one stall is not a cycle");
}
#[test]
@@ -2272,11 +2368,11 @@ mod tests {
flow(&mut t, cycle * 4_000, 232); // last frame at cycle*4000 + 3696
}
let out = watch_run(&t);
let stalls: Vec<&Stall> = out.iter().flatten().collect();
let stalls: Vec<&(Stall, Option<Duration>)> = out.iter().flatten().collect();
assert_eq!(stalls.len(), 4, "each cycle boundary is one stall");
assert!(stalls[..3].iter().all(|s| s.metronomic.is_none()));
assert!(stalls[..3].iter().all(|(_, period)| period.is_none()));
let period = stalls[3]
.metronomic
.1
.expect("the 4th evenly-spaced event completes the metronome streak");
assert!(
(period.as_secs_f64() - 4.0).abs() < 0.3,
@@ -2284,6 +2380,33 @@ mod tests {
);
}
/// A damage-idle hole must not advance the metronome: the same four evenly-spaced stalls
/// as [`metronomic_stalls_self_diagnose`], but with one classified damage-idle — the beat
/// never completes, because a hand/input pause is not display-disturbance evidence.
#[test]
fn damage_idle_stalls_do_not_feed_the_metronome() {
let base = Instant::now();
let mut w = StallWatch::new();
let mut periods = Vec::new();
for cycle in 0..5u64 {
let mut t = Vec::new();
flow(&mut t, cycle * 4_000, 232);
for ms in t {
let at = base + Duration::from_millis(ms);
if let Some(_stall) = w.note_fresh(at) {
// The 2nd stall reads damage-idle (cursor sat still on a dwm-only desktop).
let damage_idle = periods.len() == 1;
periods.push(w.cycle(at, damage_idle));
}
}
}
assert_eq!(periods.len(), 4);
assert!(
periods.iter().all(Option::is_none),
"a skipped beat must break the streak: {periods:?}"
);
}
#[test]
fn reset_swallows_the_recreate_gap() {
// Active flow, then a ring recreate (reset), then flow resumes 800 ms later — the resume
@@ -2305,6 +2428,37 @@ mod tests {
);
}
/// The repeated-stall (non-metronomic) WARN's window arithmetic: fires at the third reported
/// stall inside 60 s, stays quiet through the 300 s re-warn spacing, and re-arms on a fresh
/// burst after old entries age out.
#[test]
fn stall_rate_warn_window_and_rewarn() {
let base = Instant::now();
let at = |s: u64| base + Duration::from_secs(s);
let mut w = StallWatch::new();
assert_eq!(w.note_for_rate_warn(at(0)), None);
assert_eq!(w.note_for_rate_warn(at(10)), None);
assert_eq!(
w.note_for_rate_warn(at(20)),
Some(3),
"third stall in 60 s warns"
);
assert_eq!(
w.note_for_rate_warn(at(30)),
None,
"inside the re-warn spacing the arm stays quiet"
);
// A fresh burst well past the spacing: the old entries have aged out of the window,
// so it takes a full RATE_MIN_STALLS again — and then warns again.
assert_eq!(w.note_for_rate_warn(at(400)), None);
assert_eq!(w.note_for_rate_warn(at(401)), None);
assert_eq!(
w.note_for_rate_warn(at(402)),
Some(3),
"re-warns after the spacing"
);
}
/// [`stall::attribute`]'s verdict table — the Branch-1/Branch-2 fork, per evidence shape.
#[test]
fn stall_attribution_verdicts() {
@@ -2318,6 +2472,7 @@ mod tests {
probes: None,
etw: None,
etw_counts: None,
cursor_moved_px: None,
},
)
};
@@ -2345,7 +2500,7 @@ mod tests {
#[test]
fn stall_classification_matrix() {
use super::dxgkrnl_etw::EtwWindowCounts;
use super::stall::{classify, ProbeWindow, StallClass, StallVerdict};
use super::stall::{ProbeWindow, StallClass, StallVerdict};
let gap = Duration::from_millis(600);
let probes = |fence: Option<u64>, dwm: Option<u64>, flush: Option<u64>| ProbeWindow {
fence_max_us: fence,
@@ -2358,7 +2513,20 @@ mod tests {
queue_adds,
present_history: true,
queue_history: true,
flow_dwm_only: false,
};
// Every case below predates the damage witness — `cursor_moved_px = None` keeps the
// pre-witness classification, which is exactly what these cases assert. The witness's
// own matrix (the damage-idle split) is `damage_idle_split` below. A nested fn (not a
// closure): each call site's temporaries need their own lifetime.
fn classify(
gap: Duration,
verdict: &StallVerdict,
p: Option<&ProbeWindow>,
c: Option<&EtwWindowCounts>,
) -> StallClass {
super::stall::classify(gap, verdict, p, c, None)
}
// The driver's own verdicts win outright — probes can't overrule "we lost the frames".
assert_eq!(
classify(
@@ -2510,4 +2678,58 @@ mod tests {
StallClass::ContentSilence
);
}
/// The damage witness's split of CONTENT-SILENCE (the 2026-08-27 field reattribution): a
/// present-free hole on a dwm-only desktop with a stationary cursor is an input/hand pause
/// (DAMAGE-IDLE), a moving cursor keeps it CONTENT-SILENCE (damage existed, nothing
/// composed — the display-stack conviction), and a game session (flow not dwm-only) is
/// never demoted regardless of the cursor.
#[test]
fn damage_idle_split() {
use super::dxgkrnl_etw::EtwWindowCounts;
use super::stall::{classify, ProbeWindow, StallClass, StallVerdict};
let gap = Duration::from_millis(600);
let healthy = ProbeWindow {
fence_max_us: Some(16_000),
dwm_tick_frozen_us: Some(20_000),
dwm_flush_max_us: Some(30_000),
..ProbeWindow::default()
};
let counts = |dwm_only: bool| EtwWindowCounts {
presents: 0,
queue_adds: 0,
present_history: true,
queue_history: true,
flow_dwm_only: dwm_only,
};
let run = |dwm_only: bool, moved: Option<u32>| {
classify(
gap,
&StallVerdict::ComposeSilence,
Some(&healthy),
Some(&counts(dwm_only)),
moved,
)
};
assert_eq!(run(true, Some(0)), StallClass::DamageIdle);
assert_eq!(run(true, Some(312)), StallClass::ContentSilence);
// A game presented in the lookback: its hole is real evidence even with a still cursor.
assert_eq!(run(false, Some(0)), StallClass::ContentSilence);
// No witness (pre-witness build / GetCursorPos failing): pre-witness behavior.
assert_eq!(run(true, None), StallClass::ContentSilence);
// The witness never overrules a harder conviction: stalled fences stay ADAPTER-FREEZE.
assert_eq!(
classify(
gap,
&StallVerdict::ComposeSilence,
Some(&ProbeWindow {
fence_max_us: Some(400_000),
..ProbeWindow::default()
}),
Some(&counts(true)),
Some(0),
),
StallClass::AdapterFreeze
);
}
}
@@ -357,7 +357,18 @@ impl EtwWatch {
.copied()
.collect()
};
let counts = count_window(&events, from_q, to_q, duration_qpc(LOOKBACK, freq));
let mut counts = count_window(&events, from_q, to_q, duration_qpc(LOOKBACK, freq));
// Pre-hole flow attribution (damage-idle discriminator): when every present in the
// lookback window came from dwm.exe, the flow the stall watch gated on was pure desktop
// composition — on the IDD-push desktop that means cursor/UI damage, the kind that
// legitimately stops the instant input stops. A game presenting anywhere in the lookback
// keeps this false, so a game's real holes are never demoted. Name resolution runs here
// (a handful of `OpenProcess` calls per stall report), off the consumer callback.
let lookback_pids = lookback_present_pids(&events, from_q, duration_qpc(LOOKBACK, freq));
counts.flow_dwm_only = !lookback_pids.is_empty()
&& lookback_pids
.iter()
.all(|&pid| process_name(pid).is_some_and(|n| n.eq_ignore_ascii_case("dwm.exe")));
let ms = |dq: i64| dq.max(0) * 1_000 / freq;
let mut parts = Vec::new();
for (start_id, stop_id, label) in [
@@ -521,6 +532,29 @@ pub(super) struct EtwWindowCounts {
/// Queue-stream liveness inside [`LOOKBACK`] before the hole (`BltQueueAddEntry` or
/// `BltQueueCompleteIndirectPresent` — either proves the witness works).
pub(super) queue_history: bool,
/// Every present in the lookback window came from `dwm.exe` (and there was at least one):
/// the pre-hole flow was pure desktop composition — cursor/UI damage — not a game. Set by
/// [`EtwWatch::window_report`] (name resolution lives there, not in the pure tick math);
/// the damage-idle demotion in `stall::classify` requires it, so a game session's holes
/// are never demoted no matter what the cursor did.
pub(super) flow_dwm_only: bool,
}
/// The distinct pids that presented (DXGI 42/55) inside the lookback window `[from_q -
/// lookback_q, from_q]` — the pre-hole flow's presenters. Pure tick math (no name resolution),
/// factored out of [`EtwWatch::window_report`] so the windowing is unit-testable.
fn lookback_present_pids(events: &[(i64, u16, u32)], from_q: i64, lookback_q: i64) -> Vec<u32> {
let mut pids = Vec::new();
for &(ts, id, pid) in events {
if matches!(id, DXGI_PRESENT_ID | DXGI_PRESENT_MPO_ID)
&& ts >= from_q.saturating_sub(lookback_q)
&& ts <= from_q
&& !pids.contains(&pid)
{
pids.push(pid);
}
}
pids
}
/// Enable `guid` on `session` with a kernel-side event-id allowlist. `true` on success.
@@ -645,6 +679,8 @@ mod tests {
queue_adds: 1,
present_history: true,
queue_history: true,
// Set by `window_report` (needs name resolution), never by the tick math.
flow_dwm_only: false,
}
);
@@ -679,4 +715,21 @@ mod tests {
let c = count_window(&[ev(0, DXGI_PRESENT_ID)], 3, to, i64::MAX);
assert!(c.present_history);
}
/// [`lookback_present_pids`]'s windowing: presenters strictly from the lookback window
/// (dedup'd), never from inside or after the hole — an in-hole presenter is not "the flow
/// the stall watch gated on".
#[test]
fn lookback_presenters_come_from_before_the_hole() {
let (from, lb) = (1_000i64, 500i64);
let events = [
(600, DXGI_PRESENT_ID, 7u32), // lookback, pid 7
(700, DXGI_PRESENT_MPO_ID, 7u32), // lookback, pid 7 again (dedup)
(800, DXGI_PRESENT_ID, 9u32), // lookback, pid 9
(900, BLT_ADD_ID, 11u32), // lookback, but not a present
(1_100, DXGI_PRESENT_ID, 13u32), // inside the hole — excluded
];
assert_eq!(lookback_present_pids(&events, from, lb), vec![7, 9]);
assert!(lookback_present_pids(&events[4..], from, lb).is_empty());
}
}
+36 -14
View File
@@ -162,7 +162,8 @@ impl IddPushCapturer {
pub fn open(
target: WinCaptureTarget,
preferred: Option<(u32, u32, u32)>,
client_10bit: bool,
want_hdr: bool,
ten_bit_sdr: bool,
want_444: bool,
pyrowave: bool,
keepalive: Box<dyn Send>,
@@ -176,7 +177,8 @@ impl IddPushCapturer {
match Self::open_inner(
target,
preferred,
client_10bit,
want_hdr,
ten_bit_sdr,
want_444,
pyrowave,
sender,
@@ -195,7 +197,8 @@ impl IddPushCapturer {
fn open_inner(
target: WinCaptureTarget,
preferred: Option<(u32, u32, u32)>,
client_10bit: bool,
want_hdr: bool,
ten_bit_sdr: bool,
want_444: bool,
pyrowave: bool,
sender: crate::FrameChannelSender,
@@ -218,7 +221,8 @@ impl IddPushCapturer {
match Self::open_on(
target.clone(),
preferred,
client_10bit,
want_hdr,
ten_bit_sdr,
want_444,
pyrowave,
luid,
@@ -253,7 +257,8 @@ impl IddPushCapturer {
Self::open_on(
target,
preferred,
client_10bit,
want_hdr,
ten_bit_sdr,
want_444,
pyrowave,
drv,
@@ -270,7 +275,8 @@ impl IddPushCapturer {
fn open_on(
target: WinCaptureTarget,
preferred: Option<(u32, u32, u32)>,
client_10bit: bool,
want_hdr: bool,
ten_bit_sdr: bool,
want_444: bool,
pyrowave: bool,
luid: LUID,
@@ -338,7 +344,7 @@ impl IddPushCapturer {
// lands on an SDR desktop and blows out — the composition must honor the negotiation.
// An HDR-negotiated (10-bit) session instead enables HDR below and rides the FP16 scRGB
// ring (design/pyrowave-444-hdr.md Phase 3 for PyroWave; the H.26x P010 path otherwise).
if !client_10bit {
if !want_hdr {
let _ = pf_win_display::win_display::set_advanced_color(target.target_id, false);
let settle = Instant::now();
while settle.elapsed() < Duration::from_millis(250) {
@@ -372,8 +378,8 @@ impl IddPushCapturer {
// size the ring FP16 directly — don't race the advanced_color_enabled poll, which may not have
// settled within 250 ms and would size the ring SDR while the driver composes FP16 → a format
// mismatch → an immediate ring recreate + dropped first frames (audit §5.4).
let enabled_hdr = client_10bit
&& pf_win_display::win_display::set_advanced_color(target.target_id, true);
let enabled_hdr =
want_hdr && pf_win_display::win_display::set_advanced_color(target.target_id, true);
if enabled_hdr {
// Let the colorspace change settle before the driver composes + we size the ring:
// poll the CCD advanced-color state instead of a fixed sleep (latency plan P0.4),
@@ -399,7 +405,7 @@ impl IddPushCapturer {
// A failed open-time read defaults to SDR (unless the 10-bit path enabled HDR above) —
// there is no "last known" yet; the descriptor poller corrects a wrong guess mid-session.
// An SDR-negotiated session (either codec) forced advanced color OFF above and composes
// SDR unconditionally: `client_10bit` gates HDR so a client that advertised SDR-only is
// SDR unconditionally: `want_hdr` gates HDR so a client that advertised SDR-only is
// never handed a PQ stream, even if a physical display forces HDR on (the descriptor
// poller re-asserts OFF; PyroWave's format guard/stash absorbs any lingering FP16 compose).
// Keep the raw observation so Downgrade point D below can say whether the read reported
@@ -407,13 +413,13 @@ impl IddPushCapturer {
// causes and different fixes.
let observed_hdr =
pf_win_display::win_display::advanced_color_enabled(target.target_id);
let display_hdr = client_10bit && (enabled_hdr || observed_hdr.unwrap_or(false));
let display_hdr = want_hdr && (enabled_hdr || observed_hdr.unwrap_or(false));
// Downgrade point D (design/hdr-10bit-default-and-av1.md item 2d): the session was
// NEGOTIATED 10-bit (the client was told HDR in the Welcome), but the virtual display
// could not enable advanced color — the ring sizes SDR and the encoder will emit 8-bit
// BT.709, so the client's label overstates the stream until the descriptor poller sees
// HDR come on. Loud, because every frame of this session is affected.
if client_10bit && !display_hdr {
if want_hdr && !display_hdr {
tracing::error!(
target = target.target_id,
want_hdr = true,
@@ -586,7 +592,8 @@ impl IddPushCapturer {
render_luid = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart),
mode = format!("{w}x{h}"),
display_hdr,
client_10bit,
want_hdr,
ten_bit_sdr,
want_444,
ring_fp16 = display_hdr,
// Whether DXGI ever reached the win32u GPU-preference hook. By this point the
@@ -610,7 +617,8 @@ impl IddPushCapturer {
height: h,
slots,
generation,
client_10bit,
want_hdr,
ten_bit_sdr,
display_hdr,
hdr_pin_warned: false,
hdr_pin_failures: 0,
@@ -639,6 +647,10 @@ impl IddPushCapturer {
stall_watch: StallWatch::new(),
offered_at_fresh: 0,
max_hb_age_us: 0,
cursor_last: None,
cursor_gap_px: 0,
cursor_pending_px: 0,
cursor_sampled_at: Instant::now(),
probes: pf_host_config::config()
.stall_probes
.then(super::probes::acquire),
@@ -648,6 +660,7 @@ impl IddPushCapturer {
video_conv: None,
hdr_p010_conv: None,
hdr_rgb10_conv: None,
sdr_rgb10_conv: None,
last_seq: 0,
last_present: None,
status_logged: false,
@@ -672,6 +685,15 @@ impl IddPushCapturer {
// it back to the caller to retire or reuse the display (audit §5.1).
_keepalive: Box::new(()),
};
// The two REALTIME GPU-priority opt-ins, stamped once per capture session so EVERY
// field log self-describes its posture — the stall WARNs repeat them, but only when
// they fire, and the 7700 XT case (2026-08-26) showed a stalling log where they
// never did (design: windows-amd-host-program §3.1 Gap B).
tracing::info!(
rt_gpu_driver = super::stall::rt_gpu_driver_posture(),
rt_gpu_host = super::stall::rt_gpu_host_posture(),
"GPU-priority posture for this capture session"
);
// The HDR SDR-white reference for the composited cursor, queried ONCE here rather than
// from the blend (which holds the ring slot's keyed mutex — see
// `refresh_sdr_white_scale`). No-op on an SDR composition.
+232 -70
View File
@@ -5,12 +5,14 @@ use super::*;
/// A detected capture stall: a multi-hundred-ms hole in DWM's frame delivery that opened while the
/// desktop was actively composing right beforehand (see [`StallWatch`]).
///
/// The metronome is NOT fed here — [`StallWatch::report`] feeds it after classification, so a
/// damage-idle hole (cursor stationary on a dwm-only desktop: an input/hand pause, not a display
/// stall — the 2026-08-27 NVIDIA-laptop field case was ~30 of these misread as a 1.87 s display
/// metronome) never contributes to the beat that the METRONOMIC warns blame on display hardware.
pub(super) struct Stall {
/// How long the hole lasted (last fresh frame → the frame that ended it).
pub(super) gap: Duration,
/// `Some(mean period)` when this stall completes a metronomic cycle (see
/// [`pf_frame::metronome::Metronome`]).
pub(super) metronomic: Option<Duration>,
}
/// One degraded stretch, summarized at recovery ([`StallWatch::take_recovery`]). Per-hole stall
@@ -59,6 +61,14 @@ pub(super) struct StallEvidence {
/// display path dropped composed frames; both silent = the content stopped presenting.
/// `None` when the ETW session is unavailable.
pub(super) etw_counts: Option<super::dxgkrnl_etw::EtwWindowCounts>,
/// How far the OS cursor moved (px, |dx|+|dy| summed over samples) DURING the hole — the
/// damage witness. On the composited-cursor desktop the pointer is the damage source, so
/// `Some(0)` says the content had nothing to compose (input/hand pause: damage-idle) while
/// `Some(n>0)` says damage existed and DWM composed none of it — a positive display-stack
/// conviction. `None` = never sampled (`GetCursorPos` failing / pre-witness build); the
/// classifier then behaves as before this field existed. The stall-ending frame's own
/// cursor move is deliberately NOT counted (see the capturer's fold-on-next-call sampler).
pub(super) cursor_moved_px: Option<u32>,
}
/// The micro-probes' window read (Phase A.2, built by `probes::ProbeEngine::window`): per-leg
@@ -129,9 +139,13 @@ pub(super) enum StallClass {
CompositorBlocked,
/// Engines alive, DWM's clock ticking, driver drained E_PENDING, and the ETW present witness
/// saw (essentially) NO swapchain presents from ANY process across the hole: the content
/// stopped presenting — no damage, DWM correctly composed nothing (a game hitch, a loading
/// screen, a menu). Benign for the display path; the content side is where to look if the
/// user FELT it.
/// stopped presenting — no damage, DWM correctly composed nothing. A ONE-OFF here is benign
/// (a game hitch, a loading screen, a menu). But this class is also what a frozen *presenter*
/// looks like (disturbance-immunity Flavor 3: the display stack — win32k CCD lock, UMD
/// serialization against display events, vblank-wait limiters — stops the content's present
/// loop), and every probe we run sits at the host's elevated GPU priority, so normal-band
/// starvation reads healthy. REPEATED holes under active load do NOT exonerate the display
/// path — the repeated-stall / metronomic WARNs carry that triage.
ContentSilence,
/// Engines alive, DWM ticking, driver drained E_PENDING — and the ETW present witness saw
/// presents FLOWING through the hole while the virtual display's kernel queue
@@ -139,6 +153,13 @@ pub(super) enum StallClass {
/// them before our swap-chain. The real display-path bug class — never yet observed in the
/// field; a report with this label (counts attached) is the specimen we want.
FrameGeneration,
/// A CONTENT-SILENCE hole whose damage witness says there was nothing to compose: the
/// pre-hole flow was dwm.exe-only (cursor/UI damage, no game presenting) AND the cursor sat
/// still through the hole. That is an input/hand pause — client-radio holes, Wi-Fi
/// power-save/scan cycles, or simply a resting hand — not a display disturbance. Excluded
/// from the metronome and both repeated-stall warns; the 2026-08-27 NVIDIA-laptop field
/// case was 30/30 of these blamed on the dark laptop panel.
DamageIdle,
/// Not enough evidence to name a class (pre-telemetry driver and/or probes absent).
Unattributed,
}
@@ -155,16 +176,54 @@ impl std::fmt::Display for StallClass {
"CLASS-2 compositor blocked (engines alive, DWM tick frozen — vendor lock / DDC)"
}
Self::ContentSilence => {
"CONTENT-SILENCE (no swapchain presents from any process across the hole — the content stopped presenting; not the display path)"
"CONTENT-SILENCE (no swapchain presents from any process across the hole — the content stopped presenting; a one-off is a game hitch/menu, but REPEATED holes under load can equally be the display stack freezing the presenter)"
}
Self::FrameGeneration => {
"FRAME-GENERATION (presents FLOWED while the virtual display's kernel queue starved — the OS display path dropped composed frames)"
}
Self::DamageIdle => {
"DAMAGE-IDLE (dwm-only flow and the cursor sat still through the hole — nothing was dirty, so DWM correctly composed nothing; an input/hand pause, not a display stall)"
}
Self::Unattributed => "UNATTRIBUTED (insufficient telemetry)",
})
}
}
/// The vdisplay driver's GPU-priority lever (`PFVD_RT_GPU` / legacy opt-out `PFVD_NO_RT_GPU`) as
/// configured in THIS process's environment (machine env; the WUDFHost driver process resolves
/// the pair the same way, so this read mirrors what the driver decided — modulo a machine env
/// edited after either process started, which a restart heals). The RX 9070 XT field A/B
/// (2026-08-12) convicted EXACTLY this lever's REALTIME rung of the metronomic stall signature,
/// so every stall-triage line must say whether it is engaged before anyone chases display
/// hardware.
pub(super) fn rt_gpu_driver_posture() -> &'static str {
if std::env::var_os("PFVD_NO_RT_GPU").is_some() {
"off (PFVD_NO_RT_GPU)"
} else {
match std::env::var_os("PFVD_RT_GPU") {
None => "off (default)",
Some(v) if v.eq_ignore_ascii_case("thread") => "gpu-thread (+7)",
Some(_) => "REALTIME (PFVD_RT_GPU)",
}
}
}
/// The host's own GPU scheduling-priority policy (`PUNKTFUNK_GPU_PRIORITY_CLASS`) as prose —
/// [`rt_gpu_driver_posture`]'s twin for the second convicted REALTIME lever (the `auto` gate's
/// HIGH→REALTIME upgrade, ~3.6 s beat in the same A/B).
pub(super) fn rt_gpu_host_posture() -> &'static str {
match std::env::var("PUNKTFUNK_GPU_PRIORITY_CLASS")
.ok()
.as_deref()
{
Some("off") => "off",
Some("normal") => "normal",
Some("realtime") => "REALTIME (pinned)",
Some("auto") => "auto (gated REALTIME upgrade)",
_ => "high (default)",
}
}
/// How many window presents acquit the content: ≥8 presents across the hole mirrors
/// [`attribute`]'s offered-frames bar and [`StallWatch::RECENT`]'s sustained-flow definition —
/// a caret blink or a stall-ending frame stays under it, a game presenting through the hole
@@ -187,6 +246,7 @@ pub(super) fn classify(
verdict: &StallVerdict,
probes: Option<&ProbeWindow>,
etw_counts: Option<&super::dxgkrnl_etw::EtwWindowCounts>,
cursor_moved_px: Option<u32>,
) -> StallClass {
match verdict {
StallVerdict::WorkerStalled => return StallClass::OursWorker,
@@ -212,6 +272,16 @@ pub(super) fn classify(
Some(c) if c.present_history => {
if c.presents >= PRESENTS_ACQUIT_CONTENT {
StallClass::FrameGeneration
} else if c.flow_dwm_only && cursor_moved_px == Some(0) {
// The damage witness closes the CONTENT-SILENCE ambiguity from BOTH sides:
// dwm-only flow means the damage source was the cursor, and a cursor that
// sat still through the hole means DWM had nothing to compose — the hole is
// an input/hand pause, not a display stall. (A cursor that MOVED through a
// present-free hole stays CONTENT-SILENCE and, repeated, is the display
// stack freezing the presenter — the warns say so.) Both conditions are
// required: a game session (flow not dwm-only) is never demoted, and a
// missing witness (`None`) keeps the pre-witness behavior.
StallClass::DamageIdle
} else {
StallClass::ContentSilence
}
@@ -300,14 +370,21 @@ pub(super) struct StallWatch {
/// whole session's beat, not just the stall that tripped the metronome.
verdicts: [u32; 4],
/// Running per-class tally ([`StallClass`] order: ours-worker, ours-delivery, adapter-freeze,
/// compositor-blocked, content-silence, frame-generation, unattributed) — the verdict
/// matrix's session summary.
classes: [u32; 7],
/// compositor-blocked, content-silence, frame-generation, damage-idle, unattributed) — the
/// verdict matrix's session summary.
classes: [u32; 8],
/// The degraded stretch currently being accumulated, opened by a reported stall and fed by
/// every stall-sized hole until sustained flow returns.
episode: Option<Episode>,
/// A closed episode's summary, parked for the caller ([`Self::take_recovery`]).
pending_recovery: Option<Recovery>,
/// Instants of REPORTED stalls inside the last [`Self::RATE_WINDOW`] — the repeated-stall
/// (non-metronomic) WARN's evidence. The metronome needs a stable period; the 2026-08-26
/// 7700 XT field case showed 6 stall-sized holes in 8 s with none, and its log carried zero
/// triage guidance as a result.
rate_window: std::collections::VecDeque<Instant>,
/// When the repeated-stall WARN last fired (spacing: [`Self::RATE_REWARN`]).
last_rate_warn: Option<Instant>,
}
impl StallWatch {
@@ -327,6 +404,14 @@ impl StallWatch {
/// Episodes with fewer holes than this dissolve silently — the single stall's own report
/// line already covers them.
const EPISODE_MIN_HOLES: u32 = 2;
/// Rolling window for the repeated-stall (non-metronomic) WARN.
const RATE_WINDOW: Duration = Duration::from_secs(60);
/// Reported stalls inside [`Self::RATE_WINDOW`] that make the session WARN-worthy — well
/// above the ~1-per-minute a busy desktop legitimately produces, well under a degraded
/// session's dozens.
const RATE_MIN_STALLS: usize = 3;
/// Re-WARN spacing for the rate arm (the metronomic arms pace themselves via the metronome).
const RATE_REWARN: Duration = Duration::from_secs(300);
pub(super) fn new() -> Self {
Self {
@@ -335,12 +420,63 @@ impl StallWatch {
seen: 0,
with_os_events: 0,
verdicts: [0; 4],
classes: [0; 7],
classes: [0; 8],
episode: None,
pending_recovery: None,
rate_window: std::collections::VecDeque::new(),
last_rate_warn: None,
}
}
/// Feed one REPORTED stall at `now` into the rate window; `Some(count)` exactly when the
/// repeated-stall WARN is due (≥ [`Self::RATE_MIN_STALLS`] inside [`Self::RATE_WINDOW`],
/// spaced by [`Self::RATE_REWARN`]). Pure — unit-tested beside the verdict tests.
pub(super) fn note_for_rate_warn(&mut self, now: Instant) -> Option<usize> {
self.rate_window.push_back(now);
while let Some(front) = self.rate_window.front() {
if now.duration_since(*front) > Self::RATE_WINDOW {
self.rate_window.pop_front();
} else {
break;
}
}
if self.rate_window.len() < Self::RATE_MIN_STALLS {
return None;
}
if self
.last_rate_warn
.is_some_and(|t| now.duration_since(t) < Self::RATE_REWARN)
{
return None;
}
self.last_rate_warn = Some(now);
Some(self.rate_window.len())
}
/// The session's per-verdict tally as one log token ([`StallVerdict`] order).
fn verdict_tally(&self) -> String {
format!(
"worker-stalled {}, compose-silence {}, delivery-leg {}, no-telemetry {}",
self.verdicts[1], self.verdicts[2], self.verdicts[3], self.verdicts[0]
)
}
/// The session's per-class tally as one log token ([`StallClass`] order).
fn class_tally(&self) -> String {
format!(
"ours-worker {}, ours-delivery {}, adapter-freeze {}, compositor-blocked {}, \
content-silence {}, frame-generation {}, damage-idle {}, unattributed {}",
self.classes[0],
self.classes[1],
self.classes[2],
self.classes[3],
self.classes[4],
self.classes[5],
self.classes[6],
self.classes[7]
)
}
/// Forget the flow history (a ring recreate's gap is self-inflicted, not a DWM stall — without
/// the reset the first post-recreate frame would read as one). An open episode is closed and
/// summarized: its holes predate the recreate and are real evidence.
@@ -418,10 +554,21 @@ impl StallWatch {
if !was_active || gap < Self::STALL_MIN {
return None;
}
Some(Stall {
gap,
metronomic: self.cadence.note(now),
})
// The metronome is fed in [`Self::report`], AFTER classification — a damage-idle hole
// must never advance the beat the METRONOMIC warns blame on display hardware.
Some(Stall { gap })
}
/// Feed one classified stall into the metronome — `Some(mean period)` when it completes a
/// metronomic cycle. Damage-idle stalls are NOT fed: an input/hand pause repeating on the
/// user's cadence must not fabricate the display-disturbance beat (the 2026-08-27 field
/// case fitted a "1.87 s display metronome" to what were pauses in the client's input).
/// Split from [`Self::report`] so the metronome integration stays unit-testable.
pub(super) fn cycle(&mut self, now: Instant, damage_idle: bool) -> Option<Duration> {
if damage_idle {
return None;
}
self.cadence.note(now)
}
/// Log a detected stall, correlate it against OS display events, and — once the cadence turns
/// metronomic — name the class of disturbance and its cures.
@@ -458,6 +605,7 @@ impl StallWatch {
&verdict,
evidence.probes.as_ref(),
evidence.etw_counts.as_ref(),
evidence.cursor_moved_px,
);
self.classes[match class {
StallClass::OursWorker => 0,
@@ -466,8 +614,15 @@ impl StallWatch {
StallClass::CompositorBlocked => 3,
StallClass::ContentSilence => 4,
StallClass::FrameGeneration => 5,
StallClass::Unattributed => 6,
StallClass::DamageIdle => 6,
StallClass::Unattributed => 7,
}] += 1;
// Damage-idle holes are real delivery holes (the episode/recovery summaries still count
// them) but they are NOT display-disturbance evidence: they must not advance the
// metronome, trip either repeated-stall warn, or put a connected-inactive display on
// trial. The per-stall line below still carries their full evidence.
let damage_idle = class == StallClass::DamageIdle;
let metronomic = self.cycle(now, damage_idle);
// debug (not warn): a single hole also happens when content legitimately pauses;
// the reportable signal is the metronomic cycle below. Mounjay-class triage runs
// at debug level, and the web-console debug ring captures these.
@@ -483,12 +638,49 @@ impl StallWatch {
// inside the gap window. presents≥bar with adds≈0 = FRAME-GENERATION conviction.
etw_presents = evidence.etw_counts.map(|c| c.presents),
etw_queue_adds = evidence.etw_counts.map(|c| c.queue_adds),
// The damage witness: 0 on a dwm-only desktop = the hole had nothing to compose
// (input/hand pause); >0 with no presents = damage existed and the display stack
// composed none of it — a positive conviction the old CONTENT-SILENCE could not make.
cursor_moved_px_during_gap = evidence.cursor_moved_px,
flow_dwm_only = evidence.etw_counts.map(|c| c.flow_dwm_only),
offered_during_gap = evidence.offered_delta,
max_heartbeat_age_ms = evidence.max_heartbeat_age_ms,
"IDD-push capture stall — the desktop was composing at speed, then the ring \
delivered no frame for the gap; the class names the leg that lost them"
);
if let Some(period) = stall.metronomic {
// The repeated-stall arm: the metronome needs a stable period, but a session losing
// frames to 150+ ms holes every few seconds without one (the 2026-08-26 7700 XT case)
// deserves the same triage payload — otherwise the log's only guidance is per-stall
// DEBUG lines nobody is told to read. Skipped when THIS stall completed a metronomic
// cycle (the arms below carry strictly richer prose) and for damage-idle holes (an
// input/hand pause repeated 30 times is still not a display problem).
if metronomic.is_none() && !damage_idle {
if let Some(stalls_in_window) = self.note_for_rate_warn(now) {
let suspects = pf_win_display::display_events::connected_inactive_physicals();
let suspects = if suspects.is_empty() {
"none".to_string()
} else {
suspects.join(", ")
};
tracing::warn!(
stalls_in_window = stalls_in_window as u64,
os_correlated = format!("{}/{}", self.with_os_events, self.seen),
connected_inactive = %suspects,
rt_gpu_driver = rt_gpu_driver_posture(),
rt_gpu_host = rt_gpu_host_posture(),
verdicts = %self.verdict_tally(),
classes = %self.class_tally(),
"capture stalls are REPEATING without a stable period — same triage as the \
metronomic class: if rt_gpu_driver or rt_gpu_host shows a REALTIME opt-in, \
clear it first (unset PFVD_RT_GPU / set PUNKTFUNK_GPU_PRIORITY_CLASS=high); \
then a connected-but-inactive display's standby servicing (see \
connected_inactive), then display-poller software (the SteelSeries GG / \
SignalRGB class). A content-silence class tally does NOT exonerate the \
display stack a frozen presenter reads identically (Flavor 3)"
);
}
}
if let Some(period) = metronomic {
let suspects = pf_win_display::display_events::connected_inactive_physicals();
let suspects = if suspects.is_empty() {
"none".to_string()
@@ -497,21 +689,8 @@ impl StallWatch {
};
let correlated = format!("{}/{}", self.with_os_events, self.seen);
// The session's attribution in one token: which leg the evidence convicted, per stall.
let verdict_tally = format!(
"worker-stalled {}, compose-silence {}, delivery-leg {}, no-telemetry {}",
self.verdicts[1], self.verdicts[2], self.verdicts[3], self.verdicts[0]
);
let class_tally = format!(
"ours-worker {}, ours-delivery {}, adapter-freeze {}, compositor-blocked {}, \
content-silence {}, frame-generation {}, unattributed {}",
self.classes[0],
self.classes[1],
self.classes[2],
self.classes[3],
self.classes[4],
self.classes[5],
self.classes[6]
);
let verdict_tally = self.verdict_tally();
let class_tally = self.class_tally();
// Half-or-more of the stalls carrying a coinciding OS event = the reaction
// cascade is OS-visible; otherwise the disturbance never surfaces above the
// driver. Different classes, different cures — say which one this box has.
@@ -533,33 +712,11 @@ impl StallWatch {
suspects)"
);
} else {
// The two REALTIME GPU-priority opt-ins, as configured in THIS process's
// environment (machine env; the WUDFHost driver process resolves the PFVD pair
// the same way, so this read mirrors what the driver decided — modulo a machine
// env edited after either process started, which a restart heals). The RX 9070
// XT field A/B (2026-08-12) convicted EXACTLY this warning's signature twice
// over: the driver's swap-chain REALTIME raise beat at ~1.8 s, the host
// auto-gate's REALTIME upgrade at ~3.6 s — so a log carrying this warning must
// say whether either lever is engaged before anyone chases display hardware.
let rt_gpu_driver = if std::env::var_os("PFVD_NO_RT_GPU").is_some() {
"off (PFVD_NO_RT_GPU)"
} else {
match std::env::var_os("PFVD_RT_GPU") {
None => "off (default)",
Some(v) if v.eq_ignore_ascii_case("thread") => "gpu-thread (+7)",
Some(_) => "REALTIME (PFVD_RT_GPU)",
}
};
let rt_gpu_host = match std::env::var("PUNKTFUNK_GPU_PRIORITY_CLASS")
.ok()
.as_deref()
{
Some("off") => "off",
Some("normal") => "normal",
Some("realtime") => "REALTIME (pinned)",
Some("auto") => "auto (gated REALTIME upgrade)",
_ => "high (default)",
};
// The two REALTIME GPU-priority opt-ins (see the posture helpers' docs for the
// 2026-08-12 A/B that convicted both) — a log carrying this warning must say
// whether either lever is engaged before anyone chases display hardware.
let rt_gpu_driver = rt_gpu_driver_posture();
let rt_gpu_host = rt_gpu_host_posture();
tracing::warn!(
period_s = format!("{:.2}", period.as_secs_f64()),
os_correlated = correlated,
@@ -569,22 +726,27 @@ impl StallWatch {
verdicts = %verdict_tally,
classes = %class_tally,
"capture stalls are METRONOMIC with NO coinciding OS display event — \
the disturbance is BELOW Windows. FIRST: if rt_gpu_driver or \
rt_gpu_host shows a REALTIME opt-in, clear it (unset PFVD_RT_GPU / \
set PUNKTFUNK_GPU_PRIORITY_CLASS=high) a punktfunk process holding \
the disturbance is BELOW Windows (damage-idle holes cursor \
stationary on a dwm-only desktop, i.e. input/hand pauses are \
already excluded from this beat; see cursor_moved_px_during_gap on \
the per-stall lines). FIRST: if rt_gpu_driver or rt_gpu_host shows a \
REALTIME opt-in, clear it (unset PFVD_RT_GPU / set \
PUNKTFUNK_GPU_PRIORITY_CLASS=high) a punktfunk process holding \
REALTIME GPU priority is the field-proven amplifier of exactly this \
signature on AMD. Otherwise: the GPU driver servicing a \
signature on AMD, and every pre-0.28 field metronome ran with it \
default-on. Otherwise: the GPU driver servicing a \
connected-but-asleep sink (standby HPD/DDC/link probing), \
display-poller software (the SteelSeries-GG/SignalRGB class \
correlate 'slow display-descriptor poll' lines), or the DWM present \
clock (try a different refresh rate). If connected_inactive lists a \
display, its standby servicing is the prime suspect. For a LAPTOP \
PANEL (the exclusive isolate deactivated it the dark-but-connected \
head is itself the disturbance on hybrid laptops): keep it active \
with `topology: primary`, or try the `pnp_disable_monitors` axis. \
For an external display: unplug it at the GPU, disable its OSD auto \
input scan (TVs: instant-on/quick-start + CEC off), use an \
HPD-holding adapter/dummy, or keep it active while streaming"
display, its standby servicing is a suspect cursor motion through \
the holes is what convicts the display stack. For an external \
display: keep it active while streaming, disable its OSD auto input \
scan (TVs: instant-on/quick-start + CEC off), unplug it at the GPU, \
or use an HPD-holding adapter/dummy. For a LAPTOP PANEL: keep it \
active with `topology: primary` (the dark-but-connected-head \
hypothesis has no confirmed post-0.28 case verify with the cursor \
witness before chasing it)"
);
}
}

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