Commit Graph
807 Commits
Author SHA1 Message Date
enricobuehler e9da37aaf6 feat(host/gamelease): a launcher tile's session stops depending on invisible state
ci / web (pull_request) Failing after 2s
ci / rust-arm64 (pull_request) Failing after 3s
ci / bun-nix (pull_request) Successful in 1m15s
apple / swift (pull_request) Successful in 1m35s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 2m16s
android / android (pull_request) Successful in 4m56s
ci / rust (pull_request) Successful in 9m1s
A launcher entry (design D4) has no "the game exited" moment to detect, but the lease was
still deciding its lifetime from whatever happened to be true at launch — and the two
outcomes disagree:

  launcher NOT already running   the spawned child stays alive -> `Child` lease
                                 -> quitting the launcher ends the session
  launcher ALREADY running       the command forwards to the live instance and exits
                                 inside SHIM_WINDOW, with no detect signals behind it
                                 -> `Untracked` -> the session persists

Same tile, two lifetimes, chosen by something the user cannot see. Steam is the case that
settles which one is right: Big Picture is a *mode* of an already-running Steam client, not
a process — and on a Deck or SteamOS host Steam is always running — so no process signal can
express "the launcher's window closed". Heroic has the same shape for a different reason
(single-instance Electron: a second invocation forwards and exits).

So a launcher entry is now `LeaseKind::Untracked` unconditionally. The check sits AHEAD of
`nested`/`child`/`spec`, and that ordering is the fix rather than an implementation detail:
a launcher the host just started leaves a live child behind, and tracking that child is
precisely the inconsistency being removed.

`Untracked` already meant the right things downstream, so nothing else had to change: no
watcher thread, `terminate()` is a no-op ("nothing to end" — closing a session must not kill
the user's Steam), and no `GameExited` event, so a client does not bounce back to its library
when the launcher closes.

Threaded as `LaunchTarget::launcher` -> `LeaseRequest::launcher` from the entry's `role`.
Three call sites, one of which was the actual trap: the GameStream path does not build its
lease from a `LaunchTarget` at all, it goes through a `GsApp` intermediate that silently
dropped the field. An operator-typed `apps.json` command has no library entry behind it and
is never a launcher tile, so it passes `false` explicitly.

The test pins BOTH cases from the table above, plus the same request without the flag still
being `Matched` — so the assertions are the flag's doing and not an artifact of the fixture.

Gates: punktfunk-host 439 passed / 0 failed on .21 (+1).

Not covered here: nothing publishes launcher tiles until the plugins are released, so there
was no live exposure to fix — this is the semantics being made deliberate before the first
tile is ever clicked.
2026-08-06 18:35:09 +02:00
enricobuehler 166e158afe feat(host/library): an xbox launch kind a de-privileged plugin can publish
apple / swift (pull_request) Successful in 1m27s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m22s
android / android (pull_request) Successful in 3m9s
ci / docs-site (pull_request) Successful in 1m9s
ci / web (pull_request) Successful in 3m20s
ci / rust (pull_request) Failing after 9m9s
The xbox scanner resolves each package's PackageFamilyName by enumerating
%ProgramData%\Microsoft\Windows\AppRepository\Packages. Probed on .173 (design doc S1),
that directory is:

  NT AUTHORITY\LocalService   UnauthorizedAccessException   <- the plugin runner
  LocalSystem                 count=348                     <- the host service

So an extracted xbox plugin cannot build an AUMID, and `aumid` — which takes a complete
`<PFN>!<AppId>` — is unpublishable by one.

The design doc offered two fallbacks: entries without `aumid` (they would list but not
launch), or a one-shot elevated PFN resolve at install time. This is neither. The plugin
sends `<Identity>!<AppId>`, both read straight out of MicrosoftGame.config — which IS
readable de-privileged — and the host resolves the publisher hash at LAUNCH time, where it
already owns command construction.

That keeps D1 intact (the plugin supplies a validated value, the host builds the command),
it is the same shape as the `playnite` kind, and resolving late means a package update that
changes the publisher hash cannot leave a stale unlaunchable tile behind — which the
install-time cache would have.

`xbox_pfn` therefore moves from the scan path to the launch path rather than being deleted,
and its doc records the privilege asymmetry so the next reader doesn't re-derive it.

The charset guard is factored out (`aumid_part` / `valid_aumid`) and shared with the `aumid`
kind. On `aumid` it was belt-and-braces — every value was host-derived. On `xbox` it is
load-bearing: the Identity arrives over the wire from a plugin, and it is interpolated into
a `shell:AppsFolder\…` argument. Validated inbound at reconcile too, so a malformed value is
a 400 the plugin author can act on rather than a tile that fails on click.

Gates: punktfunk-host 438 passed / 0 failed on .21, and 55/0 for the library tests on .173
(where these arms actually compile). Both counts +1 for the new guard test.
2026-08-06 15:43:26 +02:00
enricobuehler 6ae2ea6708 Merge remote-tracking branch 'origin/main' into worktree-library-clients 2026-08-06 14:45:36 +02:00
enricobuehler dd20a17edb test(host/library): the art tests build a file:// URL Windows can read
`local_art_bytes_is_confined_and_image_only` and `posix_local_art_is_classified_and_proxied`
built their `file://` values as `format!("file://{path}")`. On Windows that yields
`file://C:\covers\cover.png`, whose authority is `C:` — a UNC reference, not a local
file — so the read half failed on the box and the host suite was red there.

The parser is right and the tests were wrong: `@punktfunk/plugin-kit/library`'s `fileUrl`
emits `file:///C:/covers/cover.png` (three slashes, forward separators) and
`file_url_to_path` documents exactly that. A shared `file_url` helper now builds the value
the way the kit does, so both tests exercise the real plugin contract on both platforms
rather than a shape no plugin ever sends.

Found while gating the Playnite launch kinds on .173 — Linux CI never compiles these arms,
so the failure had gone unnoticed. Test-only: no product code changes.
2026-08-06 14:36:04 +02:00
enricobuehler 8ff2c2e1c6 feat(host/library): Playnite can publish again, and gets a fullscreen tile
The Playnite plugin emits `kind: "command"` for every game (a `start "" "playnite://…"`
shell line). The 2026-08-05 review made `command` operator-only, and `privileged_field`
refuses a PROVIDER reconcile carrying one — so on this branch the published
`@punktfunk/plugin-playnite@0.3.0` cannot publish anything at all. Not a launcher tile:
not one game. That is a regression against a shipped plugin, and it is the same hole
`launcher_ui` was created to close, one kind further along.

Two kinds, both host-owned so D1 holds — the plugin supplies a validated VALUE and
never a command line:

  playnite     valued by the game's GUID; resolves to
               explorer.exe "playnite://playnite/start/<guid>", the same
               protocol-via-a-concrete-EXE shape the `epic` kind uses. GUID-validated
               on the way in (so a bad value is a 400 the plugin author can act on)
               and again at launch.

  launcher_ui  now accepts "playnite" on Windows, resolving to
               Playnite.FullscreenApp.exe with Playnite's own install dir as the
               working directory.

Fullscreen, not Desktop, is the whole point of a couch tile — and it is also why this
one cannot ride the URI the games use: probed on .173, Playnite's registered
`playnite://` handler is bound to Playnite.DesktopApp.exe, so no URI opens fullscreen
mode. The exe is spawned directly, with the install dir read from Playnite's own
uninstall entry (HKCU, then HKLM for a machine-wide install), falling back to
%LOCALAPPDATA%\Playnite.

`valid_launcher_ui("playnite")` is answered by RESOLUTION rather than by a static list:
a host without Playnite installed refuses the entry instead of publishing a tile that
does nothing when a user clicks it. That is the same instinct that left Epic, GOG
Galaxy and the Xbox app off the list — each still needs its own verified activation,
and a guess would ship exactly that dead tile.

Gates: punktfunk-host 436 passed / 0 failed on .21 (the Linux arms), and the Windows
arms compiled and their library tests run on .173.
2026-08-06 14:35:48 +02:00
enricobuehler 883c317872 feat(clients/library): a launcher tile looks like one, on every client
The host has been able to describe a launcher entry since M2 — `role: "launcher"`,
the `steam_ui` and `launcher_ui` kinds — and the web console has grouped them into
their own rail since M4. No other client ever looked. `pf-client-core` decoded
`role` into an `is_launcher()` helper with zero call sites, and the shared console
model dropped the field entirely on its way to the renderer.

So a launcher tile arrived everywhere else as an ordinary game with no cover art:
indistinguishable from a title whose poster failed to load, sorted into the middle
of the alphabet, and captioned "Play".

One contract, implemented in each client's own idiom:

  * launchers never interleave with titles — they lead, and each group keeps the
    host's title order
  * grid surfaces get a labelled section; a coverflow keeps its single carousel and
    names the group the cursor is in, changing as it crosses the boundary. A second
    focus rail would mean a new up/down nav model in three renderers for two or
    three tiles
  * an art-less launcher gets an accent face naming its launcher, not a title
    monogram on the neutral one — "opens Steam", not "a cover that didn't load"
  * anything that is not `"launcher"` is a game, and a host that omits the field
    renders exactly as before (design D4's intended degradation)
  * launching is unchanged: the client sends an id, the host resolves the recipe

The grouping is enforced once per client stack rather than per screen. In the
console UI it is an invariant of `LibraryShared::set_games`, so the cursor
arithmetic, the art pump and every future consumer inherit it; on Apple and Android
it is applied where the library is fetched/parsed.

Fixed in passing: the Apple and Android store badges were hard-coded
`isCustom ? "Custom" : "Steam"`, so every Lutris, GOG, Heroic, Epic and Xbox title
was labelled "Steam". Both now carry the same store table the Rust clients use.

The CLI's `--library` gains a fourth column (`game`/`launcher`), appended rather
than folded into an existing one so anything reading the first three is untouched.

Gates: punktfunk-host 436 passed / 0 failed and pf-console-ui 49 passed / 0 failed
on .21 (three new tests), workspace clippy -D warnings and cargo fmt --check clean
there; `swift build` of the full PunktfunkClient and `:app:compileDebugKotlin` clean
on macOS; `cargo check` + `clippy -D warnings` for the Windows client on .173.

Still unproven on hardware: no launcher tile has been clicked on a real host — that
needs the plugins published, which needs this branch's base merged first.
2026-08-06 14:35:24 +02:00
enricobuehler 76e8bd1b98 fix(host/gamelease): a game that exited stops counting as running
When a launched game's processes are all gone, the watcher asks one last
out-of-band question before ending the session: does the launcher still
think the game is up? On Windows that reads Steam's per-app `Running`
registry flag. It was only ever meant to be a tie-breaker for a scan that
momentarily can't see the game — a launcher re-execing, an engine
relaunching itself into a new pid.

It had no bound. Honouring the flag reset the confirm window every pass,
so a flag Steam left set — it does that whenever it doesn't cleanly
observe the exit: it crashed, it was closed first, the game re-parented —
pinned the lease in `running` for the life of the host. The console kept
showing the game, `session_on_game_exit` never fired, and the only way to
get the stream back was a manual "End". Reported from the field on
Windows 0.24.0. `steam_running_hint` also believes the FIRST hive that
says so, so a stale flag in any loaded profile was enough.

The absence timer now keeps running instead of being reset, and that is
what bounds it: past `VETO_LIMIT` (30 s) with nothing of the game on the
box, the launcher's opinion is stale rather than early and the session
ends anyway, logged at WARN so it is visible. Ending a moment early is
the cheaper failure — the stream drops while the game lives, the user
reconnects, and nothing is ever killed. Ending never was the bug.

The rule is now a pure `exit_confirmed(gone_for, hint_running)` with a
test. The watch loop polls a live process table and can't be unit-tested,
which is exactly how an unbounded veto shipped unnoticed.
2026-08-06 13:58:24 +02:00
enricobuehler 6f07bd94d3 feat(library): launcher tiles a plugin can actually publish
ci / docs-site (pull_request) Successful in 1m14s
apple / swift (pull_request) Successful in 1m28s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m37s
ci / rust-arm64 (pull_request) Successful in 2m28s
android / android (pull_request) Successful in 4m10s
ci / rust (pull_request) Successful in 6m11s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 6m56s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m35s
Design D4 promised entries that open the LAUNCHER — Steam Big Picture, Heroic,
Lutris — and the plumbing for it landed in M2/M4: the `role` field, the
`steam_ui` kind, the console's Launchers rail. But nothing could flow through it
for anything except Steam.

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

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

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

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

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

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

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

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

Three reconciliations:

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

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

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

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

Gates on .21: punktfunk-host 433 passed / 0 failed (including #58's H-2 tests and
the new file:// ones), full workspace tests clean, workspace clippy -D warnings
clean, cargo fmt --all --check clean, OpenAPI drift test green.
2026-08-05 19:59:11 +02:00
enricobuehler 0d94ef0dbe fix(host/mgmt): the field gate returns the refusal, not an error carrying it
apple / swift (pull_request) Successful in 1m26s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m49s
ci / docs-site (pull_request) Successful in 2m17s
ci / rust-arm64 (pull_request) Successful in 2m43s
android / android (pull_request) Successful in 3m3s
ci / rust (pull_request) Successful in 8m31s
`check_entry_fields` returned `Result<(), Response>`, which trips
`clippy::result_large_err` under CI's `-D warnings`: an axum `Response` is 128
bytes and it was riding in the `Err` variant.

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

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

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

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

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

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

Two consequences that would otherwise bite in the field:

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

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

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

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

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

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

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

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

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

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

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

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

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

NOT fixed, deliberately:

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

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

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

Verified: cargo fmt --all --check clean; cargo check --all-targets green on
Linux and on Windows (confirmed non-vacuous — a planted type error in
windows/install.rs fails the build); scripts/xcheck.sh windows check green;
cargo test -p punktfunk-host --bins 416 passed, the single failure being
gamestream::stream::tests::sender_delivers_batches, the known qemu-environmental
UDP-loopback flake that fails identically on clean main in the same container;
cargo test -p pf-clipboard 13 passed; web console typechecks.
2026-08-05 17:12:12 +02:00
enricobuehler 3d4a659959 feat(host,sdk,kit): store claims, launcher entries, and plugin sources on the wire
M2 of design/library-scanner-plugins-implementation-plan.md. Everything a
library scanner plugin needs is now expressible over the API; all additive.

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

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

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

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

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

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

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

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

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

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

No API change; no user-visible change.
2026-08-05 09:09:19 +02:00
enricobuehler e5416646f9 fix(host/send): a stall-resume frame paces at the proven rate instead of blasting
Stall program T2 (amplification kill), the resume-burst half. The native
pace budget was min(0.9 × time-to-deadline, overflow at ~3× stream rate) —
for steady-state frames the rate term is smaller and decides, but for an
OVERSIZED frame (a capture-stall resume carrying seconds of scene delta, a
cold IDR) the deadline term clamped a multi-interval overflow into the
remainder of ONE: an instantaneous many-×-stream-rate blast that overruns
the socket tx-buffer and loses the very frame that would have ended the
freeze. Field fingerprint across three RDNA4 standby-sink cases:
WSAENOBUFS(10055) + loss_ppm spikes at stall edges, then a recovery-IDR
round trip per retry while the client shows 'current bitrate 0.1'.

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

Gates: host clippy --all-targets -D warnings + 9 send_pacing tests green
(linux/amd64 container), fmt clean.
2026-08-05 00:28:13 +02:00
enricobuehler d27e62f7c9 fix(pad-audio): close the twelve findings the sweep left open on this branch
apple / swift (pull_request) Successful in 1m31s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 41s
ci / web (pull_request) Successful in 1m59s
ci / docs-site (pull_request) Successful in 1m59s
ci / rust-arm64 (pull_request) Successful in 4m5s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 2m37s
android / android (pull_request) Successful in 4m16s
ci / rust (pull_request) Failing after 10m50s
Everything the 2026-08-03 haptics sweep filed against the pad-audio branch (P2 + P3).
Four of them are the difference between a feature that works and one that fails silently.

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

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

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

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

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

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

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

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

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

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

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

The rest: `wiring_plan::plan` now delegates to main's `plan_with_formats`, so the pad-endpoint
filter moved into that body and the predicate behind it is factored out as `is_pad_render`
(also what B10 needs); `Ds5Feedback::AUDIO` derives from main's `REPORT_ID_LEN` like its
siblings; `AudioCtl` joins the explicitly-listed unhandled variants so the guard-false case is
covered rather than swept up by a `_`; `include/punktfunk_core.h` regenerated rather than
hand-merged.
2026-08-04 23:27:06 +02:00
enricobuehler b31495bea5 fix(host): a leftover Sunshine folder is not a conflict, and a crashed host gives the screen back
ci / rust (pull_request) Successful in 9m53s
ci / rust-arm64 (pull_request) Successful in 1m24s
apple / screenshots (pull_request) Skipped
apple / swift (pull_request) Successful in 1m20s
ci / docs-site (pull_request) Successful in 1m12s
android / android (pull_request) Successful in 2m56s
ci / web (pull_request) Successful in 2m0s
Three things a field report (Discord, upgrade from 0.1x) turned up, all on the
Windows host.

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

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

3. "The exclusive screen never times out going back to re-enabling the display."
   `isolate_displays_ccd` deactivates the operator's panels and hands the
   pre-isolate topology to the caller, which restores it at teardown — but that
   snapshot is PROCESS MEMORY, and Windows deliberately never saves the isolated
   topology to the CCD database. So a host that crashed, was killed, or was stopped
   mid-session left the desk dark with nothing in the product to undo it. There was
   one startup recovery leg already, but only for the EXPERIMENTAL
   `pnp_disable_monitors` axis, which is off by default — the default Exclusive path
   had none. `isolate_journal` now marks what an isolate is about to switch off
   (before the apply, so dying mid-apply is covered), clears the mark on restore,
   and force-EXTENDs at host startup if a mark survived. EXTEND rather than
   replaying the saved blob: the blob pins the virtual display's target id, which
   dies with the crashed host, so a replay would mostly fail BAD_CONFIGURATION into
   the very same backstop `restore_displays_ccd` already keeps — and EXTEND stays
   correct across a reboot, where saved ids would be stale.
2026-08-04 22:34:33 +02:00
enricobuehler 9f1f23eb40 Merge pull request 'feat(wire): mid-session shard-payload renegotiation — the black screen heals in seconds, jumbo behind an opt-in' (#41) from worktree-shard-payload-reneg into main
release / apple (push) Canceled after 2m53s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 1s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
windows-host / canary-manifest (push) Successful in 16s
windows-host / package (push) Successful in 11m13s
windows-host / winget-source (push) Skipped
apple / swift (push) Successful in 1m26s
android / android (push) Canceled after 3m42s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 3m45s
ci / rust (push) Canceled after 3m28s
ci / rust-arm64 (push) Canceled after 1m59s
ci / web (push) Canceled after 1m54s
ci / docs-site (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders-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
Reviewed-on: #41
2026-08-04 18:06:41 +00:00
enricobuehler 34ad3cc611 feat(host/wire): mid-session shard-payload renegotiation, driven by the MTU verdict
Phases 1-2 of design/shard-payload-reneg.md, on top of the Phase 0
per-frame geometry. The leg-1 watcher stops merely diagnosing the
constrained path and heals the CURRENT session; the same machinery,
inverted, takes a proven jumbo LAN up to ~8.9 KB shards.

- Messages: MSG_SHARD_PAYLOAD_CHANGED (0x08, host→client, {shard_payload
  u16}) and MSG_SHARD_PAYLOAD_ACK (0x09, the echo). Asymmetric by
  design: a shrink re-keys the packetizer at the next AU immediately
  after sending (per-frame pinning makes ordering irrelevant; the ack is
  telemetry), a grow emits nothing above the old size until the ack —
  the ack is the gate even though client buffers are statically sized.
- Client: one dispatch arm in the shared pump control task (all client
  families) — validate against the advertised receive bounds, ack;
  out-of-bounds requests get SILENCE, not an ack, so a buggy host can
  never read a granted grow out of garbage.
- Host driver: the wire_mtu watcher grows a ShardReneg arm — on a
  below-ceiling verdict it still records the learned budget (session 2
  starts right) and now also shrinks session 1 at the ~3-10 s verdict
  mark; with the jumbo opt-in (PUNKTFUNK_JUMBO=1, or PUNKTFUNK_WIRE_MTU
  > 1500 — one knob, derived) it sends the ack-gated grow after a
  settled-at-sealed-jumbo proof and then stays alive as the revert
  guard: quinn's blackhole detection lowering current_mtu shrinks the
  wire back through the same path. The QUIC MTUD probe ceiling rises
  from 1472 to the sealed jumbo size with the opt-in (per-ENDPOINT: a
  few extra failed probes toward non-jumbo peers, zero cost otherwise).
- Apply point: Session::set_shard_payload drained in the send loop next
  to the adaptive-FEC target, gated on no open streamed AU (a streamed
  frame's shard-aligned tiling derives from the size it began with).
- Renegotiation is gated OFF for PyroWave sessions: their clients parse
  chunk-aligned AUs in windows of the Welcome value pinned at session
  start (read once over the C ABI), so a mid-stream re-key would corrupt
  the parse — those sessions keep the leg-1 next-session clamp. This
  also settles the plan's open question on the two wire_chunk consumers:
  both are PyroWave-only, so the gate covers them entirely.
- Legacy peers are inert both ways: no Hello advertisement → the host
  never constructs the driver; an old host never sends the message.

core: 296/296 --features quic + clippy -D warnings (macOS), fmt; the
regenerated header carries the new message ids (drift gate).
2026-08-04 19:42:36 +02:00
enricobuehler 69f1db5ea9 Merge pull request 'feat(host/wire): MTU resilience for the video data plane' (#37) from worktree-wire-mtu-resilience into main
apple / swift (push) Successful in 1m30s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m21s
docker / builders-arm64cross (push) Successful in 11s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m37s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m50s
windows-host / package (push) Successful in 16m8s
android / android (push) Successful in 5m39s
windows-host / winget-source (push) Skipped
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m36s
windows-host / canary-manifest (push) Successful in 30s
deb / build-publish (push) Successful in 21m51s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m31s
deb / build-publish-client-arm64 (push) Successful in 9m9s
ci / rust (push) Canceled after 27m31s
flatpak / build-publish (push) Successful in 8m11s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 13s
release / apple (push) Successful in 9m14s
arch / build-publish (push) Successful in 9m41s
docker / deploy-docs (push) Successful in 6m19s
apple / screenshots (push) Successful in 6m0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m27s
ci / web (push) Successful in 1m3s
deb / build-publish-host (push) Failing after 30s
ci / docs-site (push) Successful in 1m15s
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 20s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 21s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
ci / rust-arm64 (push) Successful in 2m41s
Reviewed-on: #37
2026-08-04 17:02:01 +00:00
enricobuehler 4bc7eecf05 feat(host/wire): MTU resilience for the video data plane
android / android (pull_request) Successful in 3m25s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m13s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 3m16s
ci / rust (pull_request) Successful in 22m23s
ci / docs-site (pull_request) Successful in 1m5s
ci / web (pull_request) Successful in 1m47s
apple / swift (pull_request) Successful in 1m22s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m46s
Video datagrams are sealed at a shard payload sized for a clean 1500-byte
MTU (1472-byte UDP payloads). A host whose route to the client crosses a
smaller-MTU hop (a VPN/overlay adapter claiming the LAN route, a lowered
NIC MTU) delivers every small flow — QUIC control, hole punch, input,
audio — while 100% of video datagrams die: the client sits on a black
screen reporting zero loss and the host streams into the void with every
gauge green. Field-reported as 'connects fine, black screen forever'.

Three legs, none of which changes a session on a healthy path:

- PUNKTFUNK_WIRE_MTU operator override: shard payload derived from a
  given on-wire IP MTU. Wire-compatible — Welcome::shard_payload is
  already negotiated per session (the v4/v6 split ships two values
  today) and every client follows the negotiated value.
- Detection: the QUIC MTU-discovery probe ceiling moves from quinn's
  stock 1452 to exactly the sealed video-datagram size (1472), so a
  control connection's settled MTU becomes a verdict on the path:
  settled at the ceiling proves it carries video, settled below proves
  it cannot. A per-session watcher samples after the search has settled
  (live-connection guard against mid-search false learns) and logs an
  actionable WARN naming the failure shape and the diagnosis commands.
- Healing: the measured budget is recorded per peer IP; the next
  handshake clamps shard_payload to fit, so a reconnect self-heals. A
  later session that reaches the ceiling erases the record.

Verified: core 286/286 --features quic + clippy -D warnings (macOS);
host clippy -D warnings + native:: tests 44/44 (pf-lxcheck container).
The regenerated C header picks up the new MIN_SHARD_PAYLOAD constant.
2026-08-04 18:30:33 +02:00
enricobuehlerandClaude Opus 5 2cfc82e96c fix(audio): budget the audio plane against the link, and close the review's gaps
Findings from the post-implementation review of design/audio-quality-and-latency.md.

**The bandwidth gap (highest).** Tier `High` (256 kbps) and the redundant `0xD2` plane were
added separately, each costed as "~1 % of the video budget", and nobody added them together:
256 kbps sent twice is 512 kbps — ~2.5 % of a 20 Mbps session but ~10 % of a 5 Mbps one. Audio
rides QUIC datagrams, OUTSIDE the ABR loop, so ABR could neither see that nor reclaim it; a
constrained link quietly handed a tenth of its bandwidth to audio while ABR carefully managed
the rest.

`plan_audio_budget` now makes tier and redundancy ONE decision against the session's resolved
video bitrate, ordered by preference rather than cost — transparent audio beats redundant audio,
since the field report was about quality and redundancy only pays under loss, so `High` alone
outranks `Standard`+redundancy even though they cost the same. It can lower what the operator
asked for, never raise it, and never goes below `Low`: a stream with unintelligible audio is
worse than one spending a few percent more.

**The Linux host kept the exact defect fixed on Windows.** `let _ = tx.try_send(samples)` —
silent, uncounted data loss, where the encoder concatenates across the hole, so every drop is a
click AND a permanent shift of everything after it. WP0.2 turned out to be Windows-only and had
not said so. Linux now shares `capture_policy::CaptureStats`: drops counted and warned, plus
per-window peak/RMS/delivered%. A Linux audio report was until now exactly as un-triageable as
the Windows one was on 2026-08-03.

**Apple's WP0.3 was half-done** — `bufferedMS` was added and wired to nothing. The drain thread
now logs buffer/target/underruns/sheds like the other three, from one locked snapshot so the
numbers in a line describe the same instant.

Also: the Linux "audio format negotiated" line now says WHICH mode produced it, because that
changes what it is worth — in stream-sink mode the host owns the sink so the mix cannot have
been narrowed upstream, but in legacy monitor mode a 16 kHz Bluetooth sink would still be
reported as a clean 48 kHz through PipeWire's resampler, the same way WASAPI's autoconvert hid
it on Windows. Reading the monitored node's own rate needs a registry lookup this stream does
not do; recorded as an open gap rather than implied to be covered.

Two stale docs: `audio_wasapi.rs` cited `clients/windows/src/audio.rs` (deleted) and still
described the pre-shared-policy "prime to ~3 quanta" behaviour. And the Apple ring's `prefill:`
parameter, dead since the depth moved into the ring, is gone.

Verified: clippy --all-targets -D warnings on Linux (docker) AND Windows (runner .133, forced
clean rebuild of punktfunk-host + pf-client-core); core 167 tests; host 57 audio tests on
Windows; Android clippy count identical to pristine (6, all documented arm64 artifacts); Apple
ring re-simulated. The host suite's `gamestream::stream::tests::sender_delivers_batches` fails
under qemu — the recorded environmental flake, unrelated to audio, green on the earlier
less-loaded run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 17:58:17 +02:00
enricobuehlerandClaude Opus 5 3055e29ebb feat(host/audio): make audio observable, fix the endpoint choice, raise the encode quality
Phases 0-3 of design/audio-quality-and-latency.md, host side.

**WP2.1 — the 2026-08-03 root cause.** The client-only loopback preference took Steam's
Streaming *Microphone* render endpoint over real hardware unconditionally, because it is
silent on the host. But that endpoint exists to carry remote VOICE, and nothing checked
whether it could carry music: on the reporter's box it won all 31 loopback opens across 25
sessions while a clean AMD HD Audio endpoint sat idle, and the whole desktop mix went
through it before reaching Opus. A silent sink now has to EARN its preference — if its mix
format narrows the mix it drops below real hardware. It is still taken when nothing better
exists (narrow audio beats no audio), but flagged so the capture side says why.

`plan_with_formats` takes a probe rather than reading WASAPI, so all 26 wiring-plan tests
still run on every platform. An unknown format counts as fine, which is asserted:
`unknown_formats_reproduce_the_formatless_plan` proves a probe failure can never make the
plan worse than it was before formats existed.

**WP0.1 — log the endpoint's ACTUAL mix format.** Everything the old log printed ("48 kHz
f32 channels=2") was our REQUEST; with `autoconvert` WASAPI converts silently from whatever
the endpoint really runs. That is why a 3,600-line log filed over an audio-quality
complaint contained nothing that could diagnose it.

**WP0.2 — count what we drop.** The capture->encode handoff was a silent lossy `try_send`:
a stalled encode thread lost chunks, the encoder concatenated across the hole, and nothing
recorded it — a click plus a permanent shift of everything after. Now counted and warned,
alongside per-window peak/RMS/delivered% so a quiet host, a broken endpoint and a stream we
are damaging ourselves stop looking identical.

**WP2.4 — stop the default-device tug-of-war.** In Assert mode the capture is bound to the
planned endpoint EXPLICITLY, so a hijacked default changes only where apps render — the old
full reopen tore the capture down for nothing. The field log shows the cost: something
re-set the default every ~4 s and each round was a teardown, a wiring pass with
IPolicyConfig writes, and an audible dropout — seven in sixteen seconds, one ending in a
2 s error backoff. Now: put the default back, keep the stream, and after four rounds in
twenty seconds concede for a minute and say so once.

**WP1.1/1.2 — encode quality.** Constrained VBR (the hard-CBR comment justifies itself with
GameStream's audio FEC, which this plane does not have) and `AudioTier::High` by default:
stereo 128 -> 256 kbps, ~1 % of a 20 Mbps session. GameStream's encoder is deliberately
untouched — its FEC really does need fixed-size packets.

**WP3.1 — redundant `0xD2` plane**, sent when the client asked for it.

**WP2.2 — `audio.output_mode`** as a first-class setting (`client_only` / `host_and_client`
/ `follow_default`), superseding the two undocumented env vars, which stay honoured. The
enum lives in pf-host-config, which is deliberately dependency-free, so the tier table stays
in core where the codec knowledge is.

`capture_policy.rs` is split out for the same reason `wiring_plan.rs` is: both encode field
behaviour, so their tests must run on Linux CI, not only on a Windows box. That split
immediately earned itself — `capture_stats_separate_silence_from_signal` caught RMS being
divided by the FRAME count while summed over interleaved SAMPLES, which inflated it by
sqrt(channels) and made a sine report an RMS equal to its own peak.

WP4.5 (open the loopback at the minimum device period) is deliberately NOT done: in shared
mode `IAudioClient::Initialize` cannot change the engine period at all, so it would be a
no-op at best and a new failure path at worst. Recorded in the code. WP2.3 (force the parked
endpoint's volume) is deferred — `wasapi` keeps IMMDevice private, so it needs new raw COM
on a path this tree cannot compile, let alone test; its diagnostic half ships as the RMS
line above.

Verified: punktfunk-host + pf-host-config clippy --all-targets -D warnings and the audio
test suite under Linux/docker (gate proven non-vacuous with a planted type error); 26
wiring-plan tests standalone; fmt. The Windows-only halves of wasapi_cap.rs and
audio_control.rs are NOT compile-verified anywhere yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 09:02:12 +02:00
enricobuehler 3eab1e41df Merge remote-tracking branch 'origin/main' into worktree-haptics-m1-rumble-seq
ci / web (pull_request) Successful in 1m42s
ci / docs-site (pull_request) Successful in 1m40s
ci / rust-arm64 (pull_request) Successful in 3m22s
apple / swift (pull_request) Successful in 1m23s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 4m8s
ci / rust (pull_request) Successful in 8m11s
2026-08-03 19:47:32 +02:00
enricobuehler 9a52c279f1 Merge branch 'main' into feat/android-pad-audio
ci / web (pull_request) Successful in 1m24s
android / android (pull_request) Successful in 3m55s
apple / swift (pull_request) Canceled after 0s
apple / screenshots (pull_request) Canceled after 0s
ci / rust (pull_request) Canceled after 5m1s
ci / rust-arm64 (pull_request) Canceled after 3m22s
ci / docs-site (pull_request) Canceled after 1m35s
windows / build (aarch64-pc-windows-msvc) (pull_request) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (pull_request) Canceled after 0s
2026-08-03 17:39:07 +00:00
enricobuehler 5be494f490 merge: bring main into the pad-audio branch
ci / web (pull_request) Successful in 1m0s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 1m2s
apple / swift (pull_request) Successful in 1m18s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m51s
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 34s
ci / rust-arm64 (pull_request) Successful in 2m48s
android / android (pull_request) Successful in 3m49s
ci / rust (pull_request) Successful in 5m40s
Main had moved 34 commits past the merge-base and 13 files had diverged.
Resolving now rather than later, since the force-feedback sweep work is landing
in the same files.

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

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

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

NOT verified: audio_control.rs and wasapi_cap.rs are cfg(windows), so neither
the Linux container nor xcheck.sh compiles them. Those two resolutions have had
review only and need the Windows runner before this merges.
2026-08-03 19:11:26 +02:00
enricobuehler 48565c4e9e fix(host/abr): stop pinning Automatic sessions, and tell the client when the rate moves
Two host-side halves of the same sweep.

**The cadence latch.** `cadence_degraded` — which makes the control task refuse
bitrate CLIMBS — was latched true for as long as the session was escalated
(adaptive capture depth or pipelined retrieve), independently of whether encode
was still missing deadlines. The client cannot tell that refusal apart from an
encoder's real ceiling: both arrive as a short `BitrateChanged`, and two
identical ones latch a cap. Escalation needs ~20 net behind-frames, which a
startup hitch supplies while the ABR is still in slow start at the 20 Mbps
default — so one transient pinned the whole session there, long after the
escalation had bought back the headroom it was for, and escaping cost +12.5 %
per 60 s. An escalated session is still judged strictly (ANY net behind-frame
keeps it flagged, where an unescalated one gets the full bucket), but being
escalated no longer flags it by itself: escalating exists so cadence CAN be
held, and once it is, refusing climbs refuses the thing that worked. The rule
moves into `encode_behind_cadence` so it is stateable and testable.

**The silent re-target.** `adopt_built_bitrate` publishes the rate a rebuilt
pipeline actually opened at — `build_pipeline` re-resolves an Automatic rate
whenever the source delivers a size the session did not negotiate, the
mirrored-panel case — and the encoder's own clamp can land below what the
control task already acked. Neither reached the client, whose controller keeps
its own copy of that number as its climb base. A 1080p client mirroring a 4K
panel therefore believed 20 Mbps while the host encoded 60, and its first climb
computed from the stale base asked for 40: a re-target DOWNWARD, paying an
encoder rebuild to get there. Both paths now push the applied rate to the
control task, which sends `BitrateChanged` — the existing 9-byte message, which
already means precisely this and which clients already handle arriving
unprompted. No wire-format change, no capability negotiation, old clients
unaffected.

2 host tests added.
2026-08-03 18:53:02 +02:00
enricobuehler 14502769e0 fix(host/input): rumble comes back when a controller does
android / android (pull_request) Failing after 18s
ci / docs-site (pull_request) Successful in 1m7s
ci / web (pull_request) Successful in 1m9s
apple / swift (pull_request) Successful in 1m15s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m45s
ci / rust (pull_request) Successful in 6m9s
Unplug a pad mid-session and plug it back in, and roughly half the time it
never rumbles again for the rest of the session.

The removal arm restarted the pad's rumble sequence counter. The client's
reorder gate does not restart: `rumble_last_seq` lives for the whole QUIC
connection and has no reset path, so it still holds whatever the pad reached
before the unplug. Restarting the host counter therefore hands the client a
seq it has already seen, and its wrapping half-space compare drops every
envelope until the counter climbs back past the stored value — up to 128
sends. Since the counter only advances on a level change or a ~120 ms renewal
while a level is non-zero, that spans many separate rumble events, so it reads
as a flaky controller rather than a clean outage.

Whether it bites is decided by how much the pad rumbled beforehand, which is
why it looks intermittent: a pad that never rumbled before the re-plug has
`None` on the client side and always heals.

The counter now survives, matching the sibling pad-state gate — whose comment
eleven lines above already explains that a re-plug must arrive with a still-
newer seq to be accepted. The three clears that actually end the stale lease
move into `clear_pad_feedback`, whose signature deliberately has no seq
parameter so the arm cannot regress by editing.

Covered by a regression test that drives the real wire encoder and the real
client gate, and asserts the pre-fix behaviour is genuinely rejected across
the whole forward window, so it cannot pass vacuously.

Found by the 2026-08-03 force-feedback sweep (B1/T5 — see the backlog in
punktfunk-planning design/haptics-sweep-2026-08-03.md).
2026-08-03 16:57:11 +02:00
enricobuehlerandClaude Opus 5 442ea12b96 test(mgmt): pin the two things that would silently break plugin logging
The runner holds the PLUGIN token and nothing else — on Windows its LocalService
principal cannot read the admin one at all. `plugin_may_access` is an exclusion
list, so `/plugins/logs` is reachable today only because it happens not to match
`/ui-credential`. If that ever changed, plugin logs would go quiet in the console
with no other symptom and no failing test. Now asserted on that lane directly.

The second test covers ingest end to end through `GET /logs`: the `plugin:` target
prefix the console's Host/Plugins filter keys on, the level coercion (an unranked
level would sort as 0 and hide under every filter setting), a sourceless line
being attributed to the runner rather than to nothing, the caller's timestamp
surviving the trip, and an oversized batch being refused whole.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 15:56:25 +02:00
enricobuehlerandClaude Opus 5 365caa23be fix(plugins): plugin output reaches the console's log page, and /tmp is no longer hidden from the runner
A user could not get the VirtualHere plugin to use their VirtualHere client
and asked, reasonably, where the logs were. There was no good answer, and the
reason they were stuck turned out to be ours.

**The runner could not see /tmp.** `punktfunk-scripting.service` set
PrivateTmp=yes, which hands the unit a private tmpfs. But integrating with
things already running on the box is the entire job of a plugin, and on Linux
those talk over /tmp: VirtualHere's client IPC is the FIFO pair /tmp/vhclient +
/tmp/vhclient_response, X11 is /tmp/.X11-unix. So the plugin launched the vendor
binary happily and could then never reach the daemon behind it — while the same
command worked perfectly in the operator's own shell, because that shell has the
real /tmp. No config change could fix it, which is exactly the loop the report
described. PrivateTmp is now off, with /tmp added to ReadWritePaths (which
ProtectSystem=strict would otherwise make read-only).

**Plugin logs now land in the console.** Plugins are not host child processes —
the runner is a separate bun process that import()s each plugin in-process — so
nothing they print passed through the host's tracing, and the console's Logs
page could not show a single plugin line. The fallback was journalctl on Linux;
on Windows the runner's scheduled task writes no log file at all, so a failing
plugin was diagnosable only by stopping the task and re-running the runner by
hand. Both mean shell access on the host box, which is what the console exists
to avoid — and it left the one question a stuck user asks with no answer.

So the runner now tees its output to POST /api/v1/plugins/logs, and those lines
join the host's own ring under one cursor, targeted plugin:<name>. The console
grows a Host/Plugins switch beside the level filter; an empty Plugins view says
the thing that is actually usually wrong (the runner isn't running) rather than
"adjust the filter".

The shipper keeps stdout authoritative — journald and foreground output are
unchanged whatever the host is doing — and is built so that logging can never
hurt the thing being logged: it never throws into a caller, holds a bounded
queue that drops oldest and then says how many, backs off when the host is away
(a restart is normal), and re-sends a batch the host failed to take. Lines
logged while a POST is in flight are kept, which cost one round to get right:
the first version held its recursion guard across the await and silently dropped
exactly the lines a busy plugin produces.

Runner lines that report a failure (a refused unit file, a crashed plugin, a
give-up) now go out at warn/error instead of all arriving as INFO, so the
console's level filter means something for them.

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

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

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

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

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

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

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

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

Run against each other on .173:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 11:01:22 +02:00
enricobuehler 212bdc3b08 fix(devtest): resolve the pad endpoint by system lookup, not the service's cache
apple / swift (pull_request) Successful in 1m25s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m43s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m37s
ci / docs-site (pull_request) Successful in 3m8s
ci / web (pull_request) Successful in 3m31s
ci / rust (pull_request) Successful in 8m16s
android / android (pull_request) Successful in 8m43s
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 14m18s
2026-08-03 10:09:49 +02:00
enricobuehler 45cb525035 wip(host): pad-endpoint tone devtest
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m16s
apple / swift (pull_request) Successful in 1m28s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m34s
ci / docs-site (pull_request) Successful in 2m48s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m28s
ci / web (pull_request) Successful in 3m53s
android / android (pull_request) Canceled after 4m2s
ci / rust (pull_request) Canceled after 4m9s
2026-08-03 10:05:52 +02:00
enricobuehlerandClaude Fable 5 98e040fd01 fix(host/stream): the wire holds the session rate when the display outruns it
apple / swift (push) Successful in 1m20s
ci / rust (push) Successful in 5m0s
android / android (push) Successful in 6m30s
ci / rust-arm64 (push) Successful in 1m50s
apple / screenshots (push) Successful in 5m37s
arch / build-publish (push) Successful in 8m0s
ci / web (push) Successful in 1m28s
ci / docs-site (push) Successful in 1m36s
deb / build-publish-client-arm64 (push) Successful in 2m19s
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 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 6s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 13s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 16s
windows-host / package (push) Successful in 13m17s
windows-host / winget-source (push) Skipped
deb / build-publish-host (push) Successful in 5m33s
deb / build-publish (push) Successful in 5m39s
docker / builders-arm64cross (push) Successful in 7s
docker / deploy-docs (push) Successful in 24s
windows-host / canary-manifest (push) Successful in 15s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m56s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m18s
PUNKTFUNK_VDISPLAY_HZ_MULT promises extra display refreshes without one
extra frame on the wire, but the frame-driven trigger enforced its pace only
as a per-gap floor: sleep to 0.9×interval, then wake on arrival. A source
that always has a frame pending — the overdriven display under uncapped
content — settled at 0.9-interval spacing, 1.11× the negotiated rate. That
is the field report's 132 fps on a 120 fps session: ten percent more
bitrate, encode and decode for frames a 120 Hz panel can only drop.

A credit bucket (PaceBudget) now pins the long-run average at the pacing
rate: credit accrues at one frame per interval of real elapsed time, capped
at 1.25 frames of post-stall burst, and every submitted frame spends one. A
grab may run early only against banked credit, so the 0.9 floor keeps its
per-gap jitter headroom while the average cannot exceed the rate — and a
source at or below it banks faster than it spends and is never delayed.
Anchoring to real elapsed time also keeps the synchronous-encode overlap the
arrival-anchored floor bought (the owed fraction absorbs a constant encode
tail instead of stacking on top of it), and it cannot fight the phase lock's
submit grid: both agree the period is the interval.

The charge lives under the same guard as the gate — the legacy fixed tick
paces by its own grid, and charging it without ever accruing would bank
unbounded debt that stalls the loop if a rebuild later flips the capturer to
arrival-wait.

Verified on .25: native::stream tests 15/15 (three new PaceBudget tests),
punktfunk-host 369/369, clippy -D warnings clean, fmt clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 21:54:09 +02:00
enricobuehlerandClaude Fable 5 5174a59832 fix(capture/kwin): a hidden cursor leaves the stream — KWin's id-0 meta is the hide
Since the 0.22.0 cursor work (the seat-pointer park + the metadata
composite), a KWin capture-model stream always has a cursor — and it never
went away again: not in game, not in Big Picture, not with a controller in
hand (field report, 2026-08-01). The host blended the arrow forever because
pf-capture deliberately ignores SPA_META_Cursor id 0, and once `visible`
latched true nothing on Linux ever cleared it.

Two producer contracts meet on id 0, and one flag now carries which one a
stream follows. KWin rewrites the cursor meta on EVERY enqueued buffer and
writes id 0 whenever Cursor::isOnOutput says the pointer is not in this
stream — which covers both a globally hidden cursor and a client null-cursor
surface (empty geometry intersects nothing). There id 0 IS the hide, and
honoring it is what lets a game hide the pointer mid-stream. Mutter only
rewrites a buffer's meta when the cursor changed, so recycled buffers carry
stale id-0 regions between damage frames — honoring those flickered the
cursor off between hovers (on-glass round 5), and that path keeps its
last-known-state behavior.

The flag rides from the backend that created the output (correct for
registry-pooled reuse too — a kept display only ever matches its own
backend) through capture_virtual_output into the parser's CursorState. The
portal-monitor path stays on the stale-meta contract: the only thing routed
through it today is Mutter's HDR mirror.

Verified on .25: pf-capture 45/45, punktfunk-host 369/369, clippy
-D warnings clean (pf-capture, punktfunk-host, cursor-probe), fmt clean.
On-glass KDE validation still owed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 21:54:09 +02:00
enricobuehlerandClaude Fable 5 652abeb397 fix(host/audio): an unsatisfiable wiring plan waits for an endpoint change instead of hammering
Field case 2026-08: the display isolate invalidated the only real render
endpoint; the mic held the Steam Streaming Microphone, the Speakers were
blacklisted, and the capture loop re-ran the full wiring pass — three
IPolicyConfig SetDefaultEndpoint writes included — every 2 s for 8+
minutes, retrying a verdict that could never change.

- wiring_plan: a plan with no loopback is a typed structural verdict
  (Wiring::loopback_unsatisfiable + an endpoint-set fingerprint); the
  dead leftover() tier (byte-identical to real_hw()) becomes a real last
  resort that accepts ONLY the Steam Streaming Speakers, flagged
  loopback_last_resort — a known-silent-loopback QUALITY risk, never the
  cable/VoiceMeeter echo CORRECTNESS risks. excluded_from_loopback stays
  untouched (judge_default's mid-stream snap-back semantics).
- wasapi_cap: an unsatisfiable plan errors ONCE per topology with the
  render inventory, per-endpoint rejection reasons and only the remedies
  not already taken, then parks on a cheap enumerate-and-hash poll and
  re-plans the instant the set changes; transient failures get a real
  capped exponential backoff (2 s → 60 s, reset on success or set
  change); a last-resort capture re-plans on any set change and promotes
  the 30 s zero-packet breadcrumb to warn.
- audio_control: the recording default is asserted only when the plan
  changed or the default drifted — set_default_endpoint fires all three
  SetDefaultEndpoint roles unconditionally, so the 2 s loop silently
  stomped any operator recording-device change; the "attach one, or let
  the host install the Steam Streaming pair" warn (already satisfied in
  the field case) is replaced by the same diagnosis.

Verified: 19/19 wiring_plan tests (native rustc --test and the Linux CI
image via docker); both Windows files type-check and clippy clean
against wasapi 0.23.0 / windows 0.62.2 for x86_64-pc-windows-msvc via an
xcheck-style stub harness (the in-tree target check dies in
openh264-sys2's build script on macOS, as scripts/xcheck.sh documents).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 09:53:36 +02:00
enricobuehlerandClaude Fable 5 48511d1267 fix(abr): probe throughput is measured over the client's receive interval
The capacity probe divided client-side bytes by the HOST's burst
duration — a window wrong on both edges (base snapshotted before the
burst reached the host, frozen only when the ProbeResult landed, while
the host's clock stops the moment ITS send window closes, before the
switch/kernel queue finishes draining toward the client). On a 1 GbE
link a 2 Gbps burst target "measured" 1266 Mbps and set an 886 Mbps
climb ceiling the link could never carry — permanent for the session,
because set_ceiling never lowers.

The reassembler now stamps probe-scoped counters (bytes, packets,
first/last arrival, monotonic ns) at its FLAG_PROBE routing, so video
around the burst contaminates neither numerator nor denominator; the
throughput divisor is the client's first→last arrival interval, with
the host duration kept as the fallback when fewer than two probe
packets arrived. The user-facing speed test shares the corrected
computation (ProbeOutcome/PunktfunkProbeResult layouts unchanged;
elapsed_ms docs updated to the new semantics).

Two guards ride along:
- PUNKTFUNK_ABR_MAX_MBPS clamps inside set_ceiling — the one funnel
  every learned ceiling passes through — so a user cap binds no matter
  what any probe concludes.
- The controller latches decode_cap_kbps when two CONSECUTIVE backoffs
  carry decode-severe evidence (deep decode excursion or jump-to-live
  flush) at a similar pre-backoff rate, mirroring host_cap_kbps for the
  client decoder: a knee below the link ceiling was a permanent 30-60 s
  sawtooth costing a flush + dropped-frame burst per cycle (1440p120
  HEVC field case, knee ~490 Mbps). One spurious flush never latches;
  the cap re-probes on the CAP_REPROBE_WINDOWS clock, so it lifts when
  the decoder recovers.

Also rights the three stale "3 Gbps" probe-clamp comments (the host
constant has been 10 Gbps since MAX_PROBE_KBPS moved).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 09:53:36 +02:00
enricobuehlerandClaude Fable 5 0d004c4680 fix(host/handshake): the 4:4:4 gate names the encoder backend, not the capturer
capture_supports_444 was an encoder-backend fact (direct NVENC or PyroWave)
logged under a capture-ish name — a field report burned real time hunting a
capture problem because of it. The 'encode chroma' line now says
ingest_chain_supports_444, a requested-but-declined session logs WHICH gate
lost, and the console UI's Full chroma explainer names the real requirement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 09:53:29 +02:00
enricobuehlerandClaude Fable 5 ed3d236ab8 feat(pad-audio): DualSense audio haptics + speaker, host->client end to end
The 0xD1 pad-audio plane streams a DualSense's voice-coil haptics (back
channel pair, 5 ms Opus frames) and speaker (front pair, 10 ms) per pad from
a Windows host to the SDL clients, which render them into a USB DualSense's
own 4-channel audio device.

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 12:07:06 +02:00
enricobuehler ea469162f9 fix(docs): three doc comments start a markdown list they never meant to
apple / swift (push) Successful in 1m14s
ci / rust-arm64 (push) Successful in 3m31s
ci / web (push) Successful in 1m27s
ci / docs-site (push) Successful in 1m53s
apple / screenshots (push) Successful in 5m57s
ci / rust (push) Successful in 8m9s
android-screenshots / screenshots (push) Successful in 1m54s
android / android (push) Successful in 6m53s
deb / build-publish-host (push) Successful in 5m17s
decky / build-publish (push) Successful in 42s
deb / build-publish-client-arm64 (push) Successful in 1m33s
release / apple (push) Successful in 9m50s
arch / build-publish (push) Successful in 11m34s
deb / build-publish (push) Successful in 11m20s
sbom / sbom (push) Successful in 24s
linux-client-screenshots / screenshots (push) Successful in 5m18s
docker / builders-arm64cross (push) Successful in 7s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 6s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 19s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m42s
flatpak / build-publish (push) Successful in 6m44s
docker / deploy-docs (push) Successful in 1m25s
web-screenshots / screenshots (push) Successful in 3m57s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 3m6s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 4m43s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m2s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m51s
windows-host / package (push) Successful in 11m36s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Successful in 21s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m38s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m1s
Windows clippy on the v0.23.0 tag: `doc_lazy_continuation` in
crates/pf-client-core/src/audio_wasapi.rs:37. The cause is one line break —
`+ wire cost.` begins a line, so the markdown parser reads `+` as a bullet
marker and the following line becomes a lazy continuation of that list item.

Fixed by reflowing so the `+` is mid-line rather than by taking clippy's
suggested indent: indenting would keep the accidental bullet in the rendered
docs, which is the actual defect. Same treatment for the two siblings a sweep
of every `///` line found, both invisible to the Linux gate for their own
reasons:

- gamestream/audio.rs:237 — `+ libopus;` at line start, on the
  cfg(not(linux/windows)) stub, so only a macOS clippy would ever see it.
- mgmt/tests.rs:1653 — `404.` at line start IS an ordered-list marker
  (CommonMark: 1-9 digits + `.`), and it is behind cfg(test), so only an
  --all-targets run sees it.

This is the [[Windows clippy sees what the Linux gate structurally cannot]]
shape again: audio_wasapi.rs is cfg(windows), so no amount of Linux CI would
have caught it.

Verified: a scanner over every .rs doc comment in the tree now reports zero
line-initial list markers with an unindented continuation; rustfmt clean (it
does not reflow doc comments, so these edits are stable).
2026-08-01 01:30:45 +02:00
enricobuehler 951bcec650 Merge remote-tracking branch 'origin/main' into audio/mic-latency-echo 2026-08-01 00:47:50 +02:00
enricobuehlerandClaude Fable 5 f3a39df7b3 test(host/audio): prove a reopen recovered with a live uplink, not one frame
`reopens_after_push_death` failed about one run in nine, and widening its
timeout did not help — the earlier commit blamed the backoff and was wrong.

The pump drops whatever queued while it was down: audio from before the
device came back is stale, so a fresh instance drains the channel right
after opening. The harness counts `opens` from the START of the open, so
the moment the test sees the counter move, the pump has not reached that
drain yet. The single frame it then sent landed inside the drain window and
was discarded exactly as designed, leaving the test waiting for audio that
was never going to arrive.

So the test now keeps feeding, which is what a real uplink does and what
the drain assumes. The sequence advances each time or the de-jitter reads
the repeats as duplicates and drops them for a second, correct reason.

Production behaviour is unchanged: this was the test asserting something
the pump never promised.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 00:41:07 +02:00
enricobuehlerandClaude Opus 5 0985726415 fix(host,web): an empty update channel stops looking like a broken host
android / android (push) Canceled after 36s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 37s
ci / rust (push) Canceled after 37s
ci / rust-arm64 (push) Canceled after 36s
ci / web (push) Canceled after 34s
ci / docs-site (push) Canceled after 34s
deb / build-publish (push) Canceled after 19s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-host / package (push) Canceled after 33s
windows-host / winget-source (push) Canceled after 0s
flatpak / build-publish (push) Successful in 5m43s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 6m59s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m9s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 2m19s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
A channel nobody has published to answers `manifest.json` with a 404, and the
check reported that the same way it reports a dead registry or a bad signature:
"Last check failed: feed returned HTTP 404". Every host on the stable channel
shows it today, because the stable manifest only publishes when someone
dispatches `announce` for a release tag — so the first thing an operator sees
from the new Updates card is a red failure caused by nothing being wrong.

The shared checker now distinguishes the two. `feed::fetch_manifest_blocking`
returns a typed `FeedError` instead of a string, and only a 404 on the manifest
ITSELF becomes `NotPublished` — a 404 on the detached signature still fails
loudly, because that is the half-published pair the manifest-then-signature
upload order can produce, and it must stay fail-closed.

The host carries that through as `UpdateStatus.not_published`, mutually
exclusive with `last_error`. It is benign only while no manifest has ever been
seen for the channel: once a check has succeeded, the same 404 means the feed
LOST a document it used to serve, which stays an error. The console then shows a
plain sentence naming the channel instead of the failure banner, and "None
published yet" rather than "Not checked yet".

The Linux client makes the same distinction but deliberately NOT the same
choice: `--check-update` keeps exiting 1 and keeps `error` set, because its
consumer is a shell script and an empty channel is the absence of evidence that
this build is current — not a confirmation that it is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:37:56 +02:00
enricobuehlerandClaude Fable 5 10a1863cc7 test(host/audio): give the reopen tests longer than the backoff they wait for
`wait_until` allowed 200 × 10 ms — exactly the 2 s `backoff_start` the reopen
path spends before it can succeed. On a warm, idle machine it wins the race;
on a cold binary or a loaded box it does not, and `reopens_after_push_death`
failed 3 of ~5 cold runs while gating this branch. CI is always cold.

Six seconds costs nothing when the test passes and only delays a genuine
failure, so the budget now clears the backoff with room to spare.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 00:15:54 +02:00