Compare commits

...
Author SHA1 Message Date
enricobuehler 0d407a866d fix: a host that changed DHCP lease could no longer be streamed from the panel
ci / rust (pull_request) Successful in 7m15s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m58s
ci / web (pull_request) Successful in 1m7s
apple / swift (pull_request) Successful in 1m24s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m54s
ci / rust-arm64 (pull_request) Successful in 3m5s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 3m16s
android / android (pull_request) Successful in 5m2s
An adversarial review of this branch found a regression I introduced, plus three smaller
defects. All four are fixed here, each verified on .21.

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

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

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

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

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

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

Also: `PF_CLIENT_BIN` was the one launch-option value never validated — a client installed under
a path with a space would split Steam's tokenizer.
2026-08-04 21:26:09 +02:00
enricobuehler bf2d8505cf docs(decky): the gamepad-UI shortcut comment still named PF_HOST
PF_HOST is gone; the browse branch is keyed on PF_BROWSE alone and runs the SESSION binary,
which is the one path this rework deliberately did not repoint. Comment only.
2026-08-04 21:08:52 +02:00
enricobuehler 414380fc9e fix(cli): discover reads the host store without writing to it
`KnownHosts::load()` mints a stable id for any record that lacks one and SAVES it — which makes
it a write, and `discover` was calling it purely to annotate what the browse found with
saved/paired. It never hands those ids back to anyone.

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

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

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

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

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

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

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

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

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

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

Also updated: support-matrix (Decky's Profiles and Game library go /⚠️ — the panel shows
pinned profile cards but creates none, and the library lives in the console home),
wake-on-lan (the plugin no longer fires its own packet or stretches the connect budget — the
CLI runs the real wake-and-wait), pairing, game-library, profiles-and-links, input, clipboard
and install-client.
2026-08-04 20:58:11 +02:00
enricobuehler 017c37b78a feat(decky): rebuild the panel as a launcher — nested cards and request access
What is left of the plugin is what only a Decky plugin can do: start a stream through Steam so
gamescope focuses it, and stand in front of the trust decision that gates it. One Quick Access
panel, four sections, no route.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

test-backend.py is rewritten against what is left — argv shape, the exit-code mapping, and the
Steam configset editor, which was untested until now and is the riskiest thing that survived:
it edits a file holding hundreds of other games' bindings, in place.
2026-08-04 20:41:07 +02:00
enricobuehler f84c5b8114 feat(cli): launch --request-access — let the host's operator admit this device
Request access is not a second pairing ceremony, it is a LAUNCH: an ordinary identified
connect with the advertised fingerprint pinned and the handshake budget stretched past
the host's approval window. The host parks the connection until somebody approves the
device in its console or web UI, then admits the same connection and the stream starts
by itself. The desktop shells and the console home have had this for a while
(`SpawnOpts::persist_paired`, `screens/pair.rs`); headless callers had no door to it.

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

Two behaviours, both small:

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

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

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

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

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

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

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

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

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

Default 3 s, capped at 30 — this is called from a Quick Access panel, and a typo'd
`--timeout 3000` would hang that panel with no way to cancel. An empty LAN exits 0: a
caller branching on the code is asking whether the browse ran, and it did.
2026-08-04 20:26:59 +02:00
enricobuehler 454fa2e0cb Merge pull request 'feat(gamepad-ui): profiles integration — pinned cards, pin management, settings section on all three gamepad UIs' (#42) from worktree-gamepad-ui-profiles into main
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 24s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 16s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 13s
deb / build-publish-host (push) Successful in 4m30s
android / android (push) Successful in 7m37s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m50s
deb / build-publish (push) Successful in 5m31s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m5s
docker / builders-arm64cross (push) Successful in 5s
apple / screenshots (push) Successful in 5m57s
arch / build-publish (push) Successful in 8m37s
release / apple (push) Successful in 9m17s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m55s
ci / rust (push) Successful in 8m50s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m19s
docker / deploy-docs (push) Failing after 9m11s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m30s
apple / swift (push) Successful in 1m32s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 52s
ci / web (push) Successful in 1m8s
flatpak / build-publish (push) Successful in 27m16s
ci / rust-arm64 (push) Successful in 1m48s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 12s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 18s
ci / docs-site (push) Successful in 1m51s
deb / build-publish-client-arm64 (push) Successful in 1m21s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15s
Reviewed-on: #42
2026-08-04 18:12:40 +00: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 d1c4cb18dd test(core/session): pin the low-MTU chunk-aligned guarantee at clamped shard sizes
apple / swift (pull_request) Successful in 1m27s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m2s
ci / rust-arm64 (pull_request) Successful in 1m44s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m6s
ci / docs-site (pull_request) Successful in 2m3s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m51s
ci / rust (pull_request) Successful in 8m33s
android / android (pull_request) Successful in 5m21s
PyroWave sessions are gated out of mid-session renegotiation, so a
constrained path serves them through the leg-1 SESSION-START clamp. This
pins the consistency that guarantee rests on: everything chunk-aligned
derives from the one Welcome::shard_payload number — the host
packetizes at it, the client's C-ABI parse window reads it back, and
partial delivery zero-fills exact windows of it — verified at the two
clamp shapes a constrained path actually produces (1216, the
WARP/Tailscale budget, and the 512 floor) over the sealed loopback wire
with real loss.
2026-08-04 20:00:09 +02:00
enricobuehler 91aa684f0d Merge pull request 'docs: Android is on Google Play production, not a closed test track' (#40) from worktree-docs-play-production into main
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m30s
docker / deploy-docs (push) Successful in 37s
ci / web (push) Failing after 29s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 14s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
ci / docs-site (push) Successful in 1m18s
ci / rust (push) Successful in 10m40s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 27s
ci / rust-arm64 (push) Successful in 3m10s
docker / builders-arm64cross (push) Successful in 16s
Reviewed-on: #40
2026-08-04 17:53:23 +00:00
enricobuehler e629606e39 docs: Android is on Google Play production, not a closed test track
ci / rust-arm64 (pull_request) Successful in 1m29s
ci / web (pull_request) Successful in 1m2s
ci / docs-site (pull_request) Successful in 1m12s
ci / rust (pull_request) Successful in 6m48s
Play production access landed 2026-08-01 and the listing is live, but the
docs still told Android users to beg for a tester invite on Discord and
warned that the Play link "only resolves once your account is on the
tester list". Both are now wrong, and the install page is the first thing
a new Android user reads.

Stable is a public Play listing. Canary is unchanged — it still goes to
the invite-only Internal testing track — so each page now draws that line
explicitly instead of describing both as test tracks.

Also corrects the release process: channels.md said CI "never
auto-publishes to the public stores" and that someone promotes alpha ->
production by hand. Since 43e3c7b6 a vX.Y.Z tag publishes to production
at 100% with no further click (android.yml resolves TRACK=production on
refs/tags/v*). Apple is still manual, so that half stands.

Touches install-client.md, clients.md, channels.md, support-matrix.md and
uninstall.md — the last one told people to ask on Discord to be removed
from a tester list that no longer gates the app.
2026-08-04 19:48:59 +02:00
enricobuehler ff5602361f fix(android/gamepad): TV wording points at the Controller-optimized UI toggle
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m55s
apple / swift (pull_request) Successful in 1m18s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m30s
ci / docs-site (pull_request) Successful in 1m20s
ci / web (pull_request) Successful in 1m37s
ci / rust (pull_request) Successful in 8m35s
android / android (pull_request) Successful in 5m22s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m6s
'Created and edited in the touch interface' is dead advice on a TV box — no
touch to reach it with. Unlike tvOS the editor DOES exist on-device (same
APK), behind this screen's own Controller-optimized UI toggle, so on TV the
Profiles strings now name that route instead.
2026-08-04 19:47:20 +02:00
enricobuehler 5e319f3b77 Merge pull request 'fix(client/abr): the decode-cap latch fires on the knee's real presentations' (#36) from worktree-abr-decode-cap-latch into main
windows-host / canary-manifest (push) Successful in 29s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m37s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 13m16s
apple / screenshots (push) Successful in 5m51s
flatpak / build-publish (push) Successful in 9m51s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 13m6s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 1m53s
deb / build-publish (push) Successful in 6m13s
arch / build-publish (push) Successful in 8m42s
ci / web (push) Successful in 1m3s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m9s
apple / swift (push) Successful in 1m24s
deb / build-publish-host (push) Successful in 7m20s
ci / docs-site (push) Successful in 1m43s
ci / rust-arm64 (push) Successful in 2m32s
release / apple (push) Successful in 8m58s
android / android (push) Successful in 5m50s
deb / build-publish-client-arm64 (push) Successful in 3m24s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 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 9s
ci / rust (push) Canceled after 6m55s
windows-host / package (push) Successful in 12m11s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 1m18s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 38s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 27s
docker / builders-arm64cross (push) Canceled after 0s
windows-host / winget-source (push) Skipped
docker / deploy-docs (push) Canceled after 0s
Reviewed-on: #36
2026-08-04 17:46:27 +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 857d7d7b6b feat(android/gamepad): Profiles section + pin-to-hosts dialog in Default settings
GamepadSettingsScreen gains the trailing Profiles section (per-profile rows
with live pin counts, touch-interface explainer) and a console-styled
GamepadPinHostsDialog — controller- and TV-remote-navigable pin management
writing KnownHost.pinnedProfileIds through the existing store path. Pin-add
was previously touch-only; pinned-card rendering and unpin stay as they
were.
2026-08-04 19:41:04 +02:00
enricobuehler f3c0ee47d7 Merge pull request 'fix(client/windows): "Open log folder" stops opening Documents' (#31) from worktree-client-logs-folder-msix into main
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 28s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 1m21s
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
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
ci / web (push) Successful in 1m3s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 31s
ci / docs-site (push) Successful in 1m18s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 20s
docker / builders-arm64cross (push) Successful in 9s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 20s
ci / rust-arm64 (push) Successful in 1m55s
docker / deploy-docs (push) Successful in 25s
ci / rust (push) Canceled after 5m21s
Reviewed-on: #31
2026-08-04 17:41:03 +00:00
enricobuehler 80b4eccff9 feat(apple/gamepad): Profiles section + pin picker in gamepad settings
GamepadSettingsView gains a trailing Profiles section (one row per catalog
profile, live pinned-to-N-hosts counts) and an in-place pin-to-hosts picker
driving HostStore.setPinned — the first pin management reachable from the
controller-first UI, and on tvOS the only possible one. tvOS wording drops
the 'create them in the standard interface' promise (no profile editor
exists there); other platforms keep it. Pinned-card rendering and the
connect path were already in from WP5 and stay untouched.
2026-08-04 19:41:03 +02:00
enricobuehler 63a4f583b9 feat(console): profiles reach the gamepad UI — pinned cards, pin management, settings section
The Skia console now renders a pinned profile card after its host's primary
tile (KnownHost::pinned_profiles resolved by the service thread), connects
with that profile as a one-off via the existing effective_settings resolver,
and shows the bound default profile on the primary tile. The settings screen
gains a trailing Profiles section — one row per catalog profile with a live
pin count — whose activation opens a pin-to-hosts screen; toggles ride the
new ConsoleCmd::SetPin to the binary, which persists pinned_profiles (the
same field the CLI resolves for Decky's host list). Profiles themselves stay
desktop-authored (design client-settings-profiles.md §5.2a, §5.4).
2026-08-04 19:40:53 +02:00
enricobuehler 290d760ea4 feat(core/wire): per-frame shard geometry, jumbo ceiling, Hello advertisement
Phase 0 of mid-session shard-payload renegotiation (planning
design/shard-payload-reneg.md), stacked on the leg-1 MTU resilience. All
three legs are client-side and forward-compatible: deployed clients that
carry them accept a mid-session shard change the moment a future host
sends one, and nothing changes on the wire until then.

- W0.1 — the reassembler's strict shard_bytes firewall becomes per-frame
  pinning: a frame's first-arriving packet pins that frame's shard size
  (bounds-checked to [min_shard_bytes, max_shard_bytes], even), later
  packets must match the pin, and the per-frame block ceiling derives
  from the pinned size (a session-level cap would reject legitimate
  post-shrink frames). The reorder race between an ordered control
  message and unordered video dies structurally: old-geometry frames in
  flight complete under their own pin while new frames arrive under the
  new one, and no cross-geometry splice can land in one buffer. The
  in-flight budget stays byte-based and exact.
- W0.2 — MAX_DATAGRAM_BYTES 2048 → 9216: every receive path (transport
  RECV_BUF, the recvmmsg ring) now accepts sealed jumbo datagrams
  (9000-MTU LAN ≈ 8908-byte shards). Static buffers over resize-on-ack:
  the ring delta is 128 × ~7 KiB ≈ 896 KiB per client session, lazily
  allocated, hosts unaffected. Grep verdict: no embedder uses the
  constant directly, so no C ABI bump — the regenerated header rides
  along (drift gate).
- W0.3 — trailing Hello field max_shard_payload: u16 (0/absent =
  legacy), the append-with-placeholder discipline of video_caps/
  client_caps. One field is both the renegotiation capability flag and
  the jumbo ceiling; core's pump advertises it for all client families,
  the probe too.
- Host seam for Phase 1, dead until wired: Packetizer::set_shard_payload
  (re-derives the block ceilings; construction delegates to it) +
  Session::set_shard_payload (host-only, Config::validate parity).

Verification (the 0.23.0 lesson — geometry changes breed sizing bugs):
the slice-wire suite re-runs at shard 512/1216/1408/8908 (exact-multiple
sweep, lossy + reversed roundtrips, sentinel path, in-flight budget);
mid-stream shrink→grow→revert delivery; the old-geometry reorder race;
cross-geometry splice rejection; firewall bounds non-vacuous both ways;
a 48-case mixed-geometry reorder-torture proptest asserting per-frame
byte-identical DELIVERY and an exactly-zero final budget; and a sealed
loopback session test (continuous crypto/replay) delivering frames
across live re-keys — every test asserts delivered frames, never the
absence of errors.

core: 294/294 --features quic + clippy -D warnings (macOS), fmt.
2026-08-04 19:27:09 +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 7331be0a40 Merge pull request 'fix(audio): the quality root cause, the latency ratchet, and making the plane observable' (#33) from worktree-audio-quality-latency into main
ci / web (push) Successful in 1m4s
apple / swift (push) Successful in 1m28s
ci / rust-arm64 (push) Successful in 2m31s
ci / docs-site (push) Successful in 1m19s
deb / build-publish-client-arm64 (push) Successful in 3m11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 32s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m38s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m58s
deb / build-publish-host (push) Successful in 6m0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
android / android (push) Successful in 7m13s
arch / build-publish (push) Successful in 8m1s
ci / rust (push) Successful in 10m0s
apple / screenshots (push) Canceled after 0s
deb / build-publish (push) Canceled after 8m16s
docker / builders-arm64cross (push) Canceled after 9s
docker / deploy-docs (push) Canceled after 42s
release / apple (push) Successful in 9m17s
flatpak / build-publish (push) Canceled after 4m52s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 4m52s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 6m48s
windows-host / package (push) Canceled after 5m27s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Reviewed-on: #33
2026-08-04 16:51:34 +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
enricobuehler dbc12dedcc Merge pull request 'fix(client/ios): a click wins the pointer back after Escape drops it' (#34) from worktree-ipad-click-relock into main
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 15s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 14s
ci / web (push) Successful in 58s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
apple / swift (push) Successful in 1m19s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 9s
ci / docs-site (push) Successful in 1m24s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 25s
ci / rust-arm64 (push) Successful in 1m41s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 28s
docker / builders-arm64cross (push) Successful in 25s
docker / deploy-docs (push) Successful in 40s
apple / screenshots (push) Successful in 6m7s
release / apple (push) Successful in 11m50s
ci / rust (push) Successful in 26m18s
Reviewed-on: #34
2026-08-04 16:20:47 +00:00
enricobuehlerandClaude Opus 5 2dfb7791a2 fix(apple): the drift test tripped Swift's static exclusivity check
apple / swift (pull_request) Successful in 1m27s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m36s
ci / docs-site (pull_request) Successful in 1m44s
ci / rust-arm64 (pull_request) Successful in 2m15s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m3s
android / android (pull_request) Successful in 4m11s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m17s
ci / rust (pull_request) Successful in 10m50s
CI caught what my local harness could not: reading `huge.count` inside the closure that already
holds `huge` exclusively is an exclusivity violation, so PunktfunkKitTests failed to compile.

The blind spot is worth recording. I verified `AudioRing` by compiling it against a standalone
harness whose bodies were TOP-LEVEL code, where Swift applies DYNAMIC exclusivity — the same
statement in a function body gets the static check and is a hard error. A harness that does not
share the shape of the thing it stands in for can be green for a reason the real build does not
have. The harness now puts every body in a method and compiles with
`-enforce-exclusivity=checked`.

Length now comes off the buffer pointer (`$0.count`), which is what the closure already owns.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 18:14:24 +02:00
enricobuehler 5e19a4611f fix(client/abr): the decode-cap latch fires on the knee's real presentations
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m26s
ci / rust (pull_request) Successful in 10m22s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m4s
ci / rust-arm64 (pull_request) Successful in 1m55s
android / android (pull_request) Successful in 3m32s
ci / docs-site (pull_request) Successful in 1m37s
apple / swift (pull_request) Successful in 1m21s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m10s
The client-decoder knee latch (decode_cap_kbps) was unreachable in
production — zero "decode cap learned" lines across every field log, while
its own doc named the exact sawtooth it exists to end (the 2026-08-03
1440p120 field trace: 220↔450 Mbps for nine minutes, five knee backoffs,
no latch):

- The ordinary two-bad-window backoff — the knee's most common
  presentation, a standing 15–45 ms decode rise below the severe tier —
  carried no decode evidence at decision time, because evidence was judged
  from the deciding window alone. Worse, the backoff the decode signal
  itself caused then RESET the knee streak. Now the streak carries its own
  attribution (streak_decode_windows): a backoff whose bad windows were
  all decode-flagged is decode evidence.

- A cascade's second backoff can never agree with the first: a live host
  acks the ×0.7 request in ~100 ms, so the second sample always sits at
  the reduced rate — outside the ±1/8 similarity band by construction
  (0.7 < 7/8). The canonical test never acked between its backoffs, which
  is how the premise survived. Now a backoff only samples a rate the
  controller climbed back to (climb_since_backoff, armed by any ack that
  raises the rate); a drain-time backoff neither latches nor erases the
  reference the real knee set.

- A keyframe-ask storm on a clean link (the Steam Deck presentation: the
  overdriven decoder wedges and begs instead of queueing — 14–19 asks at
  ~300 Mbps with loss_ppm=0 in the field traces) is decode evidence too;
  with real loss present the asks stay network-attributed.

The reworked tests model the ack round-trip (choke → ack → re-climb →
choke), including a regression test replaying the field trace's rates and
decode figures, which must latch at its second knee encounter.
2026-08-04 18:08:49 +02:00
enricobuehler 6f54fcdd2d fix(client/ios): a click wins the pointer back after Escape drops it
ci / docs-site (pull_request) Successful in 1m12s
ci / web (pull_request) Successful in 1m7s
apple / swift (pull_request) Successful in 1m26s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m41s
ci / rust (pull_request) Successful in 13m38s
Pressing Escape mid-stream on an iPad leaves the capture in a state it
could never leave: iPadOS releases the pointer lock by itself, a bare
Escape deliberately never clears `captured` (it is a game key), and the
re-lock burst added with the Escape-drop fix is the only thing that ever
asks for the lock back. That burst fires in the 0.6 s immediately after
the platform's own "let me out" gesture — precisely when it is least
likely to be granted — and once its budget is spent nothing re-asks:
`setCaptured` is the only other requester, and `captured` never went
false. The capture then spends the rest of its life on the absolute
pointer path, which is why the field report reads the way it does —
clicks still land exactly where you aim, because absolute positions keep
forwarding, but the game receives no relative deltas and camera look is
dead for the rest of the session.

Make the click the second stage of the recovery. A click into the video
while captured-but-unlocked now re-anchors the lock chain and re-asks,
which is the request the platform actually wants: a genuine user
gesture rather than an app grabbing the pointer straight back.

Asked on the button UP, so the click has fully forwarded on one
transport first — asking on the DOWN can flip `gcMouseForwarding`
mid-click and strand the release on the GCMouse path. Gated on
`pointerLockWasEngaged`, exactly as the drop path is, so a scene that
never qualifies (Stage Manager, Split View) is never bursted at, and on
no burst already being in flight, since a pending burst mutes absolute
motion and re-arming one per click would freeze the cursor between
clicks of a menu the user is still aiming around.

Worst case is now today's behaviour rather than a permanent one: a
refused burst settles, and the next click tries again.

Typechecked for arm64-apple-ios17.0 (PunktfunkKit builds clean). NOT yet
verified on glass — the premise that a click-driven re-request is
honoured is exactly what the previous fix got wrong.
2026-08-04 18:07:07 +02:00
enricobuehlerandClaude Opus 5 c6597cbeb5 docs(troubleshooting): the audio quality knobs are a request, not a guarantee
android / android (pull_request) Failing after 2s
apple / swift (pull_request) Failing after 1m13s
ci / docs-site (pull_request) Successful in 1m14s
ci / web (pull_request) Successful in 1m20s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m59s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 3m50s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m26s
ci / rust (pull_request) Canceled after 10m32s
The page claimed "audio is a fraction of a percent of a stream's bandwidth, so high costs
nothing worth counting". At 256 kbps plus redundancy that is 512 kbps — true of a 20 Mbps
session, wrong by an order of magnitude on a 5 Mbps one, which is why the budget now exists.
Says what actually happens on a narrow link, and points at the log line that reports the
settled tier.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 17:58:39 +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 e9a209ef61 docs(troubleshooting): why streamed audio can sound worse than the host, and the knobs
WP0.4. The 2026-08-03 reporter had no way to know their desktop mix was being routed through
Steam's voice-carrier endpoint, and no documented way to change it — `PUNKTFUNK_HOST_AUDIO`
existed only in a module doc comment.

Two new sections: what the host actually captures (a render endpoint, not "the sound card"),
what the new `engine_hz/engine_ch/engine_bits` log line tells you, and the
`PUNKTFUNK_AUDIO_OUTPUT_MODE` / `_QUALITY` / `_REDUNDANCY` knobs — with host_and_client called
out as the quickest A/B for the endpoint question; and why audio that lags the picture should
now correct itself, plus what to check when it does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 09:42:06 +02:00
enricobuehlerandClaude Opus 5 a12f1f092c feat(clients/audio): one de-jitter policy for all four rings, and lossless single-packet recovery
Phase 4 + WP3.2 of design/audio-quality-and-latency.md.

**The defect.** Every client ring primed *up* to a target and clamped at a ceiling, and none
walked the depth back *down*. Any transient — a Wi-Fi arrival burst, a host stall, or plain
host-DAC-vs-client-DAC skew of a few dozen ppm — therefore added latency permanently, until
an underrun happened to re-prime. Android, with no shed at all, converged on its 120 ms hard
cap and stayed there for the rest of the session; that is the "audio latency is too high"
report. Apple did shed, 40 ms in one go, which its own comment called "one audible blip".

All four now share `punktfunk_core::audio::JitterPolicy`: depths in MILLISECONDS rather than
device quanta (`3 x quantum` meant 15 ms at a 5 ms quantum and a silent 64 ms at a 20 ms
one), a crossfaded 5 ms shed once the depth average has sat above target for 2 s of consumed
audio, and de-prime hysteresis. Linux and Windows had never had that hysteresis — they still
carried the `if ring.is_empty()` instant re-prime that Android identified as self-inflicted
crackle, where one transient drain manufactured a whole target's worth of silence.

Android's floor drops 40 -> 25 ms: the policy grows the target on the devices that actually
underrun, instead of every device pre-paying for the worst one. The Windows ring moves from
raw bytes to interleaved f32 so it can share the policy and the crossfade helper at all.

Apple is the one client where the policy is hand-written in a second language, so it gets
its own XCTest (`AudioRingDriftTests`). Verified here by compiling `AudioRing.swift`
standalone against a simulation harness — +200 ppm for 5 minutes settles at 30 ms with zero
silent callbacks, where the old ring would have ridden its 80 ms high-water mark.

**WP3.2 — recovery lives in core, not in the clients.** The rebuilt frame is re-inserted into
the demux queue in order, so every embedder (including any C-ABI consumer) gets a complete
stream without knowing the `0xD2` plane exists, and their `AudioGapTracker` simply stops
seeing the gap. `recovery_and_the_gap_tracker_agree` pins exactly that. For the same reason
core advertises CLIENT_CAP_AUDIO_RED itself rather than making four embedders remember to.

Verified: clippy --all-targets -D warnings and the full test suites for punktfunk-core,
pf-client-core, punktfunk-host, pf-host-config under Linux/docker (163 + 61 tests);
punktfunk-client-android `cargo ndk check` for aarch64 with the gate proven non-vacuous by a
planted type error, and its 6 clippy findings confirmed IDENTICAL to the pristine file (all
are the documented arm64-only artifacts); AudioRing.swift type-checked and simulated on
macOS; fmt. The Windows client half (audio_wasapi.rs) is still not compile-verified anywhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 09:28:01 +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
enricobuehlerandClaude Opus 5 7077b0a0df feat(core/audio): bitrate tiers, a shared de-jitter policy, and a redundant audio plane
Foundation for the audio quality + latency plan (design/audio-quality-and-latency.md).
All three pieces are pure and unit-tested here so the four client rings and the Windows
host glue that follow stay thin.

**Bitrate tiers** (`AudioTier`). The layout table's `bitrate` becomes the `Standard`
value, so that tier reproduces the pre-tier wire byte-for-byte — the tier machinery is
provably non-regressive. `High` (stereo 256 kbps) is the default: 5 ms Opus frames are
much less efficient than 20 ms ones, so the historical 128 kbps buys roughly what
~100 kbps buys at 20 ms, while the same session carries tens of Mbps of video. Purely a
host-side encoder knob — libopus reads the bitrate out of the packet, so no client
change and no negotiation.

**`JitterPolicy`** — the ms-denominated de-jitter state machine every client will share.
Two defects it exists to fix: (1) each ring computed its target as `3 x quantum`, a sane
15 ms at a 5 ms quantum and a silent 64 ms at a 20 ms one; (2) every ring primed *up* and
clamped at a ceiling, and none walked the depth back *down*, so drift/bursts added latency
permanently — Android, with no shed at all, converged on its 120 ms cap. Here a depth EWMA
that sits above target for 2 s of consumed audio sheds ONE 5 ms frame with a crossfade.
Driven by samples consumed rather than the wall clock: allocation- and syscall-free (safe
in a realtime callback) and deterministic under test.

`every_preset_sheds_before_it_trims` pins the invariant that makes this real rather than
decorative. The first draft had `headroom_ms` <= the shed threshold on all four presets,
so the ring was trimmed back before the average could ever reach the shed point: drift
correction was dead code and the ratchet test passed for the wrong reason (the hard cap
did the work). `a_transient_burst_does_not_shed` caught it. The shed point is now derived
from `headroom_ms` so it cannot invert again.

**`0xD2` redundant audio** — each datagram carries its frame plus a copy of the previous
one, so a single lost packet is reconstructed instead of concealed. Opus in-band FEC
cannot do this job: LBRR is a SILK feature and the desktop encoder is CELT-only
(RESTRICTED_LOWDELAY, 5 ms), so `set_inband_fec` there is a no-op. Costs no latency —
the copy rides the successor, which arrives inside de-jitter slack that already exists.
Gated capable-and-agreed via CLIENT_CAP_AUDIO_RED/HOST_CAP_AUDIO_RED; every other session
keeps the `0xC9` wire unchanged. 0xD1 is left free for the pad-audio program.

cbindgen: prefix the four new exported constants. `FRAME_MS`/`SAMPLE_RATE_HZ` as bare C
macros are the same hazard the BTN_* renames already document — a clashing #define takes
the last definition silently rather than failing to compile.

Verified: 300 core tests, clippy -D warnings, fmt. (`c_abi` fails identically on a
pristine tree — this Mac has no system libopus for the C harness link.)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 08:11:35 +02:00
enricobuehler e5453aebb7 fix(client/windows): "Open log folder" stops opening Documents
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 3m5s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 3m55s
ci / rust (pull_request) Successful in 7m46s
ci / web (pull_request) Successful in 59s
ci / docs-site (pull_request) Successful in 1m18s
ci / rust-arm64 (pull_request) Successful in 1m36s
The button shipped in d839f4c2 opens the user's Documents folder instead of the log
directory on every packaged install. Nothing is wrong with the button — the path is.

The client ships as a full-trust MSIX package, and Windows redirects a packaged app's
%LOCALAPPDATA% writes into its private ...\Packages\<family>\LocalCache\Local\. The log
module creates and appends through that redirection without ever seeing it, so the
literal %LOCALAPPDATA%\punktfunk\logs it hands out is right to WRITE to and names a
directory that never exists on disk. Explorer runs outside the container: it resolves the
literal path, finds nothing, and — instead of failing — silently falls back to Documents.
An unpackaged dev run creates that directory for real, which is why this only ever showed
up in the field.

Two more places handed the same phantom path straight to the user, both added by the same
commit and both wrong in the same way: the "client log file" startup line, and the
failed-spawn banner's "Check <path>" — the one people are told to follow after a session
dies. Anyone who did landed in an empty or absent directory.

So the fix is one resolver, not three call-site patches. `real_dir` canonicalizes the
directory it just created, which resolves through the redirection on a packaged run and
changes nothing on an unpackaged one — no package identity to detect, no LocalCache path
to hand-assemble. `log_dir` stays as the write path and goes private so a future caller
can't reach for the wrong one; `path` now resolves too, which fixes both messages.

`canonicalize` always returns a `\\?\` verbatim path and Explorer refuses those (taking
the same silent Documents fallback), so `strip_verbatim` undoes the prefix — including
the `\\?\UNC\` form a roaming profile on a share resolves to. The button additionally
guards on `is_dir()`: if the resolve ever comes back wrong, the click does nothing rather
than landing the user somewhere misleading again.
2026-08-04 07:46:43 +02:00
93 changed files with 8466 additions and 4031 deletions
@@ -50,10 +50,12 @@ import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.security.ClientIdentity
import io.unom.punktfunk.kit.security.KnownHost
import io.unom.punktfunk.models.PendingTrust
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -250,6 +252,139 @@ fun GamepadHostOptionsDialog(
}
}
/**
* The pin-to-hosts picker the settings screen's Profiles section opens — the Android mirror of the
* desktop console's PinHostsScreen (design §5.2a): one toggle row per SAVED host, D-pad up/down
* moves, A flips the focused pin, left/right unpins/pins (the settings-toggle semantics), B closes.
* A toggle is presentation only: it edits the host's pinned cards through the same store write the
* carousel's unpin uses, never the profile itself and never the host's default binding.
*
* Pin state is read live from [pinned] (backed by the host records), so what a switch shows is
* always what the store holds — the row can't disagree with the carousel it feeds.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun GamepadPinHostsDialog(
profileName: String,
hosts: List<KnownHost>,
pinned: (KnownHost) -> Boolean,
onToggle: (KnownHost) -> Unit,
onDismiss: () -> Unit,
) {
// 0..hosts.lastIndex = host rows, hosts.size = the Done button (with no hosts, index 0 IS
// Done, so it starts focused).
var focus by remember { mutableIntStateOf(0) }
BackHandler(onBack = onDismiss)
GamepadNavEffect2D(
active = true,
onDirection = { dir ->
when (dir) {
NavDir.UP -> if (focus > 0) focus--
NavDir.DOWN -> if (focus < hosts.size) focus++
// Directional = state-targeted (left → unpinned, right → pinned), so holding a
// direction can't oscillate; asking for the state it's already in is a no-op.
NavDir.LEFT -> hosts.getOrNull(focus)?.let { if (pinned(it)) onToggle(it) }
NavDir.RIGHT -> hosts.getOrNull(focus)?.let { if (!pinned(it)) onToggle(it) }
}
},
onActivate = {
val kh = hosts.getOrNull(focus)
if (kh != null) onToggle(kh) else onDismiss()
},
)
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
Box(
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)),
contentAlignment = Alignment.Center,
) {
Column(
Modifier
.padding(24.dp)
.widthIn(max = 520.dp)
.heightIn(max = maxCardHeight)
.clip(RoundedCornerShape(24.dp))
.background(Color(0xF01A1730))
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp))
.padding(28.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(
"Pin “$profileName",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Column(
Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
if (hosts.isEmpty()) {
DialogText("No saved hosts yet — pair with a host first, then pin this profile to it.")
} else {
DialogText("A pinned profile appears as its own card on the host — one press connects with it.")
hosts.forEachIndexed { i, kh ->
PinHostRow(
label = kh.name,
on = pinned(kh),
focused = i == focus,
onClick = { onToggle(kh) },
)
}
}
Spacer(Modifier.size(4.dp))
DialogButton(
"Done",
focused = focus == hosts.size,
primary = true,
enabled = true,
onClick = onDismiss,
)
}
}
}
}
/** One host's pin toggle: name + a [ConsoleSwitch], with the shared console focus visuals. */
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun PinHostRow(label: String, on: Boolean, focused: Boolean, onClick: () -> Unit) {
val visuals = animateConsoleFocus(active = focused)
// Inside the dialog's scroll region, like DialogButton: a focused row scrolled out of a short
// landscape window pulls itself into view.
val intoView = remember { BringIntoViewRequester() }
LaunchedEffect(focused) { if (focused) intoView.bringIntoView() }
val shape = RoundedCornerShape(14.dp)
Row(
Modifier
.fillMaxWidth()
.bringIntoViewRequester(intoView)
.graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
.clip(shape)
.background(visuals.background)
.border(1.dp, visuals.border, shape)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onClick,
)
.padding(horizontal = 16.dp, vertical = 13.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.weight(1f))
ConsoleSwitch(on = on, focused = focused)
}
}
/**
* Console counterpart of [SpeedTestDialog]. Same measurement, same targeting rule — a TV box on a
* powerline adapter is exactly the machine whose link is worth measuring, so this belongs on the
@@ -57,6 +57,8 @@ import androidx.compose.ui.unit.sp
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import io.unom.punktfunk.kit.deviceBodyVibrator
import io.unom.punktfunk.kit.security.KnownHost
import io.unom.punktfunk.kit.security.KnownHostStore
// The gamepad-driven settings screen — the Android mirror of the Apple client's GamepadSettingsView:
// the couch-relevant subset of the touch settings restyled as a console page and fully navigable with
@@ -72,6 +74,8 @@ private class GpRow(
val adjust: (Int) -> Boolean, // left/right; returns whether the value actually changed
val activate: () -> Unit, // A → cycle forward (wrapping) / flip
val toggled: Boolean? = null, // non-null = a toggle row, drawn as a ConsoleSwitch (not text)
val adjustable: Boolean = true, // false = the row navigates/acts instead of stepping — no chevrons
val enabled: Boolean = true, // dimmed + inert when false (still focusable, for its detail)
)
@Composable
@@ -89,7 +93,39 @@ fun GamepadSettingsScreen(
val hasBodyVibrator = remember { deviceBodyVibrator(context) != null }
// Gates the AV1 codec row the same way the touch settings do (see `codecOptionsFor`).
val av1Capable = remember { io.unom.punktfunk.kit.VideoDecoders.pickDecoder("video/av01") != null }
val rows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update)
// The Profiles section's stores, constructed here the way ConnectScreen constructs its own.
// The catalog is read once per screen entry: this screen can't create or edit profiles
// (design §5.4 — the touch interface does), so the list is stable for its lifetime. The saved
// hosts DO change under it — every pin toggle writes one — so they live in state and refresh
// on each toggle, keeping the "Pinned to N hosts" counts honest.
val knownHostStore = remember { KnownHostStore(context) }
val profileStore = remember { ProfileStore(context) }
val profiles = remember { profileStore.all() }
var savedHosts by remember { mutableStateOf(knownHostStore.all()) }
// The profile whose pin-to-hosts picker is up, or null. While it's showing, it owns the pad
// (this screen's nav gates on it, the ConnectScreen-dialog pattern).
var pinProfile by remember { mutableStateOf<StreamProfile?>(null) }
// Toggle a host+profile pin — the same store write ConnectScreen's togglePin does. Presentation
// only: pin appends at the end (card order), unpin removes, and the host's default binding
// (profileId) is never touched.
fun togglePin(kh: KnownHost, profile: StreamProfile) {
val pins = if (profile.id in kh.pinnedProfileIds) {
kh.pinnedProfileIds - profile.id
} else {
kh.pinnedProfileIds + profile.id
}
knownHostStore.save(kh.copy(pinnedProfileIds = pins))
savedHosts = knownHostStore.all()
}
// On a TV "the touch interface" is confusing advice (no touch to reach it with) — the honest
// path there is this screen's own Controller-optimized UI toggle, which swaps in the standard
// interface remote-navigably. The strings branch on it.
val tv = remember { isTvDevice(context) }
val rows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update) +
buildProfileRows(profiles, savedHosts, tv) { pinProfile = it }
var focus by remember { mutableIntStateOf(0) }
if (focus > rows.lastIndex) focus = rows.lastIndex
// The direction the focused value last stepped (+1 forward / -1 back) — drives which way the
@@ -101,7 +137,9 @@ fun GamepadSettingsScreen(
BackHandler(onBack = onBack)
GamepadNavEffect2D(
active = navActive,
// The pin picker owns the pad while it's up (its own nav + BackHandler), so this screen
// drops its probes — the pattern ConnectScreen's dialogs use.
active = navActive && pinProfile == null,
onDirection = { dir ->
when (dir) {
NavDir.UP -> if (focus > 0) focus--
@@ -162,16 +200,41 @@ fun GamepadSettingsScreen(
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
.padding(ConsoleLegendInset),
) {
// The legend follows the focused row (the desktop console's hints() does the same):
// a profile row doesn't adjust, it opens the pin picker, and the "No profiles yet"
// placeholder does nothing at all — advertising ↔/A on those would be a lie.
val focused = rows.getOrNull(focus)
GamepadHintBar(
listOf(
GamepadHint('↔', Color(0xFF9A93C7), "Adjust"),
// Tappable too (touch escape hatch): Change cycles the focused row, Done leaves.
PadGlyph.hint('A', "Change") { rows.getOrNull(focus)?.activate() },
PadGlyph.hint('B', "Done", onClick = onBack),
),
when {
focused != null && !focused.enabled -> listOf(
PadGlyph.hint('B', "Done", onClick = onBack),
)
focused != null && !focused.adjustable -> listOf(
PadGlyph.hint('A', "Pin to hosts") { focused.activate() },
PadGlyph.hint('B', "Done", onClick = onBack),
)
else -> listOf(
GamepadHint('↔', Color(0xFF9A93C7), "Adjust"),
// Tappable too (touch escape hatch): Change cycles the focused row, Done leaves.
PadGlyph.hint('A', "Change") { rows.getOrNull(focus)?.activate() },
PadGlyph.hint('B', "Done", onClick = onBack),
)
},
hazeState = hazeState,
)
}
// The pin-to-hosts picker for the activated profile row — the console counterpart of the
// touch UI's per-profile pin toggles in the host edit sheet.
pinProfile?.let { p ->
GamepadPinHostsDialog(
profileName = p.name,
hosts = savedHosts,
pinned = { kh -> p.id in kh.pinnedProfileIds },
onToggle = { kh -> togglePin(kh, p) },
onDismiss = { pinProfile = null },
)
}
}
}
@@ -180,8 +243,13 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
val visuals = animateConsoleFocus(active = focused)
val shape = RoundedCornerShape(14.dp)
// The chevrons keep their layout slot and only fade, so the value never jumps sideways when
// focus arrives; the value colour cross-fades with them.
val chevronAlpha by animateFloatAsState(if (focused) 0.6f else 0f, tween(160), label = "chevrons")
// focus arrives; the value colour cross-fades with them. A non-adjustable row (a profile row
// navigates, the empty-catalog placeholder does nothing) never shows them at all.
val chevronAlpha by animateFloatAsState(
if (focused && row.adjustable) 0.6f else 0f,
tween(160),
label = "chevrons",
)
val valueColor by animateColorAsState(
Color.White.copy(alpha = if (focused) 1f else 0.6f),
tween(160),
@@ -216,7 +284,9 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
row.label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = Color.White,
// A disabled row (the "No profiles yet" placeholder) dims but stays focusable,
// so its detail line can still explain what would go here.
color = Color.White.copy(alpha = if (row.enabled) 1f else 0.45f),
maxLines = 1,
)
Spacer(Modifier.weight(1f))
@@ -435,3 +505,62 @@ private fun buildSettingsRows(
) { update(s.copy(sc2Capture = it)) },
)
}
/**
* The trailing Profiles section — the Android mirror of the desktop console's (design §5.2a, §5.4):
* one row per catalog profile, valued with how many saved hosts pin it, activating into the
* pin-to-hosts picker. Read-only beyond pinning: profiles are created and edited in the standard
* interface, so an empty catalog shows one dimmed placeholder explaining where they come from
* instead of a dead-looking empty header. On a TV that phrasing changes: "touch interface" points
* nowhere useful on a touchless device, so the strings name the actual route — the
* Controller-optimized UI toggle a few rows up, which swaps the standard interface in
* (d-pad-navigable; the profile editor lives there on every device, unlike tvOS where none exists).
*/
private fun buildProfileRows(
profiles: List<StreamProfile>,
savedHosts: List<KnownHost>,
tv: Boolean,
openPinPicker: (StreamProfile) -> Unit,
): List<GpRow> {
val createHint = if (tv) {
"To create or edit profiles on this device, turn off Controller-optimized UI above " +
"and use the standard interface."
} else {
"Profiles are created and edited in the touch interface."
}
if (profiles.isEmpty()) {
return listOf(
GpRow(
id = "noProfiles",
header = "Profiles",
label = "No profiles yet",
value = "",
detail = "Profiles bundle stream settings for different uses — pinned ones become " +
"one-press connect cards here. " + createHint,
adjust = { false },
activate = {},
adjustable = false,
enabled = false,
),
)
}
return profiles.mapIndexed { i, p ->
// Counted straight off the host records, so it agrees with what the carousel renders.
val pins = savedHosts.count { p.id in it.pinnedProfileIds }
GpRow(
id = "profile:${p.id}",
header = if (i == 0) "Profiles" else null,
label = p.name,
value = when (pins) {
0 -> "Not pinned"
1 -> "Pinned to 1 host"
else -> "Pinned to $pins hosts"
},
detail = "Pin this profile to a host and it appears as its own card — one press " +
"connects with it. " + createHint,
adjust = { false },
activate = { openPinPicker(p) },
adjustable = false,
)
}
}
+54 -56
View File
@@ -12,10 +12,14 @@
//! realtime callback and makes us own the buffer. So this client diverges deliberately to stop the
//! Android-only crackle: (1) the callback is allocation/free-free — decoded buffers are recycled to
//! the producer via a free-list instead of being freed on the audio thread (Android's Scudo `free`
//! has unbounded tail latency); (2) the jitter ring is deeper (~40 ms prime / ~150 ms hard cap) and
//! decoupled from the tiny LowLatency burst size, with de-prime hysteresis so a transient drain
//! doesn't manufacture a silence; (3) the AAudio HW buffer is primed above its 2-burst default and
//! grown on XRuns (Google's anti-glitch technique).
//! has unbounded tail latency); (2) the jitter ring is deeper than the other clients' and decoupled
//! from the tiny LowLatency burst size, with de-prime hysteresis so a transient drain doesn't
//! manufacture a silence; (3) the AAudio HW buffer is primed above its 2-burst default and grown on
//! XRuns (Google's anti-glitch technique).
//!
//! (2) is now the SHARED `punktfunk_core::audio::JitterPolicy` at `JitterTuning::AAUDIO`, which also
//! fixed what this ring was missing: it had a hard cap but nothing that walked the depth back down,
//! so drift and arrival bursts raised latency permanently and Android settled on its ceiling.
use ndk::audio::{
AudioCallbackResult, AudioContentType, AudioDirection, AudioFormat, AudioPerformanceMode,
@@ -34,26 +38,18 @@ const SAMPLE_RATE: i32 = 48_000;
/// Decoded-chunk hand-off depth: 64 × 5 ms = 320 ms slack (matches the core's AUDIO_QUEUE).
const RING_CHUNKS: usize = 64;
// --- Jitter-ring depths, in MILLISECONDS (scaled to interleaved-f32 samples at runtime). --------
// The channel count is negotiated, not a compile-time const, so these are kept in ms and multiplied
// by `ms` (interleaved-f32 samples per millisecond at the resolved layout) inside `start`.
// Unlike the Linux client (PipeWire adaptively rate-matches the stream to the graph clock, masking
// host↔DAC drift + a shallow ring), AAudio hands us a raw callback and we own the buffer: drift and
// WiFi power-save bunching land as underruns/overflows = crackle. So Android runs a deliberately
// deeper, smoothly-managed ring than Linux — keep the two clients' depths intentionally divergent.
/// Prime/target floor: fill to ~40 ms before playing (and after a sustained drain). Deep enough to
/// ride out WiFi arrival jitter + clock drift; the dominant Android-only anti-crackle lever.
const PRIME_FLOOR_MS: usize = 40;
/// Ceiling for the burst-scaled target (so a large quantum can't push the prime depth too high).
const PRIME_CEIL_MS: usize = 80;
/// Drop-oldest headroom above the target before trimming — a ~80 ms band swallows an arrival burst
/// without overflowing.
const JITTER_HEADROOM_MS: usize = 80;
/// Hard latency bound: never let the ring exceed ~150 ms (the only thing that caps added latency).
const HARD_CAP_MS: usize = 150;
/// Re-prime (go silent to refill) only after this many CONSECUTIVE empty callbacks, so one transient
/// drain doesn't manufacture a fresh 40 ms silence (the old `if ring.is_empty()` re-primed instantly).
const DEPRIME_AFTER_CALLBACKS: u32 = 5;
// --- Jitter-ring depths now come from the SHARED policy (`punktfunk_core::audio::JitterTuning`). --
// They used to be four Android-only constants here. The rationale for Android being DEEPER than the
// other clients still holds and is preserved in `JitterTuning::AAUDIO`: unlike PipeWire, which
// adaptively rate-matches the stream to the graph clock and masks host↔DAC drift, AAudio hands us a
// raw callback and we own the buffer, so drift and Wi-Fi power-save bunching land as
// underruns/overflows = crackle.
//
// Two things changed with the move. The prime floor drops 40 ms → 25 ms, because the policy GROWS
// the target on the devices that actually underrun instead of every device pre-paying for the worst
// one. And the ring finally sheds: it had a hard cap but nothing that walked the depth back down, so
// any drift or burst raised latency permanently and Android converged on its 120 ms ceiling and
// stayed there — the "audio latency is too high" report.
/// Throttle the AAudio XRun-driven HW-buffer grow check (cheap, but no need to poll every quantum).
const XRUN_CHECK_EVERY: u32 = 128;
@@ -104,6 +100,7 @@ struct Counters {
pcm_written: AtomicU64, // PCM frames copied out to AAudio (device clock is pulling)
underruns: AtomicU64, // callbacks that emitted silence (ring not primed / drained)
ring_depth: AtomicU64, // ring sample count at the last callback
target_ms: AtomicU64, // the policy's LIVE target depth (it grows on this device's underruns)
}
/// Owned by [`crate::session::SessionHandle`]: the live AAudio stream + the decode thread.
@@ -126,10 +123,9 @@ impl AudioPlayback {
// Interleaved f32 samples per millisecond at this layout (48 kHz × channels); the ms-
// denominated jitter-ring depths scale by it.
let ms = (SAMPLE_RATE as usize / 1000) * channels;
let prime_floor = PRIME_FLOOR_MS * ms;
let prime_ceil = PRIME_CEIL_MS * ms;
let jitter_headroom = JITTER_HEADROOM_MS * ms;
let hard_cap_max = HARD_CAP_MS * ms;
let tuning = punktfunk_core::audio::JitterTuning::AAUDIO;
// Worst transient the ring can hold before the policy trims it.
let hard_cap_max = tuning.hard_cap_ms as usize * ms;
let counters = Arc::new(Counters::default());
// One open attempt at a given sharing mode. Everything the realtime callback captures
@@ -157,8 +153,10 @@ impl AudioPlayback {
// `decode_loop`.
let mut ring: VecDeque<f32> =
VecDeque::with_capacity(hard_cap_max + RING_CHUNKS * 5 * ms);
let mut primed = false;
let mut empties: u32 = 0; // consecutive empty callbacks (de-prime hysteresis)
// Shared de-jitter policy — prime depth, drift correction, de-prime hysteresis. The
// hysteresis this replaces was Android-only; Linux and Windows carried the instant
// `if ring.is_empty()` re-prime until now.
let mut policy = punktfunk_core::audio::JitterPolicy::new(tuning, channels as u8);
let mut cb_count: u32 = 0; // callbacks since open (throttles the XRun grow check)
let mut last_xrun: i32 = 0; // last AAudio XRun count we grew the buffer for
let callback = move |s: &AudioStream, data: *mut c_void, num_frames: i32| {
@@ -173,21 +171,25 @@ impl AudioPlayback {
ring.extend(chunk.drain(..));
let _ = free_tx.try_send(chunk);
}
// Jitter buffer: prime to ~40 ms (prime_floor) before playing and after a sustained
// drain; drop-oldest only above a wide ~120 ms band. Decoupled from the AAudio burst
// `want` (tiny on the LowLatency MMAP path) so the depth doesn't collapse to a single
// quantum.
let target = (3 * want).clamp(prime_floor, prime_ceil);
let hard_cap = (target + jitter_headroom).min(hard_cap_max);
while ring.len() > hard_cap {
ring.pop_front();
// Jitter buffer: the shared policy decides prime/silence, trims a burst, and —
// new here — sheds ONE crossfaded 5 ms frame when the depth average has sat above
// target long enough to be drift rather than jitter. Without that shed this ring
// had no way back down: it clamped at 120 ms and stayed pinned there.
let step = policy.step(ring.len(), want);
if step.drop_front > 0 {
punktfunk_core::audio::crossfade_drop(
&mut ring,
step.drop_front,
step.crossfade,
);
}
if !primed && ring.len() >= target {
primed = true;
}
if primed {
let mut ran_short = false;
if !step.silence {
for slot in out.iter_mut() {
*slot = ring.pop_front().unwrap_or(0.0);
*slot = ring.pop_front().unwrap_or_else(|| {
ran_short = true;
0.0
});
}
cb_counters
.pcm_written
@@ -196,20 +198,15 @@ impl AudioPlayback {
out.fill(0.0);
cb_counters.underruns.fetch_add(1, Ordering::Relaxed);
}
// Re-prime only after a RUN of empty callbacks, not a single transient one —
// otherwise every momentary drain costs a fresh 40 ms silence (the old behaviour,
// self-inflicted crackle on any jitter spike).
if ring.is_empty() {
empties += 1;
if empties >= DEPRIME_AFTER_CALLBACKS {
primed = false;
}
} else {
empties = 0;
}
// No-op while un-primed, so a deliberate priming silence is never counted as an
// underrun (which would otherwise drive the adaptive floor up for no reason).
policy.note_read(ran_short);
cb_counters
.ring_depth
.store(ring.len() as u64, Ordering::Relaxed);
cb_counters
.target_ms
.store(policy.target_ms() as u64, Ordering::Relaxed);
// Google's AAudio anti-glitch technique: when the device reports new XRuns, grow the
// HW buffer by one burst (up to capacity). getXRunCount + setBufferSizeInFrames are
// both callback-safe / non-blocking, and set clamps to capacity so it self-limits.
@@ -408,10 +405,11 @@ fn decode_loop(
}
if count % 600 == 0 {
log::info!(
"audio: opus={count} pcm_frames={} underruns={} ring={} peak={window_peak:.3}",
"audio: opus={count} pcm_frames={} underruns={} buffer_ms={} target_ms={} peak={window_peak:.3}",
counters.pcm_written.load(Ordering::Relaxed),
counters.underruns.load(Ordering::Relaxed),
counters.ring_depth.load(Ordering::Relaxed),
counters.ring_depth.load(Ordering::Relaxed) / ms.max(1) as u64,
counters.target_ms.load(Ordering::Relaxed),
);
window_peak = 0.0;
}
@@ -135,7 +135,7 @@ struct GamepadHomeView: View {
// fullScreenCover, so they become generously sized sheets over the dimmed launcher.
#if os(macOS)
.sheet(isPresented: $showSettings) {
GamepadSettingsView()
GamepadSettingsView(store: store)
.frame(width: 720, height: 640)
}
.sheet(isPresented: $showAddHost) {
@@ -144,7 +144,7 @@ struct GamepadHomeView: View {
}
.frame(minWidth: 640, minHeight: 420)
#else
.fullScreenCover(isPresented: $showSettings) { GamepadSettingsView() }
.fullScreenCover(isPresented: $showSettings) { GamepadSettingsView(store: store) }
.fullScreenCover(isPresented: $showAddHost) {
GamepadAddHostView { store.add($0) }
}
@@ -146,7 +146,9 @@ private struct ShotGamepadHome: View {
}
private struct ShotGamepadSettings: View {
var body: some View { GamepadSettingsView() }
@StateObject private var store = ShotMock.hostStore()
var body: some View { GamepadSettingsView(store: store) }
}
private struct ShotGamepadAddHost: View {
@@ -10,6 +10,14 @@
// on stale captured state. Left/right CLAMPS at a choice list's ends (the dull boundary thud tells
// the thumb it's the last option); A always cycles forward, wrapping, so every option is reachable
// with one button. Toggles read left = off, right = on refusing a no-op with the same thud.
//
// The trailing Profiles section (design/client-settings-profiles.md §5.2a/§5.4) is the pin manager
// for this controller-first surface: a row per catalog profile opens the pin-to-hosts picker an
// in-place swap of the row list (B peels back, the "one layer" rule GamepadAddHostView set) with
// one toggle row per saved host, writing `StoredHost.pinnedProfileIDs` via HostStore.setPinned.
// Pins are presentation only: never the host's default binding, never the profile itself
// profiles are created and edited in the standard interface (and can't be on tvOS, whose
// per-device catalog the detail strings are honest about).
import PunktfunkKit
import SwiftUI
@@ -21,6 +29,10 @@ import CoreHaptics
struct GamepadSettingsView: View {
@Environment(\.dismiss) private var dismiss
/// The saved-host store the pin picker writes `setPinned` through it and the profile rows
/// count pins from its live hosts. Threaded in from GamepadHomeView like the home screen
/// itself (ContentView owns the instance).
@ObservedObject var store: HostStore
@AppStorage(DefaultsKey.streamWidth) private var width = 1920
@AppStorage(DefaultsKey.streamHeight) private var height = 1080
@AppStorage(DefaultsKey.streamHz) private var hz = 60
@@ -52,6 +64,10 @@ struct GamepadSettingsView: View {
@AppStorage(DefaultsKey.rumbleOnDevice) private var rumbleOnDevice = false
#endif
@ObservedObject private var gamepads = GamepadManager.shared
/// The profile catalog (ProfileStore.shared, like every other surface that reads it) the
/// Profiles rows re-derive from it each render, so a rename/delete made in the standard
/// interface shows up live.
@ObservedObject private var profiles = ProfileStore.shared
#if os(iOS)
/// `.compact` in a landscape phone window tighter chrome so more rows fit.
@@ -62,6 +78,9 @@ struct GamepadSettingsView: View {
private let compact = false // no size classes on macOS; the sheet is sized generously
#endif
@State private var focusID: String?
/// The pin-to-hosts picker's profile non-nil swaps the row list for one toggle row per
/// saved host (§5.2a); B (Menu on tvOS) peels back to the settings rows.
@State private var pinTarget: StreamProfile?
/// The direction of the last value step (+1 right/forward, -1 left) picks which edge the
/// changed value slides in from, so the animation follows the user's motion.
@State private var lastAdjustDelta = 1
@@ -72,7 +91,7 @@ struct GamepadSettingsView: View {
focusID: $focusID,
onAdjust: { row, delta in adjust(id: row.id, by: delta) },
onActivate: { activate(id: $0.id) },
onBack: { dismiss() }
onBack: { back() }
) { row, focused in
rowView(row, focused: focused)
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
@@ -80,7 +99,7 @@ struct GamepadSettingsView: View {
}
.frame(maxWidth: .infinity)
.safeAreaInset(edge: .top, spacing: 0) {
Text("Settings")
Text(title)
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(.white)
.padding(.top, gamepadTitleTopPadding(compact: compact))
@@ -96,11 +115,7 @@ struct GamepadSettingsView: View {
.foregroundStyle(.white.opacity(0.55))
.lineLimit(2, reservesSpace: true)
.animation(.smooth(duration: 0.2), value: focusID)
GamepadHintBar(hints: [
.init(glyph: "arrow.left.and.right", text: "Adjust"),
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change"),
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"),
])
GamepadHintBar(hints: hints)
}
// Equal distance from the left and bottom edges for the legend pill (see GamepadHomeView).
.padding(.leading, compact ? 12 : 18)
@@ -138,6 +153,43 @@ struct GamepadSettingsView: View {
.accessibilityLabel("Close settings")
}
/// "Settings", or "Pin Work" while the pin picker is up the title is what says which
/// layer the row list currently is.
private var title: String {
pinTarget.map { "Pin “\($0.name)" } ?? "Settings"
}
/// The legend follows the layer: value-editing hints on the settings rows, pin/unpin on the
/// picker where B reads "Back" (it peels to the settings rows, GamepadAddHostView's "one
/// layer" rule), and a hostless picker has nothing to pin, so only Back remains.
private var hints: [GamepadHint] {
guard pinTarget != nil else {
return [
.init(glyph: "arrow.left.and.right", text: "Adjust"),
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change"),
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"),
]
}
guard !store.hosts.isEmpty else {
return [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back")]
}
return [
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Pin / Unpin"),
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back"),
]
}
/// B peels one layer: the pin picker back to the settings rows focus returning to the
/// profile row it came from then the screen itself.
private func back() {
if let profile = pinTarget {
pinTarget = nil
focusID = "profile-\(profile.id)"
} else {
dismiss()
}
}
// MARK: - Row rendering
private func rowView(_ row: Row, focused: Bool) -> some View {
@@ -164,7 +216,7 @@ struct GamepadSettingsView: View {
HStack(spacing: 9) {
Image(systemName: "chevron.left")
.font(.system(size: m.chevronFont, weight: .semibold))
.foregroundStyle(.white.opacity(focused ? 0.6 : 0))
.foregroundStyle(.white.opacity(focused && row.adjustable ? 0.6 : 0))
// Keyed by the value so a change slides the new option in instead of
// hard-swapping the string a QUIET horizontal slip following the user's
// motion (a right-step enters from the right), crossfading over ~14 pt.
@@ -185,7 +237,7 @@ struct GamepadSettingsView: View {
.animation(.smooth(duration: 0.22), value: row.value)
Image(systemName: "chevron.right")
.font(.system(size: m.chevronFont, weight: .semibold))
.foregroundStyle(.white.opacity(focused ? 0.6 : 0))
.foregroundStyle(.white.opacity(focused && row.adjustable ? 0.6 : 0))
}
}
.padding(.horizontal, m.rowHPad)
@@ -219,6 +271,9 @@ struct GamepadSettingsView: View {
let value: String
/// One-line explanation shown near the hint bar while this row is focused.
let detail: String
/// Whether left/right means anything here false hides the value's chevrons (the
/// Profiles rows navigate, and the placeholder rows do nothing at all).
var adjustable = true
/// Left/right step; returns whether the value actually changed (false boundary thud).
let adjust: (Int) -> Bool
/// A cycle forward (wrapping) / flip.
@@ -238,6 +293,9 @@ struct GamepadSettingsView: View {
}
private var rows: [Row] {
// The pin picker replaces the whole list while it's up same screen, one layer deeper,
// so the focus list's controller wiring (and the tvOS focus engine) carries over as is.
if let profile = pinTarget { return pinRows(for: profile) }
let resolution = resolutionOptions
let refresh = SettingsOptions.refreshRates(including: hz)
.map { (label: "\($0) Hz", tag: $0) }
@@ -394,7 +452,98 @@ struct GamepadSettingsView: View {
at: at + 1)
}
#endif
return list
return list + profileRows
}
// MARK: - Profiles (§5.2a)
/// The trailing Profiles section: one row per catalog profile, its value how many saved
/// hosts pin it, A opening the pin-to-hosts picker. Read-only beyond that this surface
/// pins and unpins, but profiles are created and edited elsewhere (design §5.4), so
/// left/right is a boundary thud, not an editor.
private var profileRows: [Row] {
guard !profiles.profiles.isEmpty else {
return [Row(
id: "noProfiles", header: "Profiles", icon: "slider.horizontal.3",
label: "No profiles yet", value: "",
detail: emptyCatalogDetail,
adjustable: false,
adjust: { _ in false }, activate: {})]
}
return profiles.profiles.enumerated().map { i, profile in
let pins = store.hosts
.filter { ($0.pinnedProfileIDs ?? []).contains(profile.id) }.count
return Row(
id: "profile-\(profile.id)", header: i == 0 ? "Profiles" : nil,
icon: "slider.horizontal.3", label: profile.name,
value: pins == 0 ? "Not pinned" : "Pinned to \(pins) host\(pins == 1 ? "" : "s")",
detail: profileDetail,
adjustable: false,
adjust: { _ in false },
activate: {
// Focus lands on the picker's first row the focus list's reconcile
// follows this id when the row set swaps underneath it.
focusID = store.hosts.first.map { "pinHost-\($0.id.uuidString)" } ?? "noHosts"
pinTarget = profile
})
}
}
/// The pin-to-hosts picker: one toggle row per SAVED host, sharing the settings rows'
/// toggle semantics (left = unpin, right = pin, A flips; asking for the state it's in is a
/// boundary thud). Writes ride `HostStore.setPinned` pin appends, unpin removes and
/// NEVER the host's default binding (`profileID`): a pin is presentation only (§5.2a).
private func pinRows(for profile: StreamProfile) -> [Row] {
guard !store.hosts.isEmpty else {
return [Row(
id: "noHosts", icon: "desktopcomputer", label: "No saved hosts yet",
value: "",
detail: "Pair with a host first, then pin this profile to it.",
adjustable: false,
adjust: { _ in false }, activate: {})]
}
return store.hosts.map { host in
let hostID = host.id
let pinned = (host.pinnedProfileIDs ?? []).contains(profile.id)
return Row(
id: "pinHost-\(hostID.uuidString)", icon: "desktopcomputer",
label: host.displayName,
value: pinned ? "Pinned" : "Off",
detail: "A pinned profile appears as its own card on the host — one press "
+ "connects with it.",
adjust: { delta in
let target = delta > 0
guard pinned != target else { return false }
store.setPinned(hostID, profileID: profile.id, pinned: target)
return true
},
activate: { store.setPinned(hostID, profileID: profile.id, pinned: !pinned) })
}
}
/// The profile rows' explainer. tvOS gets its own: the catalog is per-device (the App Group
/// suite nothing syncs it) and tvOS has no profile editor at all (§5.4), so pointing a TV
/// user at a "standard interface" would promise profiles that can never arrive there.
private var profileDetail: String {
#if os(tvOS)
return "Pin this profile to a host and it appears as its own card on the home screen — "
+ "one press connects with it."
#else
return "Pin this profile to a host and it appears as its own card — one press connects "
+ "with it. Profiles are created and edited in Punktfunk's standard interface."
#endif
}
/// What the empty catalog's placeholder explains again honest on tvOS, where profiles
/// cannot be created (on the device or anywhere that would reach its per-device catalog).
private var emptyCatalogDetail: String {
#if os(tvOS)
return "Profiles bundle stream settings for different uses. Creating them isn't "
+ "available on Apple TV yet."
#else
return "Profiles bundle stream settings for different uses. Create them in Punktfunk's "
+ "standard interface, then pin them here as one-press connect cards."
#endif
}
/// Resolution choices as "WxH" tags the current size is inserted when it's a custom mode
@@ -3,28 +3,66 @@ import os
/// SPSC-ish jitter ring (interleaved float, `channels` per frame), drain thread render
/// callback. The unfair lock is held for microseconds; fine at render-callback rates. Priming:
/// reads return silence until enough is buffered (at least `prefill`, and at least one
/// reads return silence until enough is buffered (at least the target, and at least one
/// packet more than the device's render quantum large-buffer devices would otherwise
/// chronically out-demand the prefill and oscillate prime dropout re-prime), and an
/// underrun re-primes, concealing jitter as one short dip instead of sustained crackle.
/// chronically out-demand the prefill and oscillate prime dropout re-prime).
/// All counts stay whole frames (multiples of `channels`), so the interleave can never slip.
///
/// **Drift correction.** Both ends run at 48 kHz but on different crystals, so backlog from a
/// network stall or plain host-vs-DAC skew never drains on its own: without correction one 300 ms
/// hiccup leaves audio 300 ms behind video for the rest of the session. This used to be handled by
/// a `highWater` shed that dropped a whole `2 × prefill` at once its own comment called that "one
/// audible blip". It is now the same two-stage scheme the Rust clients share
/// (`punktfunk_core::audio::JitterPolicy`): a slow depth average that sits above target for a
/// sustained window sheds ONE 5 ms frame with a crossfade, and the hard cap is only a backstop.
/// Keep the constants here in step with `JitterTuning.COREAUDIO`.
final class AudioRing: @unchecked Sendable {
/// Mirrors `JitterTuning::COREAUDIO` see that type for the rationale.
private static let targetMS = 20
private static let headroomMS = 30
private static let hardCapMS = 90
private static let deprimeAfter = 4
/// The protocol's frame: the shed unit, and the slack added over a large device quantum.
private static let frameMS = 5
/// Depth average must exceed target by this before drift correction fires the middle of the
/// headroom band, so the smooth shed always gets its chance BEFORE the hard cap trims.
private static let shedExcessMS = 15
/// and must stay there for this much consumed audio. Long, because a shed is the only thing
/// here a listener could notice; it must never fire on a transient.
private static let shedSustainMS = 2_000
private static let crossfadeMS = 2
/// Time constant of the depth average.
private static let ewmaTauMS = 1_000
private var buf: [Float]
private var readIdx = 0
private var writeIdx = 0
private var primed = false
private var renderQuantum = 0
private let prefill: Int
private let highWater: Int
private var emptyReads = 0
private var depthAvg: Double = 0
private var overRun = 0
/// Reported, not acted on: short reads that actually starved the callback, and smooth drift
/// corrections. A rising underrun count means the ring is being starved (network or CPU),
/// which is a different problem from the depth being wrong.
private var underrunCount = 0
private var shedCount = 0
private let channels: Int
private let perMS: Int
private let lock = OSAllocatedUnfairLock()
/// `capacity`/`prefill` in samples (interleaved `channels` per frame, both whole frames).
init(capacity: Int, prefill: Int, channels: Int) {
/// `capacity` in samples (interleaved `channels` per frame, a whole number of frames).
/// The de-jitter depth is the ring's own business (`targetMS`), not a caller's prefill.
init(capacity: Int, channels: Int) {
buf = [Float](repeating: 0, count: capacity)
self.prefill = prefill
self.channels = channels
highWater = prefill * 4
perMS = 48 * channels
}
/// Live target depth in interleaved samples, lifted so it can always serve one device quantum
/// plus a packet (a large-buffer device cannot sustain a target below its own quantum).
private var target: Int {
max(Self.targetMS * perMS, renderQuantum + Self.frameMS * perMS)
}
func write(_ samples: UnsafePointer<Float>, count: Int) {
@@ -42,12 +80,12 @@ final class AudioRing: @unchecked Sendable {
buf[(writeIdx + i) % capacity] = samples[i]
}
writeIdx += count
// Latency clamp: both ends run at 48 kHz, so backlog from a network stall (or
// creeping host-vs-DAC clock skew) never drains on its own without this, one
// 300 ms hiccup leaves audio 300 ms behind video for the rest of the session.
// Shedding down to 2× prefill costs one audible blip instead.
if writeIdx - readIdx > highWater {
readIdx = writeIdx - prefill * 2
// Backstop only: the smooth shed in `read` is what normally holds the depth down.
let cap = min(target + Self.headroomMS * perMS, Self.hardCapMS * perMS)
if writeIdx - readIdx > cap {
readIdx = writeIdx - cap
depthAvg = Double(cap)
overRun = 0
}
}
@@ -57,16 +95,37 @@ final class AudioRing: @unchecked Sendable {
defer { lock.unlock() }
renderQuantum = max(renderQuantum, count)
let available = writeIdx - readIdx
// Depth average, weighted by the callback size so its time constant is independent of the
// device quantum.
let alpha = min(1.0, Double(count) / Double(Self.ewmaTauMS * perMS))
depthAvg += (Double(available) - depthAvg) * alpha
if !primed {
// One 5 ms host packet (240 frames × channels) of slack beyond the device's demand.
if available >= max(prefill, renderQuantum + 240 * channels) {
if available >= target {
primed = true
emptyReads = 0
} else {
for i in 0..<count { out[i] = 0 }
return
}
}
let n = min(available, count)
// Drift correction: shed exactly one frame, crossfaded, once the AVERAGE has sat above
// the threshold for the sustain window. Anything shorter is jitter and must be left alone.
if depthAvg > Double(target + Self.shedExcessMS * perMS) {
overRun += count
if overRun >= Self.shedSustainMS * perMS {
overRun = 0
shedOneFrame()
shedCount += 1
depthAvg = Double(writeIdx - readIdx)
}
} else {
overRun = 0
}
let n = min(writeIdx - readIdx, count)
let capacity = buf.count
for i in 0..<n {
out[i] = buf[(readIdx + i) % capacity]
@@ -74,9 +133,63 @@ final class AudioRing: @unchecked Sendable {
readIdx += n
if n < count {
for i in n..<count { out[i] = 0 }
primed = false // underrun re-prime before resuming
// De-prime only after a RUN of short reads: a single transient drain must not
// manufacture a whole target's worth of fresh silence.
emptyReads += 1
underrunCount += 1
if emptyReads >= Self.deprimeAfter { primed = false }
} else {
emptyReads = 0
}
}
/// Drop one protocol frame from the front, linearly crossfading the seam so the correction is
/// inaudible rather than a click. Mirrors `punktfunk_core::audio::crossfade_drop`; caller holds
/// the lock.
private func shedOneFrame() {
let drop = Self.frameMS * perMS
let available = writeIdx - readIdx
guard available > drop else { return }
let fade = min(Self.crossfadeMS * perMS, min(drop, available - drop))
let capacity = buf.count
if fade > 0 {
// The tail of what we discard fades out into the head of what survives.
for i in 0..<fade {
let old = buf[(readIdx + drop - fade + i) % capacity]
let new = buf[(readIdx + drop + i) % capacity]
let t = Float(i + 1) / Float(fade + 1)
buf[(readIdx + drop + i) % capacity] = old * (1 - t) + new * t
}
}
readIdx += drop
}
/// Current buffered depth in milliseconds for the stats overlay and the drain thread's
/// periodic log.
var bufferedMS: Int {
lock.lock()
defer { lock.unlock() }
return (writeIdx - readIdx) / max(perMS, 1)
}
/// One consistent snapshot of the ring's vitals, taken under a single lock so the numbers in
/// a log line describe the same instant. Mirrors what the three Rust clients report.
struct Stats {
let bufferedMS: Int
let targetMS: Int
let underruns: Int
let sheds: Int
}
var stats: Stats {
lock.lock()
defer { lock.unlock() }
return Stats(
bufferedMS: (writeIdx - readIdx) / max(perMS, 1),
targetMS: target / max(perMS, 1),
underruns: underrunCount,
sheds: shedCount)
}
}
/// CoreAudio channel layout for the canonical wire order FL FR FC LFE RL RR [SL SR]. nil for
@@ -317,10 +317,10 @@ public final class SessionAudio {
// Build the playback layout from the host-RESOLVED channel count (never the request):
// 2 = stereo / 6 = 5.1 / 8 = 7.1, canonical wire order FL FR FC LFE RL RR SL SR.
let channels = Int(connection.resolvedAudioChannels)
// 1 s interleaved capacity, ~20 ms prefill (four 5 ms host packets of jitter absorption
// before the first sample plays), both scaled by the channel count.
let ring = self.ring ?? AudioRing(
capacity: 48_000 * channels, prefill: 960 * channels, channels: channels)
// 1 s interleaved capacity, scaled by the channel count. The de-jitter depth itself is
// the ring's own business now (`AudioRing.targetMS`, mirroring `JitterTuning::COREAUDIO`)
// rather than a prefill passed in here.
let ring = self.ring ?? AudioRing(capacity: 48_000 * channels, channels: channels)
self.ring = ring
// Engine-native deinterleaved float; the render block deinterleaves from the ring. Surround
@@ -403,6 +403,7 @@ public final class SessionAudio {
stateLock.unlock()
let thread = Thread { [connection, flag, drainDone] in
defer { drainDone.signal() }
var drained = 0
// Decode happens IN-CORE (libopus multistream) AudioToolbox's Opus path is
// stereo-only and is handed back as interleaved f32 PCM in wire channel order.
// Per-iteration autorelease pool: no runloop on this thread (see Stage2Pipeline).
@@ -421,6 +422,17 @@ public final class SessionAudio {
ring.write(base, count: pcm.frameCount * pcm.channels)
}
}
// Periodic vitals (~10 s at the protocol's 5 ms frames). The other three clients
// log buffer depth and underruns; without this an Apple audio report latency or
// dropout arrives with no numbers at all, which is the position every platform
// was in before the 2026-08 audio work.
drained += 1
if drained % 2_000 == 0 {
let s = ring.stats
log.info(
"audio: buffer_ms=\(s.bufferedMS) target_ms=\(s.targetMS) underruns=\(s.underruns) drift_sheds=\(s.sheds)"
)
}
return true
}
}
@@ -186,6 +186,16 @@ public final class StreamViewController: StreamViewControllerBase {
// pointer back to iPadOS, so an unwanted drop is re-requested below. The DELIBERATE releases
// (, Q, the Stream menu, backgrounding) all clear `captured` first, so `wantsPointerLock`
// is already false when their drop is observed and none of them are fought here.
//
// Recovery is TWO-STAGE, because either stage alone leaves a hole:
// 1. the burst below, fired the instant the drop is observed wins back a lock the system
// is willing to return immediately (a transient drop that wasn't Escape at all);
// 2. a CLICK into the video while still captured (`onPointerButton`) the fallback for the
// Escape case proper, where the platform declines during the moment right after its own
// release gesture and the burst therefore expires having achieved nothing.
// Stage 2 is what keeps a lost burst from being permanent: `captured` is still true, so no
// other path would ever ask again, and the capture would spend the rest of its life on the
// absolute pointer clicking correctly, aiming not at all.
/// Whether this capture ever actually held the lock. Only a lock we HELD is worth winning back
/// never having been granted one means the scene doesn't qualify, not that Esc took it.
/// Cleared when capture ends, so each capture starts from a clean slate.
@@ -446,6 +456,31 @@ public final class StreamViewController: StreamViewControllerBase {
}
guard self.inputCapture?.gcMouseForwarding == false else { return }
self.inputCapture?.sendMouseButton(button, pressed: down)
// and if we're captured but NOT locked, this click is also the recovery gesture for an
// Escape-drop the burst lost. iPadOS refuses to re-lock in the moment right after its
// own "let me out" gesture, so the burst fired at the drop can spend its whole budget
// and give up while the capture is still wanted. Nothing else would ever re-ask
// setCaptured is the only other requester and a bare Esc never clears `captured` so
// without this the session stays on the absolute path for the rest of the capture:
// clicks still land where you aim (absolute positions keep forwarding) but the game
// gets no relative deltas, so camera look is dead. A click is a real user gesture,
// which is exactly what the platform wants before it will hand the lock back.
//
// On the button UP, so the click has fully forwarded on ONE transport first: asking on
// the DOWN can flip `gcMouseForwarding` mid-click and strand the release on the GCMouse
// path. Gated on `pointerLockWasEngaged` exactly as the drop path is, so a scene that
// never qualifies (Stage Manager, Split View) is never bursted at, and on a burst not
// already being in flight a pending burst mutes absolute motion, so re-arming one on
// every click of a menu the user is still aiming around would freeze the cursor between
// clicks. Only once it has settled does a further click buy a fresh budget (clearing the
// attempt counter, so a gesture isn't refused inside the 2 s window the drop's own burst
// may have just spent).
if !down, self.wantsPointerLock, self.pointerLockWasEngaged,
!self.pointerRelockPending, self.pointerLockEngaged() != true {
self.pointerRelockAttempt = 0
self.updatePointerLockChain() // a reparent since the drop would break the walk to us
self.requestPointerRelock()
}
}
// Scroll is the ONE indirect channel that is NOT gated on the lock. The scroll pan keeps
// firing while the scene is pointer-locked (it is the only way trackpad two-finger scrolling
@@ -0,0 +1,95 @@
// The Apple half of the shared de-jitter policy (`punktfunk_core::audio::JitterPolicy`, whose
// constants `AudioRing` mirrors). These pin the two behaviours a listener actually notices, in the
// one client where the policy is hand-written in a second language rather than shared as code so
// a divergence from the Rust side shows up here rather than as a field report.
//
// The defect being pinned: the ring primed *up* to a target and clamped at a ceiling, with nothing
// walking the depth back *down*. Host-vs-DAC clock skew of a few dozen ppm therefore added latency
// permanently, and the only correction was a `highWater` shed that dropped `2 x prefill` at once
// its own comment called that "one audible blip".
#if !os(tvOS)
import XCTest
@testable import PunktfunkKit
final class AudioRingDriftTests: XCTestCase {
private let channels = 2
private var perMS: Int { 48 * channels }
/// Run `ms` of audio through the ring at a `quantumMS` device where the producer delivers
/// `driftPPM` more than the consumer takes. Returns `(final ms, peak ms, silent callbacks)`.
private func simulate(ms: Int, quantumMS: Int, driftPPM: Int) -> (Int, Int, Int) {
let ring = AudioRing(capacity: 48_000 * channels, channels: channels)
let want = quantumMS * perMS
var scratch = [Float](repeating: 0, count: want)
// Non-zero so a silent callback is distinguishable from real audio.
let producer = [Float](repeating: 0.25, count: want + 8)
var carry = 0, peak = 0, final = 0, silent = 0
for i in 0..<(ms / quantumMS) {
carry += want * driftPPM
let extra = carry / 1_000_000
carry -= extra * 1_000_000
producer.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: want + extra) }
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
// Skip the priming window at the very start.
if i > 20, scratch.allSatisfy({ $0 == 0 }) { silent += 1 }
peak = max(peak, ring.bufferedMS)
final = ring.bufferedMS
}
return (final, peak, silent)
}
/// THE regression: with the host clock running fast, buffered latency must return to target
/// instead of climbing to the hard cap and staying pinned there. +200 ppm is deliberately
/// harsher than real hardware (tens of ppm).
func testDriftDoesNotRatchetLatencyToTheCeiling() {
let (final, peak, silent) = simulate(ms: 5 * 60 * 1_000, quantumMS: 5, driftPPM: 200)
// Must settle inside the headroom band (target 20 + headroom 30), never near the 90 ms cap.
XCTAssertLessThanOrEqual(final, 50, "settled at \(final) ms — that is the ratchet")
XCTAssertLessThanOrEqual(peak, 50, "peaked at \(peak) ms")
XCTAssertEqual(silent, 0, "drift correction must never starve the callback")
}
/// The mirror case: a host clock running SLOW must keep audio flowing rather than being
/// "corrected" into a stutter.
func testNegativeDriftKeepsPlaying() {
let (_, _, silent) = simulate(ms: 2 * 60 * 1_000, quantumMS: 5, driftPPM: -200)
XCTAssertEqual(silent, 0, "a draining ring must re-prime, not chatter")
}
/// A device that pulls a large quantum cannot sustain a target below it the ring must lift
/// its target rather than oscillating prime dropout re-prime forever.
func testLargeDeviceQuantumStillPlays() {
let (_, _, silent) = simulate(ms: 60 * 1_000, quantumMS: 40, driftPPM: 0)
XCTAssertEqual(silent, 0, "a 40 ms quantum must not starve a 20 ms target")
}
/// One transient drain must not manufacture a whole target's worth of fresh silence: the ring
/// de-primes only after a RUN of short reads.
func testSingleShortReadDoesNotDeprime() {
let ring = AudioRing(capacity: 48_000 * channels, channels: channels)
let want = 5 * perMS
var scratch = [Float](repeating: 0, count: want)
// Prime well past target.
let big = [Float](repeating: 0.5, count: 60 * perMS)
big.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: big.count) }
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
XCTAssertTrue(scratch.contains { $0 != 0 }, "should be playing after priming")
// Drain it dry with one oversized read, then feed a normal quantum again. The length comes
// off the buffer pointer, not off `huge`: touching the array inside the closure that is
// already holding it exclusively is an exclusivity violation.
var huge = [Float](repeating: 0, count: 200 * perMS)
huge.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: $0.count) }
let feed = [Float](repeating: 0.5, count: want)
feed.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: want) }
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
XCTAssertTrue(
scratch.contains { $0 != 0 },
"a single short read must not force a full re-prime")
}
}
#endif
+399 -12
View File
@@ -41,16 +41,23 @@ mod cli {
const PROBE_TIMEOUT: Duration = Duration::from_millis(2500);
/// The handshake budget `--request-access` runs on. Matches the host's `PENDING_APPROVAL_WAIT`
/// — the connect is PARKED for that long while an operator decides, so anything shorter would
/// give up while the approval prompt is still on their screen.
const REQUEST_ACCESS_TIMEOUT_SECS: u64 = 185;
const USAGE: &str = "\
punktfunk the Punktfunk client, headless
punktfunk discover [--json] [--timeout SECS]
punktfunk pair <host[:port]> [--pin N] [--name LABEL]
punktfunk hosts list [--probe] [--json]
punktfunk hosts add <host[:port]> [--name LABEL] [--fp HEX]
punktfunk hosts forget <host-ref>
punktfunk wake <host-ref> [--wait]
punktfunk library <host-ref> [--json]
punktfunk launch <host-ref> [--game ID] [--profile REF] [--exec] [--fullscreen]
punktfunk launch <host-ref> [--game ID] [--profile REF] [--request-access]
[--exec] [--fullscreen]
punktfunk open <punktfunk://…>
punktfunk reachable <host-ref>
punktfunk speed-test <host-ref>
@@ -68,6 +75,24 @@ punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not
/// (what goes to stdout vs stderr, and which exit codes mean what).
fn verb_help(verb: &str) -> Option<&'static str> {
Some(match verb {
"discover" => {
"\
punktfunk discover [--json] [--timeout SECS] browse the LAN for hosts
Listens for Punktfunk hosts advertising over mDNS and prints what answered:
name TAB addr:port TAB saved|new TAB paired|unpaired. `saved` means this
device already has a record for it, matched by fingerprint first and address
second the same rule every other surface joins the two lists by.
--timeout SECS how long to browse (default 3, capped at 30) a bounded
call, so a panel can wait for it
--json {\"hosts\":[{\"name\",\"addr\",\"port\",\"fp\",\"pair\",\"id\",\"mgmt\",
\"os\",\"saved\",\"paired\"}]}
Nothing answering is an answer, not a failure: an empty list exits 0. A host
mDNS never sees (Tailscale, another subnet) will not appear here save it by
address with `punktfunk hosts add` and it shows in `hosts list --probe`."
}
"pair" => {
"\
punktfunk pair <host[:port]> enrol this device with a host (PIN ceremony)
@@ -96,6 +121,13 @@ punktfunk hosts — the saved-hosts store (shared with the desktop client)
another subnet). Without --fp it is a placeholder to pair later; with a
64-hex fingerprint it is pinned immediately (still unpaired).
Idempotent, and keyed on the FINGERPRINT once there is one: re-running it
for a host already saved is a no-op, and giving a known fingerprint a new
address MOVES that host's record there rather than filing a second one
(which is how a host that changed DHCP lease stays reachable by its id).
A different fingerprint for an address already saved is refused, exit 3
a changed identity is a decision for a person.
punktfunk hosts forget <host-ref>
Remove a saved host, its pinned fingerprint included. A later connect
must pair or trust it again."
@@ -119,7 +151,8 @@ this. Needs a paired host (exit 6 otherwise)."
}
"launch" => {
"\
punktfunk launch <host-ref> [--game ID] [--profile REF] [--exec] [--fullscreen]
punktfunk launch <host-ref> [--game ID] [--profile REF] [--request-access]
[--exec] [--fullscreen]
Start a stream waking the host first if it is asleep and its MAC is known.
The stream runs in the punktfunk-session renderer; this command supervises it
@@ -132,6 +165,16 @@ and relays its lifecycle to stderr.
--exec become the session process instead of supervising it the
gamescope-wrapper mode, where the launched process must BE
the streaming one for focus and lifecycle to work
--request-access
ask the host's operator to let this device in instead of
typing a PIN. The host PARKS the connect until somebody
approves it in its console or web UI (up to ~185 s), then
admits it and the stream starts by itself; the host is
recorded as paired once that happens, so later streams are
silent. Needs the host's fingerprint pinned already
(`punktfunk hosts add <addr> --fp <hex>`), and cannot be
combined with --exec under --exec there is no process
left to record the approval.
Exit 0 when the stream ends cleanly, 2 connect failed, 3 the host no longer
trusts this device (re-pair), 4 the renderer could not start."
@@ -222,7 +265,7 @@ from the config directory for a true factory reset."
fn flag_takes_value(flag: &str) -> bool {
matches!(
flag,
"--pin" | "--name" | "--fp" | "--game" | "--profile" | "--port"
"--pin" | "--name" | "--fp" | "--game" | "--profile" | "--port" | "--timeout"
)
}
@@ -269,6 +312,7 @@ from the config directory for a true factory reset."
return OK;
}
match verb.as_str() {
"discover" => discover(&rest),
"pair" => pair(&rest),
"hosts" => hosts(&rest),
"wake" => wake(&rest),
@@ -306,6 +350,104 @@ from the config directory for a true factory reset."
}
}
/// How long `discover` browses when nobody says, and the ceiling on what they can ask for.
/// The cap is not politeness: this verb is called from a Quick Access panel, and a typo'd
/// `--timeout 3000` would hang that panel with no way to cancel it.
const DISCOVER_DEFAULT_SECS: f64 = 3.0;
const DISCOVER_MAX_SECS: f64 = 30.0;
/// `discover [--json] [--timeout SECS]` — browse the LAN over mDNS and print what answered,
/// annotated against the saved-hosts store.
///
/// The annotation is the point: a caller wants "can I stream this", which is a question
/// about BOTH lists, and joining them itself is how two surfaces end up disagreeing about
/// the same host. So the match rule lives here, once, and is the same one every other
/// surface uses — fingerprint first (survives a DHCP move), address second.
fn discover(args: &[String]) -> u8 {
let secs = value(args, "--timeout")
.and_then(|v| v.parse::<f64>().ok())
.filter(|s| *s > 0.0)
.unwrap_or(DISCOVER_DEFAULT_SECS)
.min(DISCOVER_MAX_SECS);
let found = pf_client_core::discovery::discover_for(Duration::from_secs_f64(secs));
// `read`, not `load`: this verb only LOOKS at the records to annotate what it found, and
// never hands their ids back. `load` would mint ids for a pre-mint store and save them —
// a write from a read-only verb, and one that races the `hosts list` a caller is very
// likely running at the same moment (the Decky panel issues both together).
let known = KnownHosts::read();
let rows: Vec<(
&pf_client_core::discovery::DiscoveredHost,
Option<&KnownHost>,
)> = found.iter().map(|d| (d, match_saved(&known, d))).collect();
if has(args, "--json") {
let hosts: Vec<serde_json::Value> = rows
.iter()
.map(|(d, saved)| {
serde_json::json!({
"name": d.name,
"addr": d.addr,
"port": d.port,
"fp": d.fp_hex,
"pair": d.pair,
"id": d.advertised_id(),
// 0 = not advertised, which is what a consumer's own "no mgmt port"
// already means — an older host simply omits the TXT.
"mgmt": d.mgmt_port.unwrap_or(0),
"os": d.os,
"saved": saved.is_some(),
"paired": saved.is_some_and(|h| h.paired),
})
})
.collect();
println!("{}", serde_json::json!({ "hosts": hosts }));
} else {
for (d, saved) in &rows {
println!(
"{}\t{}:{}\t{}\t{}",
d.name,
d.addr,
d.port,
if saved.is_some() { "saved" } else { "new" },
if saved.is_some_and(|h| h.paired) {
"paired"
} else {
"unpaired"
},
);
}
}
// An empty LAN is an answer, not a failure — a caller branching on the exit code is
// asking "did the browse run", and it did.
OK
}
/// The saved record an advert belongs to, if any: fingerprint first, address second.
///
/// Fingerprint FIRST is deliberate and load-bearing — a host that moved to a new DHCP lease
/// still matches its record, and a *different* host that inherited the old address does not
/// inherit its pairing. This is the rule the plugin's `mergeHosts` and the shells' hosts
/// pages already use; keeping one copy is what stops two surfaces disagreeing about whether
/// the box in front of you is paired.
fn match_saved<'a>(
known: &'a KnownHosts,
advert: &pf_client_core::discovery::DiscoveredHost,
) -> Option<&'a KnownHost> {
known
.hosts
.iter()
.find(|h| {
!h.fp_hex.is_empty()
&& !advert.fp_hex.is_empty()
&& h.fp_hex.eq_ignore_ascii_case(&advert.fp_hex)
})
.or_else(|| {
known
.hosts
.iter()
.find(|h| h.addr == advert.addr && h.port == advert.port)
})
}
/// `pair <host[:port]> [--pin N]` — the SPAKE2 ceremony. Without `--pin` it prompts, which
/// is the interactive shape; with one it is scriptable. Refuses rather than prompting when
/// stdin isn't a terminal and no PIN was given: a pairing that silently blocks a CI job
@@ -424,16 +566,69 @@ from the config directory for a true factory reset."
return UNRESOLVED;
};
let (addr, port) = split_host_port(&target);
let fp = value(args, "--fp").unwrap_or_default();
let name = value(args, "--name");
let mut known = KnownHosts::load();
if known.hosts.iter().any(|h| h.addr == addr && h.port == port) {
eprintln!("{addr}:{port} is already saved");
return OK;
if let Some(i) = known
.hosts
.iter()
.position(|h| h.addr == addr && h.port == port)
{
return match merge_saved_host(&mut known, i, &fp, name.as_deref()) {
AddOutcome::Unchanged => {
eprintln!("{addr}:{port} is already saved");
OK
}
AddOutcome::Conflict => {
eprintln!(
"{addr}:{port} is already saved with a different fingerprint — \
forget it first if you really mean to replace it \
(punktfunk hosts forget {addr}:{port})"
);
TRUST_REJECTED
}
AddOutcome::Pinned => match known.save() {
Ok(()) => {
println!("updated {addr}:{port}");
OK
}
Err(e) => {
eprintln!("saving: {e:#}");
CONNECT_FAILED
}
},
};
}
// No record at this address — but a record carrying this exact FINGERPRINT is
// this same host at a new one. Re-point it rather than filing a second record:
// the fingerprint is the identity, and a host that changed DHCP lease is the
// whole reason `hosts add --fp` is idempotent in the first place. Without this a
// moved host accumulates one record per address it has ever held, and the one a
// stable id resolves to keeps the address it can no longer be reached at.
if let Some(i) = known
.hosts
.iter()
.position(|h| !fp.is_empty() && h.fp_hex.eq_ignore_ascii_case(&fp))
{
let was = format!("{}:{}", known.hosts[i].addr, known.hosts[i].port);
known.hosts[i].addr = addr.clone();
known.hosts[i].port = port;
return match known.save() {
Ok(()) => {
println!("moved {was} to {addr}:{port}");
OK
}
Err(e) => {
eprintln!("saving: {e:#}");
CONNECT_FAILED
}
};
}
known.hosts.push(KnownHost {
name: value(args, "--name").unwrap_or_else(|| addr.clone()),
name: name.unwrap_or_else(|| addr.clone()),
addr: addr.clone(),
port,
fp_hex: value(args, "--fp").unwrap_or_default(),
fp_hex: fp,
..Default::default()
});
match known.save() {
@@ -475,6 +670,55 @@ from the config directory for a true factory reset."
}
}
/// What `hosts add` did to a record that was ALREADY saved for this address.
#[derive(Debug, PartialEq, Eq)]
enum AddOutcome {
/// Nothing to do — no fingerprint was offered, or the record already carries this one.
/// Exits 0 on purpose: a panel retrying step 1 of request access must not have to
/// invent an error to show for a state that is already correct.
Unchanged,
/// The record had no fingerprint and now has this one.
Pinned,
/// The record carries a DIFFERENT fingerprint. Refused, never overwritten.
Conflict,
}
/// `hosts add --fp` against an address that is already saved. The difference between these
/// three is a trust decision, not bookkeeping.
///
/// Filling in an empty fingerprint is step 1 of request access (design §5): a host found by
/// advert is saved by address first and pinned second. Without it the `--fp` is dropped on
/// the floor and the launch that follows refuses for want of a pin — which is what this did
/// before, silently and with exit 0.
///
/// A *different* fingerprint is refused because a changed identity is a decision for a
/// person, at a surface that can show them both. That is what `upsert_trusted` exists to
/// enforce; quietly overwriting it here would be a back door through the pinning the rest
/// of the client is built on.
fn merge_saved_host(
known: &mut KnownHosts,
i: usize,
fp: &str,
name: Option<&str>,
) -> AddOutcome {
let existing = known.hosts[i].fp_hex.clone();
if fp.is_empty() || existing.eq_ignore_ascii_case(fp) {
return AddOutcome::Unchanged;
}
if !existing.is_empty() {
return AddOutcome::Conflict;
}
known.hosts[i].fp_hex = fp.to_string();
// Only a record still named after its own address is renamed: a label the user chose is
// theirs, and an advert's name must not quietly overwrite it.
if let Some(label) = name {
if known.hosts[i].name == known.hosts[i].addr {
known.hosts[i].name = label.to_string();
}
}
AddOutcome::Pinned
}
/// `wake <host-ref> [--wait]` — a magic packet, and with `--wait` the same bounded
/// wake-and-wait the shells run (`WakeWait`: a packet every 6 s, presence polled every
/// second, 90 s budget).
@@ -585,6 +829,19 @@ from the config directory for a true factory reset."
eprintln!("usage: punktfunk launch <host-ref> [--game ID] [--profile REF] [--exec]");
return UNRESOLVED;
};
let exec = has(args, "--exec");
let request_access = has(args, "--request-access");
// Refused rather than silently downgraded: under `--exec` this process BECOMES the
// session, so nothing survives to see `Ready` and record the approval. A launch that
// quietly dropped the persistence would leave hosts reading "trusted" forever with
// nobody able to say why.
if request_access && exec {
eprintln!(
"--request-access can't be combined with --exec: under --exec there is no \
process left to record the host's approval"
);
return UNRESOLVED;
}
let (known, i) = match resolve(&reference) {
Ok(v) => v,
Err(code) => return code,
@@ -597,7 +854,10 @@ from the config directory for a true factory reset."
if has(args, "--fullscreen") {
plan.settings.fullscreen_on_stream = true;
}
run_plan(plan, has(args, "--exec"))
if request_access {
plan.connect_timeout_secs = Some(REQUEST_ACCESS_TIMEOUT_SECS);
}
run_plan(plan, exec, request_access)
}
/// `open <url>` — the `punktfunk://` grammar, headless. Same parser, same refusal rules and
@@ -622,7 +882,7 @@ from the config directory for a true factory reset."
&trust::Settings::load(),
);
match outcome {
Ok(PlanOutcome::Connect(plan)) => run_plan(*plan, has(args, "--exec")),
Ok(PlanOutcome::Connect(plan)) => run_plan(*plan, has(args, "--exec"), false),
// A URL may never pair or trust on its own — that is a decision for a person, at a
// surface that can show them the fingerprint.
Ok(PlanOutcome::ConfirmUnknown(u)) => {
@@ -646,7 +906,13 @@ from the config directory for a true factory reset."
}
/// Wake if needed, then run the session — supervising it, or becoming it under `--exec`.
fn run_plan(plan: ConnectPlan, exec: bool) -> u8 {
///
/// `persist_paired` records the host as *paired* when the child reports ready. Only
/// `launch --request-access` passes true: there, the host parked the connect until an
/// operator approved this device, so `Ready` IS the approval arriving — the same thing
/// `SpawnOpts::persist_paired` means in the GTK shell. Every other launch records nothing,
/// which is correct: a plain connect proves reachability, not a new trust decision.
fn run_plan(plan: ConnectPlan, exec: bool, persist_paired: bool) -> u8 {
if plan.host.fp_hex.is_none() {
eprintln!(
"{} has no pinned fingerprint — punktfunk pair {}",
@@ -708,7 +974,24 @@ from the config directory for a true factory reset."
let mut failure: Option<(String, bool)> = None;
while let Ok(ev) = rx.recv() {
match ev {
SessionEvent::Ready => eprintln!("streaming"),
SessionEvent::Ready => {
eprintln!("streaming");
// The pin we connected WITH, not one re-derived from the store: the record
// is what we are about to rewrite, and the session proved the host holds
// exactly this identity by completing a pinned handshake against it.
if persist_paired {
if let Some(fp_hex) = &plan.host.fp_hex {
trust::persist_host(
&plan.host.name,
&plan.host.addr,
plan.host.port,
fp_hex,
true,
);
trust::forget_placeholder(&plan.host.addr, plan.host.port);
}
}
}
SessionEvent::Error {
msg,
trust_rejected,
@@ -967,6 +1250,7 @@ from the config directory for a true factory reset."
#[test]
fn every_usage_verb_has_help() {
for verb in [
"discover",
"pair",
"hosts",
"wake",
@@ -988,6 +1272,109 @@ from the config directory for a true factory reset."
assert!(verb_help("bogus").is_none());
}
fn saved(name: &str, addr: &str, fp: &str) -> KnownHost {
KnownHost {
name: name.into(),
addr: addr.into(),
port: 9777,
fp_hex: fp.into(),
..Default::default()
}
}
/// Step 1 of request access: a host saved by address gains the fingerprint its advert
/// carried. Before this, `hosts add --fp` on an existing record exited 0 having done
/// NOTHING — the launch that followed then refused for want of a pin, and the panel had
/// no way to tell why.
#[test]
fn adding_a_fingerprint_to_a_placeholder_fills_it_in() {
let mut known = KnownHosts {
hosts: vec![saved("192.168.1.9", "192.168.1.9", "")],
};
assert_eq!(
merge_saved_host(&mut known, 0, "abc123", Some("living-room")),
AddOutcome::Pinned
);
assert_eq!(known.hosts[0].fp_hex, "abc123");
assert_eq!(
known.hosts[0].name, "living-room",
"a record still named after its address takes the offered label"
);
}
/// A label the user chose is theirs — an advert's name must not overwrite it.
#[test]
fn filling_in_a_fingerprint_keeps_a_user_chosen_name() {
let mut known = KnownHosts {
hosts: vec![saved("Basement rig", "192.168.1.9", "")],
};
merge_saved_host(&mut known, 0, "abc123", Some("living-room"));
assert_eq!(known.hosts[0].name, "Basement rig");
}
/// Idempotent: the panel may retry step 1, and re-offering the fingerprint a record
/// already carries is a state that is already correct, not an error to render.
#[test]
fn re_adding_the_same_fingerprint_changes_nothing() {
let mut known = KnownHosts {
hosts: vec![saved("desk", "192.168.1.9", "ABC123")],
};
assert_eq!(
merge_saved_host(&mut known, 0, "abc123", None),
AddOutcome::Unchanged,
"fingerprints compare case-insensitively"
);
// And a bare `hosts add` with no --fp at all leaves the pin alone.
assert_eq!(
merge_saved_host(&mut known, 0, "", None),
AddOutcome::Unchanged
);
assert_eq!(known.hosts[0].fp_hex, "ABC123");
}
/// A changed identity is a decision for a person. Never a silent overwrite — this is the
/// same rule `upsert_trusted` enforces, and a back door here would defeat it everywhere.
#[test]
fn a_different_fingerprint_is_refused_not_overwritten() {
let mut known = KnownHosts {
hosts: vec![saved("desk", "192.168.1.9", "abc123")],
};
assert_eq!(
merge_saved_host(&mut known, 0, "deadbeef", None),
AddOutcome::Conflict
);
assert_eq!(
known.hosts[0].fp_hex, "abc123",
"the pin must survive intact"
);
}
/// A host that changed DHCP lease is re-pointed, not filed a second time. Without this
/// the record a stable id resolves to keeps an address the host has left, so a launch
/// dials into the void while the panel shows the live one.
#[test]
fn a_known_fingerprint_at_a_new_address_moves_the_record() {
let mut known = KnownHosts {
hosts: vec![saved("desk", "192.168.1.9", "abc123")],
};
// Simulates `hosts add 192.168.1.50 --fp abc123` finding no record at that address.
let by_addr = known
.hosts
.iter()
.position(|h| h.addr == "192.168.1.50" && h.port == 9777);
assert!(
by_addr.is_none(),
"the new address is not yet on any record"
);
let by_fp = known
.hosts
.iter()
.position(|h| h.fp_hex.eq_ignore_ascii_case("abc123"));
assert_eq!(by_fp, Some(0), "the fingerprint still identifies the host");
known.hosts[0].addr = "192.168.1.50".into();
assert_eq!(known.hosts.len(), 1, "one host, one record");
}
#[test]
fn value_reads_the_argument_after_its_flag() {
let a = argv(&["--game", "steam:570", "--exec"]);
+18
View File
@@ -67,3 +67,21 @@ fn unknown_verbs_refuse_with_the_not_found_code() {
let out = punktfunk(&["help", "frobnicate"]);
assert_eq!(out.status.code(), Some(5), "unknown help topic exits 5");
}
/// `discover` and `launch --request-access` document themselves. Help only — the verbs
/// themselves browse the LAN and dial a host, which no runner may be asked to do.
///
/// The Decky panel detects a too-old client by exactly the signature the test above pins
/// (exit 5 + `unknown command`), so this is the other half of that contract: on a client new
/// enough, `discover` is a verb with help rather than an unknown word.
#[test]
fn the_request_access_surfaces_document_themselves() {
let out = punktfunk(&["help", "discover"]);
assert!(out.status.success(), "discover has its own help topic");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("--timeout"), "discover documents --timeout");
assert!(stdout.contains("--json"), "discover documents --json");
let out = punktfunk(&["launch", "--help"]);
assert!(String::from_utf8_lossy(&out.stdout).contains("--request-access"));
}
+86 -56
View File
@@ -2,49 +2,61 @@
Stream to your **Steam Deck** without ever leaving Gaming Mode. This
**[Decky Loader](https://decky.xyz/)** plugin adds a **Punktfunk** panel to the Quick Access Menu
(the `…` button): discover hosts on your network, pair with a PIN, tweak stream settings, and launch
a fullscreen, gamescope-focused stream — all from the couch, gamepad-navigable.
(the `…` button): the hosts you can stream, the pinned cards you set up, and one tap into each.
The video itself is the native GTK4 Linux client (the `io.unom.Punktfunk` flatpak); the plugin
discovers, pairs, configures, and *launches it the right way* so gamescope fullscreens it — the same
Steam-shortcut trick MoonDeck uses. Because it's built from real Steam UI primitives (`@decky/ui`),
the panel looks and feels native to Gaming Mode.
The plugin is a **launcher**, not a client. It doesn't decode video, browse your library, or hold
any settings of its own — the Rust client does all of that, and the plugin's job is to start it
*the right way* so gamescope fullscreens and focuses it (the same Steam-shortcut trick MoonDeck
uses). Everything the panel doesn't do is one tap away in the client's own gamepad UI.
## What it does
1. **Discover** — browses the LAN over mDNS for Punktfunk hosts, in both the QAM panel and a
fullscreen page; each host row opens a details view (address, pairing policy, certificate
fingerprint to cross-check against the host's log).
2. **Pair**for a host that requires it, a gamepad-navigable PIN keypad runs the SPAKE2 pairing
ceremony headlessly, then remembers the host so future streams connect silently.
3. **Stream** — launches fullscreen via a branded "Punktfunk" Steam shortcut so gamescope focuses it.
4. **Games** — each host row has a games button that opens its **library picker**: pin titles as
one-tap "Stream <Game>" rows in the QAM (jump straight into e.g. Playnite on the host), or
**"Open library on screen"** to launch the client's controller-driven, console-style library
browser (aurora backdrop + poster coverflow; A plays, B returns to Gaming Mode). Pins survive
plugin reinstalls (stored next to the client's config) and follow a host across IP changes
(matched by certificate fingerprint).
5. **Settings** — the client's whole settings store, written to its config. Laid out like SteamOS's
own Settings: a left rail of categories (`SidebarNavigation`), one page each, so no page needs
scrolling. The categories and their order are the console settings screen's — Stream (resolution
/ refresh / render scale / bitrate / compositor), Video (codec / decoder / GPU / HDR / 4:4:4),
Presentation (prioritize / smoothness buffer / V-Sync / VRR), Audio (channels / output + mic
device / echo cancellation), Controllers, Touch & mouse, Interface (stats overlay / auto-wake /
library / fullscreen). The device pickers are populated
from the session binary (`--list-adapters` / `--list-audio`); the GPU row appears only where
there is more than one adapter.
6. **About** — plugin version, an explicit "Check for updates" button, the setup-guide link, and
a force-stop for a wedged stream client.
1. **Hosts** — the hosts on your network plus the ones you've saved, in one list. Discovery is
mDNS; saved hosts are also probed directly, so a box reached over Tailscale or a VPN shows as
online even though it never advertises. Rows sort online-first, then most recently used.
2. **Trust**an unpaired host opens a small sheet with two ways in:
- **Request access** (the default) — no PIN. The host's operator approves this Deck in its
console or web UI and the stream starts by itself. See [Request access](#request-access).
- **Use a PIN instead** — the gamepad-navigable keypad, running the same SPAKE2 ceremony.
3. **Stream** — launches fullscreen via a branded "Punktfunk" Steam shortcut so gamescope focuses
it. A sleeping host is woken first (the client runs the real wake-and-wait loop, then dials).
4. **Pinned cards** — a *(host, profile)* pair renders nested under its host as `▸ <Profile name>`
and streams with that settings profile applied. Cards are the **shared** pinning model every
other client speaks, stored on the host's record — so one you make in the desktop client shows
up here, and vice versa. The plugin renders them; it doesn't create or edit them.
5. **Open Punktfunk** — launches the client's **console home**: the host picker, add-host by
address, PIN pairing, the game library browser, and the **full settings screen**. This is where
everything the panel no longer does now lives.
6. **About** — plugin version, "Check for updates", "Recreate library shortcut", and a force-stop
for a wedged stream.
To leave a stream: the in-client controller chord (**L1 + R1 + Start + Select**), or close the
"game" from the Steam overlay — either returns you to Gaming Mode.
### Request access
Request access is not a second pairing ceremony — it is a **launch**. The plugin saves the host
with the fingerprint it **advertised**, then starts an ordinary identified connect with the
handshake budget stretched to 185 s. The host *parks* that connection until its operator approves
the device, then admits the same connection; the stream starts on its own, and the record flips
to **paired** so every later stream is silent.
**No advertised fingerprint, no request access.** That pinned fingerprint is the only thing
standing between a 185-second wait and an impostor answering for the host, so a host you typed in
by address gets the PIN path only — and the sheet says why. The plugin never trusts-on-first-use
past a missing fingerprint.
## Install on the Deck
You need **[Decky Loader](https://decky.xyz/)** and the **`io.unom.Punktfunk` flatpak**
([`packaging/flatpak`](../../packaging/flatpak/README.md)) installed on the Deck — SteamOS `/usr` is
read-only, so the flatpak (which bundles libadwaita/SDL3) is the canonical client. Discovery uses
`avahi-browse`, which ships on SteamOS/Bazzite.
You need **[Decky Loader](https://decky.xyz/)** and a **Punktfunk client** on the Deck. On a normal
Deck that's the `io.unom.Punktfunk` flatpak ([`packaging/flatpak`](../../packaging/flatpak/README.md))
SteamOS `/usr` is read-only, so the flatpak (which bundles libadwaita/SDL3) is the canonical client.
A native install (sysext, distro package, nix profile, your own build) works too.
**The client must be v0.22.0 or newer** — that is when the headless `punktfunk` CLI shipped, and
the panel drives everything through it. An older client says so in the panel, with the update
button that fixes it right there. (Discovery no longer needs `avahi-browse` on the Deck; the
client's own mDNS does it.)
**Recommended — install from URL** (published by CI): in Decky → Settings → **Developer Mode**
**Install Plugin from URL**, paste:
@@ -55,17 +67,15 @@ https://unom.io/pf-decky
(short link for `https://git.unom.io/api/packages/unom/generic/punktfunk-decky/latest/punktfunk.zip`;
for a pinned version use `https://git.unom.io/api/packages/unom/generic/punktfunk-decky/<version>/punktfunk.zip`
directly). The plugin then **self-updates** without
the Decky store — when a newer build exists, an **Update** button appears and drives Decky
Loader's own (SHA-256-verified) install. Installs and updates can take a couple of minutes on some
networks: Decky's installer also contacts its plugin store first, which may be slow or blackholed
before the actual download proceeds.
directly). The plugin then **self-updates** without the Decky store — when a newer build exists, an
**Update** button appears and drives Decky Loader's own (SHA-256-verified) install. Installs and
updates can take a couple of minutes on some networks: Decky's installer also contacts its plugin
store first, which may be slow or blackholed before the actual download proceeds.
### Updating the client
The plugin also reports — and where it can, installs — updates for the **client** it launches.
What is possible depends on how that client was installed, and the About tab names the install
kind so the answer is never a mystery:
What is possible depends on how that client was installed:
| Install | Update |
| --- | --- |
@@ -88,6 +98,8 @@ pnpm install
pnpm build # rollup → dist/index.js
pnpm run package # → out/punktfunk/ + out/punktfunk-v<ver>.zip
DECK=deck@<deck-ip> pnpm run deploy # rsync → /tmp, sudo-install into the root-owned plugins dir, restart loader
python3.13 scripts/test-backend.py # backend unit checks (needs Python ≥3.10)
```
`~/homebrew/plugins/` is root-owned (the loader runs as root), so `deploy.sh` stages to a temp dir
@@ -96,28 +108,46 @@ restart is required for an out-of-band install to appear.
## Architecture
Everything below the panel is the CLI. `main.py` builds argv and maps exit codes; it parses none of
the client's data files and re-implements none of its rules.
| File | Role |
| --- | --- |
| `src/index.tsx` | Plugin entry: the QAM panel + route registration. |
| `src/page.tsx` | The `/punktfunk` fullscreen page — Hosts (with per-host details) / Settings / About tabs. |
| `src/settings.tsx` · `src/pair.tsx` | The settings screen (a `SidebarNavigation` of seven category pages over one shared settings object); the gamepad-navigable PIN-pairing modal. |
| `src/library.tsx` | The per-host game picker (pin/unpin, "Open library on screen") + the pinned-game launch helper. |
| `src/hostmgmt.tsx` | Add / edit host dialogs — mutate the shared known-hosts store (`client-known-hosts.json`) via the flatpak client's headless modes, so a host saved here shows up in the desktop client too. |
| `src/ui.tsx` | Shared UI primitives for the fullscreen page + modals (right-aligned row actions, consistent Field layout). |
| `src/hooks.ts` · `src/boundary.tsx` | Shared discovery/update/pins hooks + actions; the render error boundary. |
| `src/steam.ts` | Steam-shortcut launch (`AddShortcut` / `SetAppLaunchOptions` / `RunGame`) — the focus-correct stream start. The shortcut's exe is `/bin/sh` with the wrapper passed as an argument, so the script never needs an exec bit (Decky's zip extraction drops it and the root-owned plugins dir can't be chmodded by the unprivileged backend). Launch extras ride env-prefix tokens: `PF_LAUNCH=<id>` (pinned game) / `PF_BROWSE=1` + `PF_MGMT=<port>` (on-screen library); ids are validated space/quote-free at pin AND launch time. |
| `src/backend.ts` | Typed `callable` bridges to `main.py`. |
| `bin/punktfunkrun.sh` | The launch wrapper the Steam shortcut runs (so the window is focusable); maps `PF_LAUNCH`/`PF_BROWSE`/`PF_MGMT` to `--launch`/`--browse`/`--mgmt`. An older flatpak ignores the flags harmlessly (plain stream / hosts page). |
| `main.py` | Backend: `discover` (via `avahi-browse`) / `pair` / `library` (headless flatpak `--library`, TSV) / pins store (`decky-pinned.json`) / settings / `kill_stream` / `check_update` (with an explicit CA-bundle search — Decky's embedded Python has no usable default TLS roots on SteamOS). |
| `scripts/test-backend.py` | Stdlib-only checks for the backend's pure parsers (TSV, error classes, avahi TXT) + the pins round trip. |
| `src/index.tsx` | Plugin entry + the QAM panel: update banner, hosts (with nested pinned cards), the console-home door, about. |
| `src/hooks.ts` | `useHosts` (one call merging discovery and the saved store), the update hooks, and the launch action. Also the trust-state model the rows render. |
| `src/trust.tsx` · `src/pair.tsx` | The trust sheet (Request access / Use a PIN instead / Cancel) and the gamepad-navigable PIN keypad. |
| `src/steam.ts` | Steam-shortcut launch (`AddShortcut` / `SetAppLaunchOptions` / `RunGame`) — the focus-correct stream start. The shortcut's exe is `/bin/sh` with the wrapper passed as an argument, so the script never needs an exec bit (Decky's zip extraction drops it and the root-owned plugins dir can't be chmodded by the unprivileged backend). |
| `src/backend.ts` · `src/boundary.tsx` · `src/os-icon.tsx` | Typed `callable` bridges to `main.py`; the render error boundary; the host row's OS mark. |
| `bin/punktfunkrun.sh` | The launch wrapper the Steam shortcut runs (so the window is focusable). Reads `PF_REF` / `PF_PROFILE` / `PF_REQUEST_ACCESS` / `PF_BROWSE` and runs `punktfunk launch` — or the session's `--browse` for console home. |
| `main.py` | Backend: four thin CLI shells (`discover` / `hosts` / `pair` / `trust_host`) plus the Steam-side work only a plugin can do — `runner_info`, `shortcut_art`, `apply_controller_config`, `kill_stream`, `check_update` / `update_client` (with an explicit CA-bundle search — Decky's embedded Python has no usable default TLS roots on SteamOS). |
| `scripts/test-backend.py` | Stdlib-only checks: argv shape, the CLI exit-code mapping, and the Steam configset editor. |
| `plugin.json` · `update.json` | Decky manifest; CI-baked update channel. |
### Why the launch goes through Steam
gamescope only gives focus and fullscreen to the window tree Steam launched via `reaper` (it
detects the "current app" by AppID — gamescope#484). A client spawned from the plugin's own
backend comes up invisible and unfocused. So the plugin registers non-Steam shortcuts whose exe is
`/bin/sh` running `bin/punktfunkrun.sh`, and starts them with `RunGame`.
There are **two** shortcuts, both named `Punktfunk` so Steam keys them to one Steam Input
configset (the key is the lowercase name): a hidden, stateful one that carries the stream, and the
visible, stateless library entry that opens console home.
## Limitations / next steps
- No manual "add host by IP" entry yet (discovery is mDNS-only).
- No in-stream overlay inside the plugin — the client owns the session once launched.
- Pairing needs the operator to **arm pairing on the host** so it shows the PIN; the plugin can't arm
it remotely.
- **Profiles and pinned cards can't be created here** — the panel renders them; making one needs
the desktop client, or the client's own gamepad UI once that work lands. A Deck with no profiles
simply sees host rows, and nothing is broken.
- **Per-game pins are on hold.** The shared model pins *host+profile*; nothing in the shared store
persists a pinned *game* yet. The old `decky-pinned.json` is left on disk untouched so a later
migration can read it.
- Pairing with a PIN needs the operator to **arm pairing on the host** so it shows the PIN; the
plugin can't arm it remotely. Request access needs no arming — just an approval.
- **A parked connect looks like a hanging one.** The plugin toasts before launching a request-access
stream to set expectations, which is a patch rather than a fix; teaching the session's connect
screen the same "waiting for approval" copy the console shell already has would pay off for every
shell.
## Related
+56 -53
View File
@@ -1,33 +1,32 @@
#!/usr/bin/env bash
# punktfunk stream runner — the target of the hidden non-Steam shortcut the plugin creates.
# punktfunk stream runner — the target of the non-Steam shortcuts the plugin creates.
#
# WHY A WRAPPER SCRIPT (load-bearing, from MoonDeck's hard-won knowledge): the stream client
# must be a descendant of the process Steam launches via `reaper`, or gamescope never gives
# its window focus/fullscreen in Gaming Mode (gamescope detects the "current app" by AppID,
# which only attaches to reaper's descendants — see gamescope#484). So the Decky plugin
# launches THIS script through SteamClient.Apps.RunGame; the script then execs the flatpak
# client, which inherits the shortcut's AppID and is focused. Launching the flatpak directly
# from the (root) Decky backend produces an unfocused, invisible window.
# launches THIS script through SteamClient.Apps.RunGame; the script then runs the client,
# which inherits the shortcut's AppID and is focused. Launching the client directly from the
# (root) Decky backend produces an unfocused, invisible window.
#
# Per-session parameters arrive as environment variables, set as the shortcut's Steam launch
# options by the plugin (SteamClient.Apps.SetAppLaunchOptions), so ONE generic shortcut serves
# every host (and every pinned game):
# PF_HOST host[:port] to connect to (required for streaming; optional for browse)
# PF_LAUNCH library id to launch on connect (optional, e.g. steam:570 — pinned games)
# PF_BROWSE non-empty = open the gamepad library (optional; --browse instead of --connect)
# PF_MGMT management-API port for --browse (optional; client defaults to 47990)
# PF_CONNECT_TIMEOUT connect budget in seconds (optional; the plugin stretches it after
# firing Wake-on-LAN so the connect survives the host's resume)
# PF_APPID flatpak app id (default io.unom.Punktfunk)
# PF_FLATPAK override the flatpak binary path (default: `flatpak` on PATH)
# every host:
# PF_REF host reference — a saved host's stable id, or addr[:port] (required to stream)
# PF_PROFILE settings-profile id for a pinned card (optional)
# PF_REQUEST_ACCESS non-empty = ask the host's operator to admit this device instead of
# pairing with a PIN. The connect PARKS until somebody approves it.
# PF_BROWSE non-empty = open the client's console home instead of streaming
# PF_APPID flatpak app id (default io.unom.Punktfunk)
# PF_FLATPAK override the flatpak binary path (default: `flatpak` on PATH)
# PF_CLIENT_BIN absolute path of a NATIVE client (optional; set by the plugin when it
# resolved a non-flatpak install — then the client is exec'd directly and
# resolved a non-flatpak install — then the client is run directly and
# PF_APPID/PF_FLATPAK are unused)
#
# Values are plain tokens (the plugin validates launch ids to space/quote-free ASCII before
# they ever reach Steam launch options). An older flatpak without --launch/--browse ignores
# the unknown flags harmlessly (hand-scanned argv): PF_LAUNCH degrades to the plain desktop
# session, PF_BROWSE to the client's hosts page.
# A REFERENCE, NEVER A VALUE. Host refs and profile ids are the only things that ride this
# channel; no resolution, bitrate or codec ever does. The client resolves both against its own
# stores, which is what keeps a Steam launch option from becoming a second settings surface.
# The plugin validates them to space/quote-free ASCII before they reach Steam's tokenizer.
#
# Runs as the `deck` user (Steam launched it), so the --user flatpak install is visible and
# WAYLAND_DISPLAY / XDG_RUNTIME_DIR are already correct for gamescope.
@@ -42,13 +41,22 @@ APPID="${PF_APPID:-io.unom.Punktfunk}"
FLATPAK="${PF_FLATPAK:-flatpak}"
# The client is not always the flatpak: a sysext, a .deb/.rpm, an AUR build or a nix profile
# installs a native `punktfunk-client`, and the plugin passes its absolute path here when that
# is what it resolved. Both kinds take the same argv and share ~/.config/punktfunk, so the only
# difference is the prefix in front of it.
# installs a native `punktfunk-client` with the CLI as its sibling, and the plugin passes the
# client's absolute path here when that is what it resolved.
#
# exec so the client IS the game process — when it exits, Steam ends the "game" and Gaming Mode
# reclaims focus automatically (no manual refocus needed).
run_client() {
# run_cli execs the HEADLESS CLI (`punktfunk`); run_session execs the GTK/console shell
# (`punktfunk-client`). Both live in the same place in both install kinds — /app/bin inside the
# flatpak, reachable with `--command=`, and one bindir natively.
run_cli() {
if [ -n "${PF_CLIENT_BIN:-}" ]; then
# `${VAR%/*}` rather than `dirname`: pure parameter expansion, so this works with no
# PATH at all — which is the environment a Steam launch option can leave us in.
exec "${PF_CLIENT_BIN%/*}/punktfunk" "$@"
fi
exec "$FLATPAK" run --arch=x86_64 --command=punktfunk "$APPID" "$@"
}
run_session() {
if [ -n "${PF_CLIENT_BIN:-}" ]; then
exec "$PF_CLIENT_BIN" "$@"
fi
@@ -58,40 +66,35 @@ run_client() {
# What we are about to run, for the log line each branch prints.
CLIENT_LABEL="${PF_CLIENT_BIN:-$APPID}"
# --fullscreen: present the stream chrome-less and fullscreen (the client also auto-detects the
# Deck/gamescope env, and ignores the flag harmlessly on older builds that predate it).
# The console home: the client's own gamepad UI (host picker, pairing, add-host by address, the
# library browser and the full settings screen). UNCHANGED from before this rework — the shell
# binary already execs the session for `--browse`, so there is nothing to repoint here.
if [ -n "${PF_BROWSE:-}" ]; then
# The gamepad UI. BARE `--browse` (no PF_HOST) opens the console home — the self-contained
# host picker + pairing + settings, gamepad-navigable — which is what the stateless, visible
# library shortcut launches. `--browse <host>` opens straight into that host's library (the
# per-host "open on screen" action). A streams a game, session end returns here, B quits.
if [ -z "${PF_HOST:-}" ]; then
echo "punktfunkrun: gamepad UI $CLIENT_LABEL --browse (console home)" >&2
run_client --browse --fullscreen
fi
echo "punktfunkrun: library $CLIENT_LABEL --browse $PF_HOST" >&2
if [ -n "${PF_MGMT:-}" ]; then
run_client --browse "$PF_HOST" --mgmt "$PF_MGMT" --fullscreen
fi
run_client --browse "$PF_HOST" --fullscreen
echo "punktfunkrun: gamepad UI $CLIENT_LABEL --browse (console home)" >&2
run_session --browse --fullscreen
fi
# Streaming modes need a host (browse above is the only host-less path).
if [ -z "${PF_HOST:-}" ]; then
echo "punktfunkrun: PF_HOST is not set (the plugin sets it as a launch option)" >&2
if [ -z "${PF_REF:-}" ]; then
echo "punktfunkrun: PF_REF is not set (the plugin sets it as a launch option)" >&2
exit 2
fi
# Trailing args shared by both streaming execs. A stretched connect budget rides along when the
# plugin set one (it just fired Wake-on-LAN, so the host may still be resuming); an older flatpak
# without --connect-timeout ignores the flag harmlessly (hand-scanned argv).
set -- --fullscreen
if [ -n "${PF_CONNECT_TIMEOUT:-}" ]; then
set -- --connect-timeout "$PF_CONNECT_TIMEOUT" "$@"
if [ -n "${PF_PROFILE:-}" ]; then
set -- --profile "$PF_PROFILE" "$@"
fi
if [ -n "${PF_LAUNCH:-}" ]; then
# A pinned game: the id rides the session Hello and the host launches that title.
echo "punktfunkrun: streaming $CLIENT_LABEL --connect $PF_HOST --launch $PF_LAUNCH" >&2
run_client --connect "$PF_HOST" --launch "$PF_LAUNCH" "$@"
# REQUEST ACCESS RUNS SUPERVISED — no `--exec`. Under --exec the CLI BECOMES the session, so no
# process survives to see the stream come up and record the host as paired; the CLI refuses the
# combination outright rather than downgrading silently. This is safe for gamescope because
# focus follows reaper's DESCENDANT TREE, not a single process, and `flatpak run`/`bwrap`
# already sit between reaper and the client on every other path.
if [ -n "${PF_REQUEST_ACCESS:-}" ]; then
echo "punktfunkrun: request access $CLIENT_LABEL launch $PF_REF (waiting for approval)" >&2
run_cli launch "$PF_REF" --request-access "$@"
fi
echo "punktfunkrun: streaming $CLIENT_LABEL --connect $PF_HOST" >&2
run_client --connect "$PF_HOST" "$@"
# The ordinary stream. `--exec` is the documented gamescope-wrapper mode: the CLI becomes the
# session, so the process tree stays flat and Steam's "game" ends exactly when the stream does.
echo "punktfunkrun: streaming $CLIENT_LABEL launch $PF_REF" >&2
run_cli launch "$PF_REF" --exec "$@"
+214 -725
View File
File diff suppressed because it is too large Load Diff
+126 -127
View File
@@ -1,12 +1,12 @@
#!/usr/bin/env python3
"""Unit checks for main.py's pure helpers — stdlib only, no Decky runtime needed.
Stubs the ``decky`` module (main.py imports it at module level), then asserts the
avahi/TSV/error parsers against fixture strings. The LibraryError fixtures are pinned to
the REAL Display strings in clients/linux/src/library.rs if those are reworded, the
classifier degrades to ``client-error`` and the matching assertion here fails on purpose.
Stubs the ``decky`` module (main.py imports it at module level), then asserts the argv
shapes, the exit-code mapping and the Steam VDF editor against fixtures.
python3 clients/decky/scripts/test-backend.py
Needs Python >= 3.10 for `X | None` annotations macOS ships 3.9, so run it explicitly:
python3.13 clients/decky/scripts/test-backend.py
"""
import sys
@@ -40,136 +40,135 @@ def check(name: str, cond: bool):
failures += 1
# ---- _parse_library_tsv -----------------------------------------------------------------
tsv = (
"steam:570\tsteam\tDota 2\n"
"custom:abc\tcustom\tTabs\tin\ttitle\n" # tabs inside the title survive (split max 2)
"2 game(s)\n" # the count trailer has no tabs — self-skips
# ---- _cli_argv: the flatpak app id must stay LAST ---------------------------------------
#
# `flatpak run --command=X <app-id> ARGS` — everything after the app id is the APP's argv, so
# an app id that drifts left silently turns our flags into the client's. This is the shape the
# deleted _session_argv used and the one thing about it that is easy to get wrong.
main._client_argv = lambda: ["/usr/bin/flatpak", "run", "--arch=x86_64", "io.unom.Punktfunk"]
main._flatpak = lambda: "/usr/bin/flatpak"
check(
"cli argv: flatpak form, app id last",
main._cli_argv()
== [
"/usr/bin/flatpak",
"run",
"--arch=x86_64",
"--command=punktfunk",
"io.unom.Punktfunk",
],
)
games = main._parse_library_tsv(tsv)
check("tsv: two games parsed", len(games) == 2)
check("tsv: fields", games[0] == {"id": "steam:570", "store": "steam", "title": "Dota 2"})
check("tsv: tabs in title preserved", games[1]["title"] == "Tabs\tin\ttitle")
check("tsv: empty input", main._parse_library_tsv("0 game(s)\n") == [])
# ---- _classify_library_error (fixtures = library.rs Display strings) --------------------
check(
"err: not-paired",
main._classify_library_error(
"library: The host didn't recognize this device. Pair with the host first — the "
"library is authorized by this device's certificate (no token needed)."
)
== "not-paired",
)
check(
"err: pin-mismatch",
main._classify_library_error(
"library: The host's certificate doesn't match the pinned fingerprint. "
"Re-pair with a PIN to re-establish trust."
)
== "pin-mismatch",
)
check(
"err: unreachable",
main._classify_library_error(
"library: Couldn't reach the host's management API: connection refused. Check the "
"host is updated and reachable."
)
== "unreachable",
)
check(
"err: http",
main._classify_library_error("library: The management API returned HTTP 500.") == "http",
)
check(
"err: outdated client (GTK init noise)",
main._classify_library_error("cannot open display: \nGtk-WARNING: init failed")
== "client-outdated",
)
check("err: generic fallback", main._classify_library_error("boom") == "client-error")
# ---- _parse_avahi_browse (incl. the new id/mgmt TXT keys) --------------------------------
avahi = (
"+;eth0;IPv4;living-room;_punktfunk._udp;local\n"
"=;eth0;IPv4;living-room;_punktfunk._udp;local;lr.local;192.168.1.42;9777;"
'"proto=punktfunk/1" "fp=aabbcc" "pair=required" "id=abc123" "mgmt=47990"\n'
"=;eth0;IPv6;living-room;_punktfunk._udp;local;lr.local;fe80::1;9777;"
'"proto=punktfunk/1" "fp=aabbcc" "pair=required" "id=abc123" "mgmt=47990"\n'
"=;eth0;IPv4;bare-host;_punktfunk._udp;local;bh.local;192.168.1.77;9777;"
'"proto=punktfunk/1" "fp=ddeeff" "pair=optional"\n'
)
hosts = main._parse_avahi_browse(avahi)
check("avahi: two hosts (id-dedup, IPv4 preferred)", len(hosts) == 2)
lr = next(h for h in hosts if h["name"] == "living-room")
check("avahi: ipv4 wins", lr["host"] == "192.168.1.42")
check("avahi: mgmt parsed", lr["mgmt"] == 47990)
check("avahi: id parsed", lr["id"] == "abc123")
bare = next(h for h in hosts if h["name"] == "bare-host")
check("avahi: mgmt absent -> 0", bare["mgmt"] == 0)
check("avahi: id absent -> empty", bare["id"] == "")
# ---- pins store (round-trip through the real methods, isolated HOME) --------------------
import asyncio # noqa: E402
# A native install: the CLI is the client binary's sibling. Absent => no CLI at all, which the
# caller must see as "unavailable" rather than as an empty result.
#
# The fixture dir is torn down FIRST, not just created: leaving the sibling behind made the
# "absent" assertion below pass only on the first run of the day and fail on every rerun.
import shutil # noqa: E402
shutil.rmtree(decky.DECKY_USER_HOME, ignore_errors=True)
plugin = main.Plugin()
pin = {
"game_id": "steam:570",
"title": "Dota 2",
"store": "steam",
"host_fp": "AABBCC",
"host_id": "abc123",
"host_name": "living-room",
"host": "192.168.1.42",
"port": 9777,
"mgmt": 47990,
"added_at": 1780000000,
}
dupe = dict(pin, title="Dota 2 again")
junk = {"title": "no game id"}
res = asyncio.run(plugin.set_pins([pin, dupe, junk]))
check("pins: write ok", res.get("ok") is True)
got = asyncio.run(plugin.get_pins())["pins"]
check("pins: dedup + junk dropped", len(got) == 1)
check("pins: unpaired without known-hosts", got[0]["paired"] is False)
# Mark the host paired in the client's known-hosts store — get_pins must pick it up.
cfg = main._client_config_dir()
cfg.mkdir(parents=True, exist_ok=True)
(cfg / "client-known-hosts.json").write_text(
'{"hosts": [{"name": "living-room", "addr": "192.168.1.42", "port": 9777, '
'"fp_hex": "aabbcc", "paired": true}]}'
)
got = asyncio.run(plugin.get_pins())["pins"]
check("pins: paired via known-hosts fp (case-insensitive)", got[0]["paired"] is True)
shutil.rmtree(decky.DECKY_USER_HOME, ignore_errors=True)
shutil.rmtree("/tmp/pf-test-native", ignore_errors=True)
tmp = Path("/tmp/pf-test-native/bin")
tmp.mkdir(parents=True, exist_ok=True)
(tmp / "punktfunk-client").write_text("")
main._client_argv = lambda: [str(tmp / "punktfunk-client")]
check("cli argv: native without a sibling CLI is None", main._cli_argv() is None)
(tmp / "punktfunk").write_text("")
check("cli argv: native sibling found", main._cli_argv() == [str(tmp / "punktfunk")])
# ---- `--list-audio` parsing (the settings tab's device pickers) --------------------------
sinks, sources = main._parse_audio_endpoints(
"sink\talsa_output.pci-0000_04_00.6.analog-stereo\tSteam Deck Speakers\n"
"sink\tbluez_output.AC_12_2F.1\tWH-1000XM4\n"
"source\talsa_input.pci-0000_04_00.6.analog-stereo\tSteam Deck Microphone\n"
# ---- _cli_error: the CLI's exit-code contract -------------------------------------------
#
# Exit 5 + `unknown command` is how a client too old for a verb announces itself — the ONE
# signature the panel turns into "update the client" plus the button that fixes it. Getting it
# wrong makes an out-of-date client look like a broken plugin.
check(
"err: unknown verb => client-outdated",
main._cli_error(5, 'unknown command "discover"\n\npunktfunk — the Punktfunk client')
== "client-outdated",
)
check("audio: sinks parsed", [d["name"] for d in sinks] == [
"alsa_output.pci-0000_04_00.6.analog-stereo", "bluez_output.AC_12_2F.1"
])
check("audio: sources parsed", len(sources) == 1)
check("audio: description kept", sinks[1]["description"] == "WH-1000XM4")
check(
"err: exit 5 without that phrase is NOT outdated",
main._cli_error(5, 'no saved host matches "desk"') == "unresolved",
)
check("err: connect failed", main._cli_error(2, "unreachable 10.0.0.1:9777") == "unreachable")
check("err: trust rejected", main._cli_error(3, "wrong PIN") == "refused")
check("err: needs a person", main._cli_error(6, "pair it first") == "needs-pairing")
check("err: nothing ran", main._cli_error(-1, "") == "client-unavailable")
check("err: unmapped code falls back", main._cli_error(4, "renderer") == "client-error")
# Junk the picker must not offer: no node.name is unusable (it is the id that gets stored), a
# short line is malformed, and an unknown kind belongs to neither list. A blank description
# falls back to the name so no entry renders unlabelled.
sinks, sources = main._parse_audio_endpoints(
"sink\t\tNo node name\n"
"sink\tonly-two-columns\n"
"monitor\tsome.monitor\tNot a sink or source\n"
"source\tbare.node\t\n"
"\n"
# ---- _cli_json: a zero exit with junk on stdout is a FAILURE, not an empty result --------
import asyncio # noqa: E402
def _fake_cli(rc: int, out: str, err: str = ""):
async def run(_args, timeout=20.0):
return rc, out, err
return run
main._run_cli = _fake_cli(0, '{"hosts": [{"name": "desk"}]}')
got = asyncio.run(main._cli_json(["discover", "--json"]))
check("json: payload merged under ok", got == {"ok": True, "hosts": [{"name": "desk"}]})
main._run_cli = _fake_cli(0, "not json at all")
got = asyncio.run(main._cli_json(["discover", "--json"]))
check("json: unparseable stdout is an error, not an empty list", got["ok"] is False)
check("json: ...and says so specifically", got["error"] == "client-error")
main._run_cli = _fake_cli(5, "", 'unknown command "discover"')
got = asyncio.run(main._cli_json(["discover", "--json"]))
check("json: old client surfaces as client-outdated", got["error"] == "client-outdated")
check("json: detail carries the CLI's own last line", "unknown command" in got["detail"])
# ---- _field_from (flatpak info parsing, drives the client update check) ------------------
info = " ID: io.unom.Punktfunk\n Origin: punktfunk-origin\n Commit: abc123def\n"
check("field: commit", main._field_from(info, "Commit") == "abc123def")
check("field: origin", main._field_from(info, "Origin") == "punktfunk-origin")
check("field: absent", main._field_from(info, "Nope") == "")
# ---- _looks_outdated (the GTK-init signature of a client predating a headless flag) ------
check("outdated: gtk init noise", main._looks_outdated("cannot open display: \nGtk-WARNING") is True)
check("outdated: an ordinary error is not", main._looks_outdated("connection refused") is False)
# ---- _semver_tuple (plugin update comparison) --------------------------------------------
check("semver: plain", main._semver_tuple("1.2.3") == (1, 2, 3))
check("semver: pre-release suffix dropped", main._semver_tuple("1.2.3-rc1") == (1, 2, 3))
check("semver: short forms pad", main._semver_tuple("2") == (2, 0, 0))
check("semver: ordering", main._semver_tuple("0.10.0") > main._semver_tuple("0.9.9"))
# ---- _upsert_configset_entry (Steam Input layout binding) --------------------------------
#
# Untested until now, and the riskiest thing that survived the cut: it edits a file holding
# HUNDREDS of other games' controller bindings, in place. Every assertion below is about not
# touching them.
empty = main._upsert_configset_entry("", "punktfunk", "template", "punktfunk.vdf")
check("vdf: builds the skeleton when the file is new", '"controller_config"' in empty)
check("vdf: the entry lands", '"punktfunk"' in empty and '"punktfunk.vdf"' in empty)
existing = (
'"controller_config"\n'
"{\n"
'\t"halflife2"\n'
"\t{\n"
'\t\t"template"\t\t"other.vdf"\n'
"\t}\n"
"}\n"
)
check("audio: junk lines dropped", sinks == [])
check("audio: blank description falls back to the node name", sources == [
{"name": "bare.node", "description": "bare.node"}
])
added = main._upsert_configset_entry(existing, "punktfunk", "template", "punktfunk.vdf")
check("vdf: an existing game's entry survives insertion", '"halflife2"' in added)
check("vdf: ours is inserted", '"punktfunk"' in added)
# Re-running must REPLACE our block, not accumulate a second one (this runs on every plugin
# session gated only by a localStorage marker, so idempotence is the whole contract).
twice = main._upsert_configset_entry(added, "punktfunk", "template", "punktfunk.vdf")
check("vdf: idempotent", twice.count('"punktfunk"\n') == 1)
check("vdf: neighbour still intact after the rewrite", '"halflife2"' in twice)
# Steam keys non-Steam games by their LOWERCASE name, and files on disk may carry either case —
# a case-sensitive match would append a duplicate the game never reads.
mixed = existing.replace('"halflife2"', '"Punktfunk"')
replaced = main._upsert_configset_entry(mixed, "punktfunk", "template", "punktfunk.vdf")
check("vdf: matches an existing key case-insensitively", replaced.count("unktfunk\"\n") == 1)
print()
if failures:
+92 -218
View File
@@ -1,95 +1,94 @@
// Bridge to the Python backend (main.py) + shared types.
//
// Every call here is a thin shell over the headless `punktfunk` CLI, so these types are the
// CLI's JSON shapes rather than anything this plugin invents. That is deliberate: the plugin
// used to model the client's stores itself and drifted from them with every field the client
// added.
import { callable } from "@decky/api";
export interface Host {
name: string;
host: string;
port: number;
pair: string; // "required" | "optional" — the HOST's policy
fp: string; // host cert SHA-256 fingerprint (lowercase hex) from the mDNS advert
proto: string; // advertised protocol, e.g. "punktfunk/1"
paired: boolean; // whether THIS device has already PIN-paired this host (by fingerprint)
id: string; // the host's stable instance id (mDNS TXT `id`; "" when not advertised)
mgmt: number; // management-API port (mDNS TXT `mgmt`; 0 = not advertised → default 47990)
os: string; // OS-identity chain (mDNS TXT `os`, e.g. "linux/fedora/bazzite"); "" on older hosts
}
// One title from a host's game library (the flatpak client's --library TSV, parsed by the
// backend). `id` is store-qualified (steam:<appid> / custom:<id>) and doubles as the
// launch handle (PF_LAUNCH → the session Hello).
export interface GameEntry {
/** A settings profile as the CLI resolves it — ids are dangling-checked and names attached. */
export interface Profile {
id: string;
store: string; // "steam" | "custom" | "heroic" | "lutris" | …
title: string;
name: string;
}
export interface LibraryResult {
ok: boolean;
games?: GameEntry[];
// "flatpak-not-found" | "timeout" | "not-paired" | "pin-mismatch" | "unreachable" |
// "http" | "client-outdated" | "client-error"
error?: string;
detail?: string; // the client's own one-line reason, for the generic error copy
}
// A pinned game — a one-tap stream row in the QAM. The host is identified primarily by
// cert fingerprint (survives IP changes; pairing is fp-keyed too), with the stored
// address as the launch fallback when the host isn't currently advertising.
export interface PinnedGame {
game_id: string;
title: string;
store: string;
host_fp: string;
host_id: string;
host_name: string;
host: string;
port: number;
mgmt: number;
added_at: number; // unix seconds
paired?: boolean; // annotated by get_pins from the client's known-hosts store
}
export interface PairResult {
ok: boolean;
fp?: string;
error?: string;
}
// A host in the SHARED saved-hosts store (client-known-hosts.json) — the same file the desktop
// client reads/writes, so add/rename/pair in either surface shows up in both. `online` comes
// from a mDNS-INDEPENDENT reachability probe (a Tailscale/VPN host isn't shown offline just
// because it doesn't advertise); `null` means reachability is unknown (probe skipped or a client
// too old for `--list-hosts`, which then also can't probe).
export interface SavedHost {
/**
* A host answering on mDNS right now (`punktfunk discover --json`).
*
* `saved`/`paired` are annotated BY THE CLI against the saved-hosts store fingerprint first,
* address second. The plugin does not join the two lists itself; that rule living in one place
* is what stops this surface disagreeing with the desktop client about the same box.
*/
export interface DiscoveredHost {
name: string;
addr: string;
port: number;
fp_hex: string; // host cert fingerprint (lowercase hex); "" for a not-yet-paired manual entry
fp: string; // advertised cert fingerprint (lowercase hex); "" when not advertised
pair: string; // the HOST's policy: "required" | "optional"
id: string; // the host's advertised stable id; "" when not advertised
mgmt: number; // management-API port; 0 = not advertised
os: string; // OS-identity chain, e.g. "linux/fedora/bazzite"; "" on older hosts
saved: boolean;
paired: boolean;
}
/**
* A host in the shared saved-hosts store (`punktfunk hosts list --probe --json`) the same
* `client-known-hosts.json` the desktop client owns.
*
* `online` comes from a mDNS-INDEPENDENT probe, so a host reached over Tailscale/VPN is not
* shown offline merely because it never advertises; `null` means the probe was skipped.
*
* `profile` is the host's DEFAULT binding, which a plain connect applies silently. It is not
* the same thing as `pinned_profiles`, which are the cards a user chose to surface. Both come
* back already resolved against the profile catalog, so this plugin never opens it.
*/
export interface SavedHost {
id: string | null; // the record's stable id — the reference a launch should use
name: string;
addr: string;
port: number;
fp_hex: string; // "" for a placeholder saved by address with no pin yet
paired: boolean;
mac: string[];
// OS-identity chain learned by the desktop client; optional because the installed
// flatpak client may predate the field.
os?: string;
os: string;
last_used: number | null;
clipboard_sync: boolean;
profile: Profile | null;
pinned_profiles: Profile[];
online: boolean | null;
}
export interface HostsResult {
ok: boolean;
hosts: SavedHost[];
probed: boolean;
fallback?: boolean; // true when read straight off disk (client too old for --list-hosts)
}
// The result of a host-store mutation (add/edit/forget). `error` is a stable code:
// "client-unavailable" (flatpak missing) | "client-outdated" (client predates the mode) |
// "unreachable"/"http"/… (from the client) | "client-error" (generic; see `detail`).
export interface MutationResult {
/**
* Every backend call answers in this shape. `error` is a stable code, never prose:
*
* - `client-unavailable` no client is installed, or the call never ran
* - `client-outdated` the installed client predates the verb (exit 5 + `unknown command`)
* - `unreachable` the host did not answer
* - `refused` trust rejected: a wrong PIN, or a fingerprint that already differs
* - `needs-pairing` the CLI refused because it needs a person
* - `unresolved` nothing matched what was named
* - `client-error` anything else; `detail` carries the CLI's own last line
*/
export interface CliResult {
ok: boolean;
error?: string;
detail?: string;
}
export interface DiscoverResult extends CliResult {
hosts?: DiscoveredHost[];
}
export interface HostsResult extends CliResult {
hosts?: SavedHost[];
}
export interface PairResult extends CliResult {
fp?: string;
}
export interface RunnerInfo {
runner: string; // absolute path to bin/punktfunkrun.sh
app_id: string; // flatpak app id
@@ -101,99 +100,6 @@ export interface RunnerInfo {
client_bin?: string;
}
// The flatpak client's settings JSON — the SAME `client-gtk-settings.json` the desktop client
// and the console's settings screen own, so a value changed in any of them shows in the others.
//
// Every field the client's `Settings` struct persists is modelled here EXCEPT the ones that
// cannot be answered from a plugin backend or aren't settings at all:
// • `forward_pad` — which physical pad is player 1. Needs SDL's live device list, which only
// the client process has; there is no CLI that enumerates pads.
// • `last_window_w/h` — the session's remembered window size, written BY the client, not a
// preference anyone sets.
// Both round-trip untouched: get_settings returns the whole parsed file, patches are object
// spreads, and set_settings merges onto what's on disk.
//
// Optional (`?`) marks a key the client writes with a serde `default`, so a store written before
// that key existed simply lacks it. Read those through the same fallback the client uses —
// `?? true` for the default-on ones, never `!!` — or a pre-existing file reads as "off" here
// while the stream runs with it on.
export interface StreamSettings {
// ---- Stream mode ----
width: number; // 0 = native
height: number; // 0 = native
refresh_hz: number; // 0 = native
render_scale?: number; // render-resolution multiplier; 1.0 = native (absent in pre-scale files)
bitrate_kbps: number; // 0 = host default
compositor: string; // "auto" | "kwin" | "wlroots" | "mutter" | "gamescope"
// Stream mode follows the session window instead of width/height, renegotiating on resize.
// Overrides width/height while on; degenerates to the display's native mode on fullscreen.
match_window?: boolean;
// ---- Video ----
codec?: string; // "auto" | "hevc" | "h264" | "av1" | "pyrowave" (absent in pre-codec files)
decoder?: string; // "auto" | "vulkan" | "vaapi" | "software"
hdr_enabled?: boolean; // default ON — advertise 10-bit/HDR10
enable_444?: boolean; // default off — ask for full chroma
adapter?: string; // decode/present GPU by marketing name; "" = automatic
// ---- Presentation ----
// What the client optimises for when a decoded frame is ready: "latency" | "smooth". Shared
// with the Apple and Android clients under this name, so one profile reads the same everywhere.
present_priority?: string;
smooth_buffer?: number; // frames held back under "smooth"; 0 = Automatic (resolves to 2), else 13
vsync?: boolean; // default ON — tear-free; off asks for a tearing present mode (best-effort)
allow_vrr?: boolean; // default ON — let a VRR panel refresh in step with the stream
// ---- Audio ----
audio_channels?: number; // 2 (stereo) | 6 (5.1) | 8 (7.1)
speaker_device?: string; // PipeWire node.name for playback; "" = system default
mic_enabled: boolean;
mic_device?: string; // PipeWire node.name for capture; "" = system default
echo_cancel?: boolean; // default ON; only meaningful while mic_enabled
// ---- Controllers ----
gamepad: string; // "auto" | "xbox360" | "xboxone" | "dualsense" | "dualshock4" | "steamdeck"
// Forward this device's controllers at all. Absent in pre-forwarding files, where the
// client's own serde default (true) applies — so `?? true` at every read, never `!!`.
gamepad_forwarding?: boolean;
// ---- Touchscreen, mouse & keyboard ----
touch_mode?: string; // "trackpad" | "pointer" | "touch"
mouse_mode?: string; // "capture" | "desktop"
invert_scroll?: boolean;
// Whether the session grabs the keyboard so Alt+Tab/Super reach the host.
inhibit_shortcuts: boolean;
// ---- Interface & behaviour ----
// Stats-overlay tier: "off" | "compact" | "normal" | "detailed". Absent in a pre-tier file,
// which resolves through `show_stats` — read both the way the client's
// `Settings::stats_verbosity` does, and write both the way `set_stats_verbosity` does.
stats_verbosity?: string;
// The legacy on/off the tier supersedes; kept written in sync so a client that predates the
// tiers still honours an Off chosen here.
show_stats?: boolean;
fullscreen_on_stream?: boolean;
auto_wake?: boolean; // default ON — Wake-on-LAN a sleeping host before connecting
library_enabled?: boolean; // the CLIENT's own library browser (this plugin has its own)
}
// One audio endpoint from the client's enumeration: the stable id that gets stored, plus the
// human name to show.
export interface AudioDevice {
name: string; // PipeWire node.name — what `speaker_device` / `mic_device` store
description: string; // human label ("Steam Deck Speakers")
}
// What the device pickers need, read from the session binary (`--list-adapters` / `--list-audio`).
// `ok: false` = the session binary couldn't be run or failed; every list is then empty and the
// pickers stay on their stored value rather than pretending the device is gone.
export interface DeviceLists {
ok: boolean;
adapters: string[]; // Vulkan physical devices, discrete first
sinks: AudioDevice[]; // playback endpoints
sources: AudioDevice[]; // capture endpoints
}
export interface UpdateInfo {
current: string; // installed PLUGIN version (package.json)
latest: string; // newest plugin version in our registry for this channel
@@ -229,21 +135,30 @@ export interface ShortcutArt {
icon_path: string;
}
export const discover = callable<[], Host[]>("discover");
// ---- The four CLI shells --------------------------------------------------------------
/** Browse the LAN over mDNS. Bounded by the CLI (3 s) plus a cold-start allowance. */
export const discover = callable<[], DiscoverResult>("discover");
/** The saved hosts, probed for reachability, with profiles and pinned cards resolved. */
export const hosts = callable<[], HostsResult>("hosts");
/** The PIN ceremony. `refused` = wrong PIN or a host that isn't armed. */
export const pair = callable<
[host: string, port: number, pin: string, name: string],
[addr: string, port: number, pin: string, name: string],
PairResult
>("pair");
// Fetch a paired host's game library (headless flatpak --library; can take seconds on a
// cold client start — show a spinner). Pass fp whenever known so the pin can't degrade.
export const library = callable<
[host: string, mgmt_port: number, fp: string],
LibraryResult
>("library");
export const getPins = callable<[], { pins: PinnedGame[] }>("get_pins");
export const setPins = callable<[pins: PinnedGame[]], { ok: boolean; error?: string }>(
"set_pins",
);
/**
* Step 1 of request access: save the host with its ADVERTISED fingerprint, pinned but unpaired.
* The launch that follows pins the same fingerprint, which is the only thing standing between a
* 185 s wait for approval and an impostor answering for the host. Idempotent; a host already
* saved under a DIFFERENT fingerprint comes back `refused` rather than being overwritten.
*/
export const trustHost = callable<
[addr: string, port: number, fp: string, name: string],
CliResult
>("trust_host");
// ---- Steam / plugin business (only a Decky plugin can do these) ------------------------
export const runnerInfo = callable<[], RunnerInfo>("runner_info");
export const shortcutArt = callable<[], ShortcutArt>("shortcut_art");
// Install the Steam Input layout (native touchscreen `ts_n` + gamepad passthrough) and point our
@@ -254,48 +169,7 @@ export const applyControllerConfig = callable<
[name: string],
{ ok: boolean; applied?: string[]; errors?: string[]; accounts?: number; error?: string; detail?: string }
>("apply_controller_config");
export const getSettings = callable<[], StreamSettings>("get_settings");
export const setSettings = callable<[settings: StreamSettings], { ok: boolean }>(
"set_settings",
);
// GPUs + audio endpoints for the device pickers. Costs a subprocess that initialises Vulkan and
// PipeWire, so it is called ONCE when the settings tab mounts and never on the launch path.
export const listDevices = callable<[], DeviceLists>("list_devices");
// The same, bypassing the backend's cache — for the user who just plugged in a headset.
export const refreshDevices = callable<[], DeviceLists>("refresh_devices");
export const killStream = callable<[], { ok: boolean }>("kill_stream");
// Send a Wake-on-LAN magic packet to a saved host (headless flatpak --wake) so a sleeping host is
// up by the time the stream connects. The MAC is looked up from the flatpak client's own
// known-hosts store; `ok: false` (no-op) when none has been learned yet. Fire before launching.
export const wake = callable<[host: string, port: number], { ok: boolean; error?: string }>(
"wake",
);
// ---- Shared saved-hosts store (the SAME client-known-hosts.json the desktop client owns) ----
// The saved hosts, each annotated with a live (mDNS-independent) `online` probe when `probe` is
// true. Falls back to a direct JSON read (no reachability) on a client too old for --list-hosts.
export const listHosts = callable<[probe: boolean], HostsResult>("list_hosts");
// Save a host by address (survives mDNS-blind networks). `fp` empty = unpaired placeholder to
// pair next; a later pair replaces it with the fingerprinted entry.
export const addHost = callable<[target: string, name: string, fp: string], MutationResult>(
"add_host",
);
// Rename and/or re-point a saved host. `selector` = its fingerprint (survives IP change) or
// current addr[:port]; empty fields are left untouched.
export const editHost = callable<
[selector: string, name: string, addr: string, port: number],
MutationResult
>("edit_host");
// Remove a saved host by fingerprint or addr[:port] (idempotent).
export const forgetHost = callable<[selector: string], MutationResult>("forget_host");
// Reset this device's Punktfunk state (saved hosts + stream settings + pins); KEEPS the client
// identity so the box isn't seen as new everywhere (re-pairing re-adds hosts).
export const resetConfig = callable<[], { ok: boolean; error?: string }>("reset_config");
// Reachability of one host[:port] via the client's mDNS-independent QUIC probe (a "test address"
// check). `{ ok: true, online }` when determined, else `{ ok: false, error }`.
export const probeHost = callable<
[target: string],
{ ok: boolean; online?: boolean; error?: string }
>("probe_host");
export const checkUpdate = callable<[force: boolean], UpdateInfo>("check_update");
// Update the client by whichever route its install supports: `flatpak update --user` for the
// flatpak, `punktfunk-client --apply-update` (the packaged root helper) for a one-tap-capable
+194 -347
View File
@@ -1,18 +1,14 @@
// Shared state hooks + user actions for the QAM panel and the fullscreen page.
// Shared state hooks + user actions for the QAM panel.
import { toaster } from "@decky/api";
import { Navigation } from "@decky/ui";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import {
checkUpdate,
discover,
GameEntry,
getPins,
Host,
listHosts,
PinnedGame,
resetConfig,
DiscoveredHost,
hosts as listHosts,
Profile,
SavedHost,
setPins as setPinsBackend,
updateClient,
UpdateInfo,
} from "./backend";
@@ -37,19 +33,191 @@ declare global {
// PluginInstallType.UPDATE in decky-loader's browser.py (INSTALL=0/REINSTALL=1/UPDATE=2/…).
const INSTALL_TYPE_UPDATE = 2;
/**
* How far this device has got with a host. The three states are what the row says under the
* name, and which of them a host is in decides whether pressing it streams or opens the trust
* sheet.
*
* - `paired` the host approved this device (a PIN ceremony, or request access).
* - `trusted` its fingerprint is pinned but nobody has approved us yet. Streams work if
* the host's policy is `optional`; under `required` the connect parks.
* - `needs-access` no pinned fingerprint. Not streamable until the trust sheet runs.
*/
export type TrustState = "paired" | "trusted" | "needs-access";
/**
* One host as the panel shows it the union of the saved store and the live mDNS browse.
*
* A saved host is ONLINE when it either advertises or answers the reachability probe, so a box
* reached over Tailscale/VPN stops reading as offline. Discovered hosts that aren't saved are
* appended as extra rows.
*/
export interface HostView {
name: string;
addr: string;
port: number;
/**
* The fingerprint PINNED ON THE RECORD. "" means nothing is pinned, which is exactly what
* makes a host unstreamable the session binary refuses a pinless connect.
*
* Deliberately NOT filled in from a live advert. A host saved by address that happens to be
* advertising right now still has an empty pin on disk, and borrowing the advert's here would
* draw it as ready to stream while every launch refused for want of a fingerprint. What the
* advert offers is [`advertisedFp`], and moving it onto the record is a trust decision the
* user makes in the sheet.
*/
fp: string;
/** What the host is advertising right now, if anything — what request access would pin. */
advertisedFp: string;
/**
* The host is answering at an address its record does not carry it changed DHCP lease.
*
* This matters because a launch names the host by [`ref`], and the CLI dials whatever address
* the RECORD holds. So the row would show the live address and dial the dead one. The record
* has to be re-pointed before such a host can stream; `startStream` does it.
*/
moved: boolean;
paired: boolean;
online: boolean;
saved: boolean;
/** The advert's policy ("required"|"optional"); "" when the host isn't advertising. */
pairPolicy: string;
/** OS-identity chain (live advert preferred, else the stored one); "" unknown. */
os: string;
/**
* What a launch should NAME this host by: the record's stable id, which survives renames and
* DHCP moves, falling back to `addr:port` for a row that has no record yet (a discovered host
* the trust sheet is about to save, or a client too old to have minted ids).
*/
ref: string;
/** The host's default profile binding — applied silently by a plain connect, not a card. */
profile: Profile | null;
/** The cards to render nested under this host; already resolved against the catalog. */
pinnedProfiles: Profile[];
lastUsed: number | null;
}
export function trustState(v: HostView): TrustState {
if (v.paired) return "paired";
return v.fp ? "trusted" : "needs-access";
}
/**
* Must this host go through the trust sheet before it can stream?
*
* A pinned fingerprint is the ONLY rule. The session binary refuses a pinless connect, so a row
* without one can offer nothing but a button that fails; with one, the connect is verified and
* the host either admits it or parks it for an operator. The old rule also consulted the
* advertised policy for unsaved hosts, which made the answer depend on which of two lists a row
* came from the same box could read differently before and after being saved.
*/
export function needsPair(v: HostView): boolean {
return v.fp === "";
}
function advertMatchesSaved(a: DiscoveredHost, s: SavedHost): boolean {
return (
(!!s.fp_hex && !!a.fp && s.fp_hex.toLowerCase() === a.fp.toLowerCase()) ||
(s.addr === a.addr && s.port === a.port)
);
}
/**
* Join the saved store and the live browse into the rows the panel draws.
*
* Fingerprint first, address second a host that moved DHCP lease still matches its record,
* and a different box that inherited the old address does not inherit its pairing. The CLI's
* `discover` annotates `saved`/`paired` by exactly this rule too, so the two can't disagree.
*/
export function mergeHosts(saved: SavedHost[], discovered: DiscoveredHost[]): HostView[] {
const views: HostView[] = saved.map((s) => {
// Prefer a live advert's address: the host may have moved since it was last saved.
const advert = discovered.find((a) => advertMatchesSaved(a, s));
return {
name: s.name || s.addr,
addr: advert?.addr ?? s.addr,
port: advert?.port ?? s.port,
fp: s.fp_hex,
advertisedFp: advert?.fp ?? "",
moved: !!advert && (advert.addr !== s.addr || advert.port !== s.port),
paired: s.paired,
online: !!advert || s.online === true,
saved: true,
pairPolicy: advert?.pair ?? "",
os: advert?.os || s.os || "",
ref: s.id || `${advert?.addr ?? s.addr}:${advert?.port ?? s.port}`,
profile: s.profile,
pinnedProfiles: s.pinned_profiles ?? [],
lastUsed: s.last_used,
};
});
for (const a of discovered) {
if (saved.some((s) => advertMatchesSaved(a, s))) {
continue; // already rendered as its saved row, with a live pip
}
views.push({
name: a.name,
addr: a.addr,
port: a.port,
// No record, so nothing is pinned — whatever it advertises is an OFFER, not a pin.
fp: "",
advertisedFp: a.fp,
moved: false, // no record, so nothing to be stale
paired: a.paired,
online: true,
saved: false,
pairPolicy: a.pair,
os: a.os,
ref: `${a.addr}:${a.port}`,
profile: null,
pinnedProfiles: [],
lastUsed: null,
});
}
return views.sort(sortRows);
}
/**
* Online first, then most recently used, then by name. The host you streamed last night should
* be the first thing under your thumb; a host that is off right now should never be.
*/
function sortRows(a: HostView, b: HostView): number {
if (a.online !== b.online) return a.online ? -1 : 1;
if ((a.lastUsed ?? 0) !== (b.lastUsed ?? 0)) return (b.lastUsed ?? 0) - (a.lastUsed ?? 0);
return a.name.localeCompare(b.name);
}
// ----------------------------------------------------------------------------------------
// Discovery — mDNS scan state shared by the QAM panel and the full page.
// Hosts — ONE call site for both lists. They were separate hooks when the plugin had two
// views mounting them independently; the panel is the only view now, and merging them means
// the "scanning" state covers the whole row set rather than half of it flickering in first.
// ----------------------------------------------------------------------------------------
export function useHosts() {
const [hosts, setHosts] = useState<Host[]>([]);
const [views, setViews] = useState<HostView[]>([]);
const [scanning, setScanning] = useState(false);
// Why the list is empty, when it is empty for a reason other than an empty LAN. Rendering
// either of these as "No hosts yet" would blame the user's network for the plugin's problem:
// "client-outdated" — the installed client predates `punktfunk discover`
// "client-unavailable" — there is no client installed at all
const [problem, setProblem] = useState<string | null>(null);
const refresh = useCallback(async () => {
setScanning(true);
try {
setHosts(await discover());
// Both in flight at once: the browse is time-bounded and the probe is network-bound, so
// running them in sequence would cost the sum of two waits for no benefit.
const [d, s] = await Promise.all([discover(), listHosts()]);
// Both calls run the same binary, so they fail the same way; take whichever answered.
setProblem(
d.error === "client-unavailable" || s.error === "client-unavailable"
? "client-unavailable"
: d.error === "client-outdated" || s.error === "client-outdated"
? "client-outdated"
: null,
);
setViews(mergeHosts(s.hosts ?? [], d.hosts ?? []));
} catch (e) {
toaster.toast({ title: "Punktfunk", body: `Discovery failed: ${e}` });
toaster.toast({ title: "Punktfunk", body: `Couldn't list hosts: ${e}` });
} finally {
setScanning(false);
}
@@ -59,157 +227,7 @@ export function useHosts() {
void refresh();
}, [refresh]);
return { hosts, scanning, refresh };
}
// ----------------------------------------------------------------------------------------
// Saved hosts — the SHARED known-hosts store (client-known-hosts.json), the same file the
// desktop client reads/writes. Fetched WITH a reachability probe so a host reached over a
// routed network (Tailscale/VPN) reports online without ever appearing on mDNS.
// ----------------------------------------------------------------------------------------
export function useSavedHosts() {
const [saved, setSaved] = useState<SavedHost[]>([]);
const [loading, setLoading] = useState(false);
const refresh = useCallback(async () => {
setLoading(true);
try {
const r = await listHosts(true);
setSaved(r.hosts ?? []);
} catch {
/* backend unavailable — keep the current view */
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
return { saved, loading, refresh };
}
/**
* One host as the UI shows it the union of the saved store and the live mDNS scan. A saved
* host is ONLINE when it either advertises on mDNS OR answers the reachability probe (so
* mDNS-blind-but-reachable hosts stop reading as offline). Discovered hosts not in the store
* are appended as unsaved rows.
*/
export interface HostView {
name: string;
addr: string;
port: number;
fp: string; // "" for a saved-but-unpaired placeholder
paired: boolean; // PIN-paired specifically (a TOFU host has fp but paired=false)
online: boolean;
saved: boolean; // present in the known-hosts store
pairPolicy: string; // the advert's policy ("required"|"optional"), "" when not advertising
mgmt: number; // advertised mgmt-API port (0 = not advertised → default)
id: string; // advertised stable host id ("" when not advertising)
os: string; // OS-identity chain (live advert preferred, else the stored one); "" unknown
}
function advertMatchesSaved(a: Host, s: SavedHost): boolean {
return (
(!!s.fp_hex && !!a.fp && s.fp_hex.toLowerCase() === a.fp.toLowerCase()) ||
(s.addr === a.host && s.port === a.port)
);
}
export function mergeHosts(saved: SavedHost[], discovered: Host[]): HostView[] {
const views: HostView[] = saved.map((s) => {
// Prefer a live advert's address (a host may have moved DHCP leases since it was saved).
const advert = discovered.find((a) => advertMatchesSaved(a, s));
return {
name: s.name || s.addr,
addr: advert?.host ?? s.addr,
port: advert?.port ?? s.port,
fp: s.fp_hex || advert?.fp || "",
paired: s.paired,
online: !!advert || s.online === true,
saved: true,
pairPolicy: advert?.pair ?? "",
mgmt: advert?.mgmt ?? 0,
id: advert?.id ?? "",
os: advert?.os || s.os || "",
};
});
for (const a of discovered) {
if (saved.some((s) => advertMatchesSaved(a, s))) {
continue; // already rendered as its saved card (with a live pip)
}
views.push({
name: a.name,
addr: a.host,
port: a.port,
fp: a.fp,
paired: a.paired,
online: true,
saved: false,
pairPolicy: a.pair,
mgmt: a.mgmt,
id: a.id,
os: a.os,
});
}
return views;
}
/**
* True when this host must be paired before it can stream. A saved host is streamable once it
* has a pinned fingerprint (PIN-paired OR TOFU-trusted); a saved placeholder (no fp yet) must be
* paired. For an unsaved discovered host we keep the advertised-policy rule the UI always used.
*/
export function needsPair(v: HostView): boolean {
return v.saved ? v.fp === "" : v.pairPolicy === "required" && !v.paired;
}
/** Adapt a merged view back into the `Host` shape the pair/library/stream helpers consume. */
export function toHost(v: HostView): Host {
return {
name: v.name,
host: v.addr,
port: v.port,
pair: v.pairPolicy || (needsPair(v) ? "required" : "optional"),
fp: v.fp,
proto: "",
paired: v.paired,
id: v.id,
mgmt: v.mgmt,
os: v.os,
};
}
/** Is a pinned game's host currently online, considering BOTH the live scan and saved probe? */
export function pinIsOnline(pin: PinnedGame, views: HostView[]): boolean {
const fp = pin.host_fp.toLowerCase();
return views.some(
(v) =>
v.online &&
((!!fp && v.fp.toLowerCase() === fp) ||
(!!pin.host_id && v.id === pin.host_id) ||
(v.addr === pin.host && v.port === pin.port)),
);
}
/**
* Reset all Punktfunk state (saved hosts + stream settings + pins), keeping the client identity.
* Refreshes whatever views are passed so the UI clears immediately. Ends in a toast.
*/
export async function resetAll(refreshers: Array<() => void | Promise<void>>): Promise<void> {
try {
const r = await resetConfig();
for (const fn of refreshers) void fn();
toaster.toast({
title: "Punktfunk",
body: r.ok
? "Reset — saved hosts, settings, and pins cleared."
: `Reset failed${r.error ? ` (${r.error})` : ""}.`,
});
} catch {
toaster.toast({ title: "Punktfunk", body: "Reset failed." });
}
return { views, scanning, problem, refresh };
}
// ----------------------------------------------------------------------------------------
@@ -260,36 +278,6 @@ export function clientUpdateIsOneTap(info: UpdateInfo | null | undefined): boole
);
}
/**
* How the client got onto this box, in words a Deck user recognises. The raw kind comes from
* the client's own detector (`pf_update_check::detect`); anything unmapped falls through as
* itself rather than as "unknown", because the raw word is still more useful than a shrug.
*/
export function clientInstallLabel(kind: string): string {
switch (kind) {
case "flatpak":
return "Flatpak (per-user)";
case "apt":
return "System package (apt)";
case "dnf":
return "System package (dnf)";
case "rpm-ostree":
return "Layered package (rpm-ostree)";
case "pacman":
return "System package (pacman)";
case "sysext":
return "System extension (sysext)";
case "nix":
return "Nix profile";
case "steamos-source":
return "On-device build";
case "source":
return "Built from source";
default:
return kind;
}
}
/** True when the only pending update is one this Deck can't apply itself. */
export function clientUpdateIsManualOnly(info: UpdateInfo | null | undefined): boolean {
return !!info && info.client_update_available && !clientUpdateIsOneTap(info);
@@ -427,167 +415,26 @@ export async function applyUpdate(
}
// ----------------------------------------------------------------------------------------
// Stream launch — via the hidden Steam shortcut (see steam.ts for why).
// Stream launch — via the hidden Steam shortcut (see steam.ts for why it can't be direct).
// ----------------------------------------------------------------------------------------
/**
* Stream this host. `opts.profileId` streams one of its pinned cards; `opts.requestAccess`
* runs the supervised launch that waits for the host's operator to approve this Deck.
*
* The host is named by REFERENCE (`v.ref`), never by value no resolution, bitrate or codec
* ever rides the launch path, which is the same rule the deep-link grammar enforces.
*/
export async function startStream(
h: Host,
v: HostView,
opts: LaunchOpts = {},
label?: string,
): Promise<void> {
try {
await launchStream(h.host, h.port, opts);
await launchStream(v.ref, opts);
Navigation.CloseSideMenus();
toaster.toast({ title: "Punktfunk", body: `Starting ${label ?? "stream"}${h.name}` });
toaster.toast({ title: "Punktfunk", body: `Starting ${label ?? "stream"}${v.name}` });
} catch (e) {
toaster.toast({ title: "Punktfunk", body: `Launch failed: ${e}` });
}
}
/** Open the GTK client's gamepad library launcher for a host (`--browse` via PF_BROWSE). */
export async function startBrowse(h: Host): Promise<void> {
try {
await launchStream(h.host, h.port, { browse: true, mgmt: h.mgmt });
Navigation.CloseSideMenus();
toaster.toast({ title: "Punktfunk", body: `Opening library — ${h.name}` });
} catch (e) {
toaster.toast({ title: "Punktfunk", body: `Launch failed: ${e}` });
}
}
// ----------------------------------------------------------------------------------------
// Pinned games — the QAM's one-tap game rows, persisted by the backend next to the
// client's config (survives plugin reinstalls).
// ----------------------------------------------------------------------------------------
export interface PinsApi {
pins: PinnedGame[];
addPin: (h: Host, g: GameEntry) => void;
removePin: (hostFp: string, gameId: string) => void;
isPinned: (hostFp: string, gameId: string) => boolean;
/** Refresh a pin's stored address from a live advert (hosts change IPs). */
updatePinHost: (pin: PinnedGame, h: Host) => void;
refresh: () => Promise<void>;
}
export function usePins(): PinsApi {
const [pins, setPins] = useState<PinnedGame[]>([]);
// A live mirror of `pins`. The Games picker is mounted by Decky's `showModal` into a
// detached portal that captures this hook's callbacks ONCE and never re-renders with fresh
// props, so a mutator closing over the `pins` array reads a frozen base — pinning a second
// game in the same session would compute from the stale `[]` and clobber the first (silent
// data loss). Reading the ref keeps every mutation based on the current set, and lets the
// callbacks keep a stable identity (deps free of `pins`).
const pinsRef = useRef<PinnedGame[]>([]);
pinsRef.current = pins;
const refresh = useCallback(async () => {
try {
setPins((await getPins()).pins);
} catch {
/* backend unavailable — keep the current view */
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
// Optimistic local state; the backend validates/dedups and is re-read on failure.
const save = useCallback(
(next: PinnedGame[]) => {
pinsRef.current = next;
setPins(next);
setPinsBackend(next).catch(() => void refresh());
},
[refresh],
);
const addPin = useCallback(
(h: Host, g: GameEntry) => {
const pin: PinnedGame = {
game_id: g.id,
title: g.title,
store: g.store,
host_fp: h.fp,
host_id: h.id,
host_name: h.name,
host: h.host,
port: h.port,
mgmt: h.mgmt,
added_at: Math.floor(Date.now() / 1000),
paired: h.paired,
};
save([
...pinsRef.current.filter(
(p) => !(p.host_fp === pin.host_fp && p.game_id === pin.game_id),
),
pin,
]);
},
[save],
);
const removePin = useCallback(
(hostFp: string, gameId: string) => {
save(pinsRef.current.filter((p) => !(p.host_fp === hostFp && p.game_id === gameId)));
},
[save],
);
const isPinned = useCallback(
(hostFp: string, gameId: string) =>
pins.some((p) => p.host_fp === hostFp && p.game_id === gameId),
[pins],
);
const updatePinHost = useCallback(
(pin: PinnedGame, h: Host) => {
if (pin.host === h.host && pin.port === h.port && pin.mgmt === h.mgmt) {
return;
}
save(
pinsRef.current.map((p) =>
p.host_fp === pin.host_fp && p.game_id === pin.game_id
? { ...p, host: h.host, port: h.port, mgmt: h.mgmt, host_name: h.name }
: p,
),
);
},
[save],
);
return { pins, addPin, removePin, isPinned, updatePinHost, refresh };
}
/**
* The host a pin should launch against right now: match the live mDNS scan by cert
* fingerprint first (pairing is fp-keyed, survives IP changes), then by the host's stable
* id, else fall back to the stored address (host offline or scan flaky still launch).
*/
export function resolvePinHost(
pin: PinnedGame,
live: Host[],
): { host: Host; online: boolean } {
const fp = pin.host_fp.toLowerCase();
const match =
(fp && live.find((h) => h.fp && h.fp.toLowerCase() === fp)) ||
(pin.host_id && live.find((h) => h.id && h.id === pin.host_id)) ||
undefined;
if (match) {
return { host: match, online: true };
}
return {
host: {
name: pin.host_name || pin.host,
host: pin.host,
port: pin.port,
pair: pin.paired ? "optional" : "required",
fp: pin.host_fp,
proto: "",
paired: !!pin.paired,
id: pin.host_id,
mgmt: pin.mgmt,
os: "", // pins don't store the chain; the icon is a hosts-tab affordance
},
online: false,
};
}
-164
View File
@@ -1,164 +0,0 @@
// Add / edit host dialogs for the fullscreen page. These mutate the SHARED known-hosts store
// (client-known-hosts.json) through the flatpak client's headless modes, so a host saved or
// renamed here shows up in the desktop client too. Text entry uses @decky/ui's TextField, which
// brings up Steam's on-screen keyboard on focus (the digit-grid trick in pair.tsx is only needed
// for the numeric PIN).
import { DialogButton, Focusable, ModalRoot, Spinner, TextField } from "@decky/ui";
import { toaster } from "@decky/api";
import { ChangeEvent, FC, useState } from "react";
import { addHost, editHost, MutationResult } from "./backend";
import { HostView } from "./hooks";
import { actionButton } from "./ui";
/** Stable copy for a failed host-store mutation. */
export function mutationError(r: MutationResult): string {
switch (r.error) {
case "client-unavailable":
return "The Punktfunk client isn't installed (flatpak io.unom.Punktfunk).";
case "client-outdated":
return "The installed client is too old for host management — update it from the About tab.";
default:
return r.detail || "Couldn't save the host.";
}
}
// Split a typed address: a pasted `host:port` wins over the separate port field. IPv6 literals
// aren't supported by the host advert/known-hosts format, so a bare colon is treated as host:port.
function targetFrom(addr: string, port: string): string {
const a = addr.trim();
if (a.includes(":")) {
return a;
}
const p = port.trim() || "9777";
return `${a}:${p}`;
}
const field: React.CSSProperties = { marginBottom: "0.8em" };
const HostForm: FC<{
title: string;
submitLabel: string;
initial: { addr: string; port: string; name: string };
addrDisabled?: boolean;
onSubmit: (addr: string, port: string, name: string) => Promise<MutationResult>;
onDone: () => void;
closeModal?: () => void;
}> = ({ title, submitLabel, initial, addrDisabled, onSubmit, onDone, closeModal }) => {
const [addr, setAddr] = useState(initial.addr);
const [port, setPort] = useState(initial.port);
const [name, setName] = useState(initial.name);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async () => {
if (!addr.trim()) {
setError("Enter an address.");
return;
}
setBusy(true);
setError(null);
try {
const r = await onSubmit(addr.trim(), port.trim(), name.trim());
if (r.ok) {
onDone();
closeModal?.();
} else {
setError(mutationError(r));
}
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
};
return (
<ModalRoot closeModal={closeModal}>
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.6em" }}>{title}</div>
<div style={field}>
<TextField
label="Address"
description="IP or hostname (a Tailscale/VPN name works too). Add :port to override."
value={addr}
disabled={addrDisabled || busy}
onChange={(e: ChangeEvent<HTMLInputElement>) => setAddr(e.target.value)}
/>
</div>
<div style={field}>
<TextField
label="Port"
value={port}
mustBeNumeric
disabled={busy}
onChange={(e: ChangeEvent<HTMLInputElement>) => setPort(e.target.value)}
/>
</div>
<div style={field}>
<TextField
label="Name (optional)"
value={name}
disabled={busy}
onChange={(e: ChangeEvent<HTMLInputElement>) => setName(e.target.value)}
/>
</div>
{error && (
<div style={{ color: "#ff6b6b", marginBottom: "0.6em" }}>{error}</div>
)}
<Focusable style={{ display: "flex", gap: "0.5em", justifyContent: "flex-end" }}>
<DialogButton style={actionButton} disabled={busy} onClick={() => closeModal?.()}>
Cancel
</DialogButton>
<DialogButton style={actionButton} disabled={busy} onClick={submit}>
{busy ? <Spinner style={{ height: "1em" }} /> : submitLabel}
</DialogButton>
</Focusable>
</ModalRoot>
);
};
/** "+" — save a new host by address (unpaired placeholder; the user pairs it next). */
export const AddHostModal: FC<{ onDone: () => void; closeModal?: () => void }> = ({
onDone,
closeModal,
}) => (
<HostForm
title="Add host"
submitLabel="Add"
initial={{ addr: "", port: "9777", name: "" }}
onSubmit={async (addr, port, name) => {
const r = await addHost(targetFrom(addr, port), name, "");
if (r.ok) {
toaster.toast({ title: "Punktfunk", body: `Added ${name || addr}` });
}
return r;
}}
onDone={onDone}
closeModal={closeModal}
/>
);
/** Rename / re-point a saved host. Identified by fingerprint when it has one (survives IP
* changes), else by its current address. */
export const EditHostModal: FC<{
host: HostView;
onDone: () => void;
closeModal?: () => void;
}> = ({ host, onDone, closeModal }) => {
const selector = host.fp || `${host.addr}:${host.port}`;
return (
<HostForm
title={`Edit ${host.name}`}
submitLabel="Save"
initial={{ addr: host.addr, port: String(host.port), name: host.name }}
onSubmit={async (addr, port, name) => {
const r = await editHost(selector, name, addr, parseInt(port, 10) || 0);
if (r.ok) {
toaster.toast({ title: "Punktfunk", body: `Updated ${name || addr}` });
}
return r;
}}
onDone={onDone}
closeModal={closeModal}
/>
);
};
+148 -117
View File
@@ -1,46 +1,47 @@
// Plugin entry: the Quick Access Menu panel + route registration. The fullscreen page lives
// in page.tsx; shared hooks/actions in hooks.ts; the Steam-shortcut launch in steam.ts.
// Plugin entry: the Quick Access Menu panel. That is the whole plugin now — the fullscreen
// route, the settings screen, the host editor and the games picker are gone, because the
// client's own console home does all four one shortcut away (and is gamepad-navigable, which
// a QAM panel re-implementing them never quite was).
//
// What is left is what only a Decky plugin can do: start a stream through Steam so gamescope
// focuses it (see steam.ts), and stand in front of the trust decision that gates it.
import {
ButtonItem,
Field,
Navigation,
PanelSection,
PanelSectionRow,
Spinner,
showModal,
staticClasses,
} from "@decky/ui";
import { definePlugin, routerHook, toaster } from "@decky/api";
import { definePlugin, toaster } from "@decky/api";
import { FC } from "react";
import {
FaDownload,
FaLock,
FaLockOpen,
FaPlay,
FaPlus,
FaStopCircle,
FaSyncAlt,
FaTv,
} from "react-icons/fa";
import { killStream } from "./backend";
import { PluginErrorBoundary } from "./boundary";
import {
applyUpdate,
checkForUpdatesNow,
clientUpdateIsManualOnly,
hasUpdate,
mergeHosts,
HostView,
needsPair,
pinIsOnline,
startStream,
toHost,
trustState,
useHosts,
usePins,
useSavedHosts,
useUpdate,
} from "./hooks";
import { streamPin } from "./library";
import { PunktfunkRoute, ROUTE } from "./page";
import { PairModal } from "./pair";
import { ensureGamepadUiShortcut, recreateShortcuts } from "./steam";
import { OsMark } from "./os-icon";
import { ensureGamepadUiShortcut, launchGamepadUi, recreateShortcuts, stopStream } from "./steam";
import { TrustSheet } from "./trust";
// Recovery action for "the Punktfunk library entry vanished" — recreates the visible shortcut.
// Deleting the shortcut (optionally + reinstalling the plugin) leaves a stale appId in Steam's
@@ -54,22 +55,78 @@ async function recreatePunktfunkShortcut(): Promise<void> {
});
}
// ----------------------------------------------------------------------------------------
// QAM panel — quick status + entry into the full page + one-tap stream for known hosts
// and pinned games.
// ----------------------------------------------------------------------------------------
const QamPanel: FC = () => {
const { hosts: discovered, scanning, refresh: refreshDiscovered } = useHosts();
const { saved, loading: loadingSaved, refresh: refreshSaved } = useSavedHosts();
const { info: update, checking, check } = useUpdate();
const pins = usePins();
/** Force-stop a wedged stream: end Steam's "game", then make sure the client itself is gone. */
async function forceStop(): Promise<void> {
stopStream();
try {
await killStream();
} catch {
/* best-effort — the TerminateApp above is usually enough */
}
toaster.toast({ title: "Punktfunk", body: "Stopped the stream" });
}
const hosts = mergeHosts(saved, discovered);
const busy = scanning || loadingSaved;
const refresh = () => {
void refreshDiscovered();
void refreshSaved();
};
/** The line under a host's name: where it is, whether it's up, and how far trust has got. */
function hostDescription(v: HostView): string {
const trust = {
paired: "paired",
trusted: "trusted",
"needs-access": "needs access",
}[trustState(v)];
return `${v.addr}:${v.port} · ${v.online ? "online" : "offline"} · ${trust}`;
}
const HostRow: FC<{ host: HostView; refresh: () => void }> = ({ host, refresh }) => {
const gated = needsPair(host);
const stream = (opts: { requestAccess?: boolean } = {}) => void startStream(host, opts);
return (
<>
<PanelSectionRow>
<ButtonItem
layout="below"
onClick={() =>
gated
? showModal(
<TrustSheet host={host} onStream={stream} onChanged={refresh} />,
)
: stream()
}
label={
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
{gated ? <FaLock /> : <OsMark os={host.os} />}
{host.name}
</span>
}
description={hostDescription(host)}
>
{gated ? "Connect…" : "Stream"}
</ButtonItem>
</PanelSectionRow>
{/* Pinned cards, nested under their host rather than in a section of their own: a card
IS a (host, profile) pair, and a row that floats free of its host is the "a pinned
tile reads as a duplicate host" problem the desktop shells still have. The host's
own BOUND profile is deliberately not a card it applies silently on the plain row
above, and showing it twice would suggest they do different things. */}
{!gated &&
host.pinnedProfiles.map((p) => (
<PanelSectionRow key={`${host.ref}:${p.id}`}>
<ButtonItem
layout="below"
onClick={() => void startStream(host, { profileId: p.id }, `${p.name}`)}
label={`${p.name}`}
>
<FaPlay style={{ marginRight: "0.5em" }} />
Stream
</ButtonItem>
</PanelSectionRow>
))}
</>
);
};
const QamPanel: FC = () => {
const { views, scanning, problem, refresh } = useHosts();
const { info: update, checking, check } = useUpdate();
return (
<>
@@ -110,15 +167,62 @@ const QamPanel: FC = () => {
</PanelSection>
))}
<PanelSection title="Hosts">
<PanelSectionRow>
<ButtonItem layout="below" onClick={() => void refresh()} disabled={scanning}>
{scanning ? (
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
) : (
<FaSyncAlt style={{ marginRight: "0.5em" }} />
)}
{scanning ? "Scanning…" : "Refresh"}
</ButtonItem>
</PanelSectionRow>
{/* A client that is missing or too old explains itself rather than rendering an empty
list "no hosts on your LAN" would blame the network for the plugin's problem, and
for the outdated case the button that fixes it is in this same panel. */}
{problem && (
<PanelSectionRow>
<Field
focusable={false}
label={
problem === "client-unavailable"
? "Punktfunk isnt installed"
: "Update the Punktfunk client"
}
description={
problem === "client-unavailable"
? "This panel launches the Punktfunk app, which isnt on this Deck yet. Install it in Desktop Mode."
: "This client is too old to find hosts on your network. Saved hosts still work."
}
/>
</PanelSectionRow>
)}
{views.length === 0 && scanning && (
<PanelSectionRow>
<Field focusable={false} description="Scanning your network…" />
</PanelSectionRow>
)}
{views.length === 0 && !scanning && !problem && (
<PanelSectionRow>
<Field
focusable={false}
label="No hosts yet"
description="Open Punktfunk to find and pair one."
/>
</PanelSectionRow>
)}
{views.map((v) => (
<HostRow key={v.ref} host={v} refresh={refresh} />
))}
</PanelSection>
<PanelSection title="Punktfunk">
<PanelSectionRow>
<ButtonItem
layout="below"
description="Host details, stream settings, and help"
onClick={() => {
Navigation.Navigate(ROUTE);
Navigation.CloseSideMenus();
}}
description="Settings, adding a host by address, and browsing a host's games all live here."
onClick={() => void launchGamepadUi()}
>
<FaTv style={{ marginRight: "0.5em" }} />
Open Punktfunk
@@ -126,85 +230,6 @@ const QamPanel: FC = () => {
</PanelSectionRow>
</PanelSection>
{/* Pinned games the "jump straight into Playnite" rows. Pin games from a host's
picker (fullscreen page host row games button). */}
{pins.pins.length > 0 && (
<PanelSection title="Pinned Games">
{pins.pins.map((pin) => {
const online = pinIsOnline(pin, hosts);
return (
<PanelSectionRow key={`${pin.host_fp}:${pin.game_id}`}>
<ButtonItem
layout="below"
onClick={() => streamPin(pin, hosts.map(toHost), pins)}
label={pin.title}
description={`${pin.host_name}${online ? "" : " · offline?"}${
pin.paired ? "" : " · pairing required"
}`}
>
<FaPlay style={{ marginRight: "0.5em" }} />
Stream
</ButtonItem>
</PanelSectionRow>
);
})}
</PanelSection>
)}
<PanelSection title="Hosts">
<PanelSectionRow>
<ButtonItem layout="below" onClick={refresh} disabled={busy}>
{busy ? (
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
) : (
<FaSyncAlt style={{ marginRight: "0.5em" }} />
)}
{busy ? "Scanning…" : "Refresh"}
</ButtonItem>
</PanelSectionRow>
{hosts.length === 0 && busy && (
<PanelSectionRow>
<Field focusable={false} description="Scanning your network…" />
</PanelSectionRow>
)}
{hosts.length === 0 && !busy && (
<PanelSectionRow>
<Field
focusable={false}
label="No hosts found"
description="Open Punktfunk to add a host by address, or start a host on this network and refresh."
/>
</PanelSectionRow>
)}
{hosts.map((v) => {
const pair = needsPair(v);
const h = toHost(v);
return (
<PanelSectionRow key={v.fp || `${v.addr}:${v.port}`}>
<ButtonItem
layout="below"
onClick={() =>
pair
? showModal(<PairModal host={h} onPaired={() => startStream(h)} />)
: startStream(h)
}
label={
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
{pair ? <FaLock /> : <FaLockOpen />}
{v.name}
</span>
}
description={`${v.addr}:${v.port} · ${v.online ? "online" : "offline"}${
pair ? " · pairing required" : v.paired ? " · paired" : ""
}`}
>
{pair ? "Pair & Stream" : "Stream"}
</ButtonItem>
</PanelSectionRow>
);
})}
</PanelSection>
<PanelSection title="About">
<PanelSectionRow>
<Field
@@ -236,13 +261,22 @@ const QamPanel: FC = () => {
Recreate library shortcut
</ButtonItem>
</PanelSectionRow>
<PanelSectionRow>
<ButtonItem
layout="below"
description="Ends a stream that stopped responding."
onClick={() => void forceStop()}
>
<FaStopCircle style={{ marginRight: "0.5em" }} />
Force-stop
</ButtonItem>
</PanelSectionRow>
</PanelSection>
</>
);
};
export default definePlugin(() => {
routerHook.addRoute(ROUTE, PunktfunkRoute, { exact: true });
// Ensure the visible, stateless "Punktfunk" library entry (opens the gamepad UI / console
// home) exists and is repointed to the current plugin dir — also installs the native-touch
// controller config. Fire-and-forget: cosmetic library upkeep must never block plugin load.
@@ -260,8 +294,5 @@ export default definePlugin(() => {
</PluginErrorBoundary>
),
icon: <FaTv />,
onDismount() {
routerHook.removeRoute(ROUTE);
},
};
});
-230
View File
@@ -1,230 +0,0 @@
// The per-host game picker + pinned-game launch helper. The picker fetches a paired
// host's library through the backend (headless flatpak --library — a cold client start
// can take seconds, hence the explicit spinner copy) and pins titles as one-tap rows in
// the QAM's Games section; its header also launches the GTK client's on-screen gamepad
// library (`--browse`).
import { DialogButton, Field, ModalRoot, Spinner, showModal } from "@decky/ui";
import { FC, useEffect, useState } from "react";
import { FaThLarge, FaTv } from "react-icons/fa";
import { GameEntry, Host, library, LibraryResult, PinnedGame } from "./backend";
import { PinsApi, resolvePinHost, startBrowse, startStream } from "./hooks";
import { isSafeLaunchId } from "./steam";
import { PairModal } from "./pair";
import { RowActions, actionButton } from "./ui";
/** Human store tag (mirrors the GTK client's `store_label`). */
export function storeLabel(store: string): string {
switch (store) {
case "steam":
return "Steam";
case "custom":
return "Custom";
case "heroic":
return "Heroic";
case "lutris":
return "Lutris";
case "epic":
return "Epic";
case "gog":
return "GOG";
case "xbox":
return "Xbox";
default:
return "Game";
}
}
/**
* Stream a pinned game: resolve the host from the live scan (fp id stored address),
* opportunistically refresh a drifted stored address, and route through pairing first if
* this device is no longer paired with the host.
*/
export function streamPin(pin: PinnedGame, live: Host[], pins: PinsApi): void {
const { host, online } = resolvePinHost(pin, live);
if (online) {
pins.updatePinHost(pin, host); // no-op unless the address actually drifted
}
if (!pin.paired) {
showModal(
<PairModal
host={host}
onPaired={() => {
void pins.refresh(); // pick up the now-paired annotation
void startStream(host, { launchId: pin.game_id }, pin.title);
}}
/>,
);
return;
}
void startStream(host, { launchId: pin.game_id }, pin.title);
}
// Copy per backend error code (LibraryResult.error); `detail` covers the generic case.
function errorCopy(res: LibraryResult): string {
switch (res.error) {
case "not-paired":
return "This Deck isn't paired with the host — pair first, then browse its library.";
case "pin-mismatch":
return "The host's identity changed — re-pair to re-establish trust.";
case "unreachable":
return "Couldn't reach the host's management API. Is the host online and up to date?";
case "timeout":
return "Timed out talking to the host — try again.";
case "flatpak-not-found":
return "The Punktfunk client isn't installed (flatpak io.unom.Punktfunk).";
case "client-outdated":
return "The installed client is too old for library browsing — update it from the About tab.";
default:
return res.detail || "Couldn't fetch the library.";
}
}
// ----------------------------------------------------------------------------------------
// The picker modal: "open on screen" + a pin-toggle list of the host's games.
// ----------------------------------------------------------------------------------------
export const GamePickerModal: FC<{
host: Host;
pins: PinsApi;
clientUpdatePending?: boolean;
closeModal?: () => void;
}> = ({ host, pins, clientUpdatePending, closeModal }) => {
const [result, setResult] = useState<LibraryResult | null>(null);
const [attempt, setAttempt] = useState(0); // bump to refetch (retry / after pairing)
// The modal is a detached `showModal` portal that never re-renders from the page's pin
// state, so `pins.isPinned` would read a frozen snapshot and the Pin/Unpin label would
// never flip within a session. Track this host's pinned ids locally, seeded once from the
// snapshot at open; persistence still goes through the (stale-closure-safe) pins API.
const [pinnedIds, setPinnedIds] = useState<Set<string>>(
() => new Set(pins.pins.filter((p) => p.host_fp === host.fp).map((p) => p.game_id)),
);
const togglePin = (g: GameEntry) => {
const wasPinned = pinnedIds.has(g.id);
setPinnedIds((prev) => {
const next = new Set(prev);
if (wasPinned) next.delete(g.id);
else next.add(g.id);
return next;
});
if (wasPinned) pins.removePin(host.fp, g.id);
else pins.addPin(host, g);
};
useEffect(() => {
let stale = false;
setResult(null);
library(host.host, host.mgmt, host.fp)
.then((res) => {
if (!stale) setResult(res);
})
.catch((e) => {
if (!stale) setResult({ ok: false, error: "client-error", detail: String(e) });
});
return () => {
stale = true;
};
}, [host.host, host.mgmt, host.fp, attempt]);
const games = (result?.ok && result.games) || [];
const sorted = [...games].sort((a, b) => a.title.localeCompare(b.title));
return (
<ModalRoot closeModal={closeModal}>
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.4em" }}>
{host.name} Games
</div>
<Field
label="Open library on screen"
description="Browse this host's games with the controller, full screen"
childrenContainerWidth="max"
>
<RowActions>
<DialogButton
style={actionButton}
onClick={() => {
closeModal?.();
void startBrowse(host);
}}
>
<FaTv style={{ marginRight: "0.4em" }} />
Open
</DialogButton>
</RowActions>
</Field>
{clientUpdatePending && (
<Field
focusable={false}
description="A client update is available — direct game launch and on-screen browsing need the latest client."
/>
)}
{result === null && (
<Field
focusable={false}
label={
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.6em" }}>
<Spinner style={{ height: "1em" }} />
Fetching the library
</span>
}
description="This starts the client headlessly — a cold start can take a few seconds."
/>
)}
{result !== null && !result.ok && (
<Field label="Couldn't fetch the library" description={errorCopy(result)} childrenContainerWidth="max">
<RowActions>
{result.error === "not-paired" && (
<DialogButton
style={actionButton}
onClick={() =>
showModal(<PairModal host={host} onPaired={() => setAttempt((n) => n + 1)} />)
}
>
Pair
</DialogButton>
)}
<DialogButton style={actionButton} onClick={() => setAttempt((n) => n + 1)}>
Retry
</DialogButton>
</RowActions>
</Field>
)}
{result?.ok && sorted.length === 0 && (
<Field
focusable={false}
label="No games found"
description="Install Steam titles or add custom entries in the host's web console."
/>
)}
{sorted.length > 0 && (
<div style={{ maxHeight: "55vh", overflowY: "auto" }}>
{sorted.map((g: GameEntry) => {
const pinned = pinnedIds.has(g.id);
const safe = isSafeLaunchId(g.id);
return (
<Field
key={g.id}
label={g.title}
description={
storeLabel(g.store) + (safe ? "" : " · unsupported id — can't be pinned")
}
childrenContainerWidth="max"
>
<RowActions>
<DialogButton style={actionButton} disabled={!safe} onClick={() => togglePin(g)}>
<FaThLarge style={{ marginRight: "0.4em" }} />
{pinned ? "Unpin" : "Pin"}
</DialogButton>
</RowActions>
</Field>
);
})}
</div>
)}
</ModalRoot>
);
};
-596
View File
@@ -1,596 +0,0 @@
// The fullscreen page (registered as the /punktfunk route) — Hosts / Settings / About tabs.
import {
ConfirmModal,
DialogButton,
Field,
Focusable,
ModalRoot,
Navigation,
Spinner,
Tabs,
showModal,
staticClasses,
} from "@decky/ui";
import { RowActions, actionButton, iconButton } from "./ui";
import { toaster } from "@decky/api";
import { CSSProperties, FC, useState } from "react";
import {
FaArrowLeft,
FaDownload,
FaExternalLinkAlt,
FaInfoCircle,
FaLock,
FaLockOpen,
FaPen,
FaPlay,
FaPlus,
FaSyncAlt,
FaThLarge,
FaTrashAlt,
} from "react-icons/fa";
import { UpdateInfo, forgetHost, killStream } from "./backend";
import { PluginErrorBoundary } from "./boundary";
import { OsMark } from "./os-icon";
import {
DOCS_URL,
HostView,
PinsApi,
applyUpdate,
checkForUpdatesNow,
clientInstallLabel,
clientUpdateIsManualOnly,
hasUpdate,
mergeHosts,
needsPair,
pinIsOnline,
resetAll,
startStream,
toHost,
useHosts,
usePins,
useSavedHosts,
useUpdate,
} from "./hooks";
import { AddHostModal, EditHostModal, mutationError } from "./hostmgmt";
import { GamePickerModal, storeLabel, streamPin } from "./library";
import { PairModal } from "./pair";
import { SettingsSection } from "./settings";
import { stopStream } from "./steam";
export const ROUTE = "/punktfunk";
// Bottom inset so the last control clears Gaming Mode's footer hint bar. Routed pages render
// *under* that bar otherwise — that's why the last Stream-settings row was getting hidden. The
// value is generous on purpose (and harmless where the tab area already insets); tune to taste.
const SAFE_BOTTOM = "80px";
// Each tab is its own scroll area so long content is always reachable above the footer.
const tabScroll: CSSProperties = {
height: "100%",
overflowY: "auto",
padding: "0.5em 2.5em",
paddingBottom: SAFE_BOTTOM,
boxSizing: "border-box",
};
// The one-line status under a host name: address, live presence, and trust state.
function hostSubtitle(v: HostView): string {
const parts = [`${v.addr}:${v.port}`, v.online ? "online" : "offline"];
if (needsPair(v)) {
parts.push("pairing required");
} else if (v.paired) {
parts.push("paired");
} else if (v.saved) {
parts.push("trusted");
}
return parts.join(" · ");
}
/** Confirm + forget a saved host, then refresh the list. */
function confirmForget(v: HostView, refresh: () => void): void {
const selector = v.fp || `${v.addr}:${v.port}`;
showModal(
<ConfirmModal
strTitle={`Forget ${v.name}?`}
strDescription="You'll need to pair or trust it again to reconnect."
strOKButtonText="Forget"
bDestructiveWarning
onOK={async () => {
const r = await forgetHost(selector);
toaster.toast({
title: "Punktfunk",
body: r.ok ? `Forgot ${v.name}` : mutationError(r),
});
refresh();
}}
/>,
);
}
// ----------------------------------------------------------------------------------------
// Host details — everything we know, plus (for a saved host) rename / edit / forget.
// ----------------------------------------------------------------------------------------
const HostDetailsModal: FC<{
host: HostView;
onChanged: () => void;
closeModal?: () => void;
}> = ({ host, onChanged, closeModal }) => {
const fp = host.fp ? (host.fp.match(/.{1,4}/g) ?? [host.fp]).join(" ") : "not known yet";
return (
<ModalRoot closeModal={closeModal}>
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.4em" }}>
{host.name}
</div>
<Field focusable={false} label="Address">
{host.addr}:{host.port}
</Field>
<Field focusable={false} label="Presence">
{host.online ? "Online" : "Offline"}
</Field>
<Field focusable={false} label="This Deck">
{host.paired ? "Paired" : host.fp ? "Trusted" : "Not paired yet"}
</Field>
<Field
focusable={false}
label="Certificate fingerprint (SHA-256)"
description={
<span
style={{ fontFamily: "monospace", fontSize: "0.85em", wordBreak: "break-word" }}
>
{fp}
</span>
}
/>
{host.saved && (
<Field label="Manage" childrenContainerWidth="max">
<RowActions>
<DialogButton
style={actionButton}
onClick={() => {
closeModal?.();
showModal(<EditHostModal host={host} onDone={onChanged} />);
}}
>
<FaPen style={{ marginRight: "0.4em" }} />
Edit
</DialogButton>
<DialogButton
style={actionButton}
onClick={() => {
closeModal?.();
confirmForget(host, onChanged);
}}
>
<FaTrashAlt style={{ marginRight: "0.4em" }} />
Forget
</DialogButton>
</RowActions>
</Field>
)}
</ModalRoot>
);
};
// ----------------------------------------------------------------------------------------
// One host row: status icon + address, details / pair / stream actions.
// ----------------------------------------------------------------------------------------
const HostRow: FC<{
host: HostView;
onChanged: () => void;
onGames: () => void;
}> = ({ host, onChanged, onGames }) => {
const pair = needsPair(host);
const h = toHost(host);
return (
<Field
label={
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
<OsMark os={host.os} />
{pair ? <FaLock /> : <FaLockOpen />}
{host.name}
</span>
}
description={hostSubtitle(host)}
childrenContainerWidth="max"
>
<RowActions>
<DialogButton
style={iconButton}
onClick={() => showModal(<HostDetailsModal host={host} onChanged={onChanged} />)}
>
<FaInfoCircle />
</DialogButton>
{/* Labeled, not icon-only: this is the entry to the game picker AND the on-screen
library browser, and controller nav has no hover tooltip to explain a bare icon. */}
<DialogButton style={actionButton} onClick={onGames}>
<FaThLarge style={{ marginRight: "0.4em" }} />
Games
</DialogButton>
{pair && (
<DialogButton
style={actionButton}
onClick={() => showModal(<PairModal host={h} onPaired={onChanged} />)}
>
Pair
</DialogButton>
)}
<DialogButton
style={actionButton}
onClick={() =>
pair
? showModal(<PairModal host={h} onPaired={() => startStream(h)} />)
: startStream(h)
}
>
<FaPlay style={{ marginRight: "0.4em" }} />
Stream
</DialogButton>
</RowActions>
</Field>
);
};
const HostsTab: FC<{
hosts: HostView[];
scanning: boolean;
refresh: () => void;
pins: PinsApi;
clientUpdatePending: boolean;
}> = ({ hosts, scanning, refresh, pins, clientUpdatePending }) => (
<div style={tabScroll}>
<Field
label="Hosts"
description={
scanning
? "Scanning the LAN…"
: `${hosts.length} host${hosts.length === 1 ? "" : "s"} — saved and on your network`
}
childrenContainerWidth="max"
bottomSeparator={hosts.length ? "standard" : "none"}
>
<RowActions>
<DialogButton
style={actionButton}
onClick={() => showModal(<AddHostModal onDone={refresh} />)}
>
<FaPlus style={{ marginRight: "0.5em" }} />
Add
</DialogButton>
<DialogButton style={actionButton} disabled={scanning} onClick={refresh}>
{scanning ? (
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
) : (
<FaSyncAlt style={{ marginRight: "0.5em" }} />
)}
{scanning ? "Scanning…" : "Refresh"}
</DialogButton>
</RowActions>
</Field>
{hosts.length === 0 && !scanning && (
<Field
focusable={false}
label="No hosts yet"
description="Add one by address with +, or start a Punktfunk host on this network and refresh. The setup guide (About tab) covers installing a host."
/>
)}
{hosts.map((h) => (
<HostRow
key={h.fp || `${h.addr}:${h.port}`}
host={h}
onChanged={refresh}
onGames={() =>
showModal(
<GamePickerModal
host={toHost(h)}
pins={pins}
clientUpdatePending={clientUpdatePending}
/>,
)
}
/>
))}
{/* Pinned games — also the cleanup surface for pins whose host is gone from the scan. */}
{pins.pins.length > 0 && (
<>
<Field
focusable={false}
label="Pinned games"
description="One-tap streams — they also live in the quick-access menu"
bottomSeparator="standard"
/>
{pins.pins.map((pin) => {
const online = pinIsOnline(pin, hosts);
return (
<Field
key={`${pin.host_fp}:${pin.game_id}`}
label={pin.title}
description={`${storeLabel(pin.store)} · ${pin.host_name}${
online ? "" : " · offline?"
}${pin.paired ? "" : " · pairing required"}`}
childrenContainerWidth="max"
>
<RowActions>
<DialogButton
style={actionButton}
onClick={() => streamPin(pin, hosts.map(toHost), pins)}
>
<FaPlay style={{ marginRight: "0.4em" }} />
Play
</DialogButton>
<DialogButton
style={actionButton}
onClick={() => pins.removePin(pin.host_fp, pin.game_id)}
>
Remove
</DialogButton>
</RowActions>
</Field>
);
})}
</>
)}
</div>
);
// NOT `tabScroll`: the settings screen is a SidebarNavigation, which lays out its own rail +
// content pane and scrolls the pane itself. Wrapping it in an outer scroll area would give it an
// indefinite height to fill, collapsing the rail — so this pane only hands it the full height and
// keeps its hands off the overflow. The footer inset lives inside the pages instead.
const settingsPane: CSSProperties = { height: "100%", overflow: "hidden" };
const SettingsTab: FC = () => (
<div style={settingsPane}>
<SettingsSection />
</div>
);
// ----------------------------------------------------------------------------------------
// About — plugin version + explicit update check, docs link, stream-exit help, force-stop,
// and the destructive "reset everything" action.
// ----------------------------------------------------------------------------------------
async function forceStopStream(): Promise<void> {
stopStream(); // ask Steam to end the "game" first (clean path)
const res = await killStream(); // then the flatpak-level hammer for a wedged client
toaster.toast({
title: "Punktfunk",
body: res.ok ? "Stream client stopped." : "Couldnt stop the stream client.",
});
}
function confirmReset(refreshers: Array<() => void | Promise<void>>): void {
showModal(
<ConfirmModal
strTitle="Reset Punktfunk?"
strDescription="Clears every saved host, your stream settings, and all pinned games on this Deck. Your client identity is kept, so you'll re-pair hosts to reconnect. This can't be undone."
strOKButtonText="Reset"
bDestructiveWarning
onOK={() => void resetAll(refreshers)}
/>,
);
}
const AboutTab: FC<{
update: UpdateInfo | null;
checking: boolean;
check: (force: boolean) => Promise<UpdateInfo | null>;
onReset: () => void;
}> = ({ update, checking, check, onReset }) => (
<div style={tabScroll}>
<Field
label="Version"
description={
update
? `v${update.current}${
update.channel ? ` · ${update.channel} channel` : " · development build"
}`
: "…"
}
childrenContainerWidth="max"
>
<RowActions>
<DialogButton
style={actionButton}
disabled={checking}
onClick={() => void checkForUpdatesNow(check)}
>
{checking ? <Spinner style={{ height: "1em" }} /> : "Check for updates"}
</DialogButton>
</RowActions>
</Field>
{/* What the client IS, so "why is there no Update button?" has a visible answer. The
install kind decides everything below it. */}
{!!update?.client_install && (
<Field
label="Client"
description={`${clientInstallLabel(update.client_install)}${
update.client_current ? ` · ${update.client_current}` : ""
}`}
/>
)}
{hasUpdate(update) && (
<Field
label={
update!.update_available
? `Plugin update — v${update!.latest}${
update!.client_update_available ? " + client" : ""
}`
: `Client update — ${update!.client_latest || "available"}`
}
description={
// Only promise a one-tap install when there is one. On a notify-only install the
// row becomes the command itself, which is the whole answer for that box.
clientUpdateIsManualOnly(update) && !update!.update_available
? update!.client_opt_in || update!.client_command
: "Installing can take a couple of minutes; Decky reloads the plugin when done"
}
childrenContainerWidth="max"
>
{clientUpdateIsManualOnly(update) && !update!.update_available ? null : (
<RowActions>
<DialogButton style={actionButton} onClick={() => applyUpdate(update!, check)}>
<FaDownload style={{ marginRight: "0.4em" }} />
Update
</DialogButton>
</RowActions>
)}
</Field>
)}
{!!update?.client_error && (
<Field
label="Client update check"
description={
update.client_error === "client-outdated"
? "This client predates update checks — update it once by hand and the check starts working."
: "Couldnt check the client for updates."
}
/>
)}
<Field
label="Setup guide"
description="Hosts, pairing, controllers, and troubleshooting — docs.punktfunk.unom.io"
childrenContainerWidth="max"
>
<RowActions>
<DialogButton
style={actionButton}
onClick={() => Navigation.NavigateToExternalWeb(DOCS_URL)}
>
<FaExternalLinkAlt style={{ marginRight: "0.4em" }} />
Open
</DialogButton>
</RowActions>
</Field>
<Field
focusable={false}
label="Leaving a stream"
description="Hold L1 + R1 + Start + Select inside the stream, or close the “game” from the Steam overlay — either returns you to Gaming Mode."
/>
<Field
label="Stream stuck?"
description="Force-stop the stream client if a session wedges"
childrenContainerWidth="max"
>
<RowActions>
<DialogButton style={actionButton} onClick={() => void forceStopStream()}>
Force-stop
</DialogButton>
</RowActions>
</Field>
<Field
label="Reset Punktfunk"
description="Clear saved hosts, stream settings, and pinned games on this Deck (keeps your client identity)"
childrenContainerWidth="max"
>
<RowActions>
<DialogButton style={actionButton} onClick={onReset}>
<FaTrashAlt style={{ marginRight: "0.4em" }} />
Reset
</DialogButton>
</RowActions>
</Field>
</div>
);
const PunktfunkPage: FC = () => {
const { hosts: discovered, scanning, refresh: refreshDiscovered } = useHosts();
const { saved, loading: loadingSaved, refresh: refreshSaved } = useSavedHosts();
const { info: update, checking, check } = useUpdate();
const pins = usePins();
const [tab, setTab] = useState("hosts");
const hosts = mergeHosts(saved, discovered);
// A host action (pair/add/edit/forget) can change either store, so refresh both.
const refreshHosts = () => {
void refreshDiscovered();
void refreshSaved();
};
return (
<div
style={{
marginTop: "40px",
height: "calc(100% - 40px)",
display: "flex",
flexDirection: "column",
}}
>
{/* Header is title + back only — updates live on the About tab (and the QAM banner). */}
<Focusable
style={{
display: "flex",
alignItems: "center",
gap: "1em",
padding: "0 2.5em",
marginBottom: "0.4em",
flexShrink: 0,
}}
>
<DialogButton style={iconButton} onClick={() => Navigation.NavigateBack()}>
<FaArrowLeft />
</DialogButton>
<div className={staticClasses?.Title} style={{ flex: 1, margin: 0 }}>
Punktfunk
</div>
</Focusable>
{/* Two things fight each other on an L1/R1 tab switch:
1. Valve's Tabs slides the incoming panel in from the right with a CSS transform.
2. `autoFocusContents` then focuses a control inside that still-offscreen panel, which
fires scrollIntoView. Because the panel is offset by a *transform* (not by scroll
position), scrollIntoView can't satisfy it by scrolling any one ancestor, so it walks
up and pans the whole page the "screen jumps right, then animates back" glitch.
Dropping autoFocusContents removes the scrollIntoView entirely, so nothing fights the
slide. L1/R1 still cycles tabs (that handler lives on the Tabs focus scope, active while
focus is anywhere inside including the tab strip); after a switch, focus stays on the
strip and Down enters the content, which is how Steam's own tabbed pages behave.
The overflow:hidden clip stays as defense-in-depth against any stray horizontal pan. */}
<div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
<Tabs
activeTab={tab}
onShowTab={(id: string) => setTab(id)}
tabs={[
{
id: "hosts",
title: "Hosts",
content: (
<HostsTab
hosts={hosts}
scanning={scanning || loadingSaved}
refresh={refreshHosts}
pins={pins}
clientUpdatePending={!!update?.client_update_available}
/>
),
},
{
id: "settings",
title: "Settings",
content: <SettingsTab />,
},
{
id: "about",
title: "About",
content: (
<AboutTab
update={update}
checking={checking}
check={check}
onReset={() => confirmReset([refreshHosts, pins.refresh])}
/>
),
},
]}
/>
</div>
</div>
);
};
// Full page behind the boundary — registered as the /punktfunk route.
export const PunktfunkRoute: FC = () => (
<PluginErrorBoundary>
<PunktfunkPage />
</PluginErrorBoundary>
);
+26 -4
View File
@@ -3,10 +3,32 @@
import { DialogButton, Focusable, ModalRoot, Spinner } from "@decky/ui";
import { toaster } from "@decky/api";
import { FC, useState } from "react";
import { Host, pair } from "./backend";
import { pair } from "./backend";
import { HostView } from "./hooks";
/**
* User-facing copy for a failed ceremony. The CLI's stable exit codes say WHICH failure it was,
* so the keypad can name the fix instead of echoing a log line: `refused` is overwhelmingly a
* mistyped PIN or a host nobody armed, and telling someone to check their network for that
* would send them the wrong way entirely.
*/
function pairErrorBody(error: string | undefined, name: string): string {
switch (error) {
case "refused":
return "Wrong PIN, or the host isnt showing one. Arm pairing again and retry.";
case "unreachable":
return `Couldnt reach ${name}.`;
case "client-outdated":
return "Update the Punktfunk client to pair from here.";
case "client-unavailable":
return "Couldnt reach the Punktfunk client — is it still installed?";
default:
return "Pairing failed.";
}
}
export const PairModal: FC<{
host: Host;
host: HostView;
closeModal?: () => void;
onPaired: () => void;
}> = ({ host, closeModal, onPaired }) => {
@@ -21,13 +43,13 @@ export const PairModal: FC<{
setBusy(true);
setError(null);
try {
const res = await pair(host.host, host.port, pin, "Steam Deck");
const res = await pair(host.addr, host.port, pin, "Steam Deck");
if (res.ok) {
toaster.toast({ title: "Punktfunk", body: `Paired with ${host.name}` });
onPaired();
closeModal?.();
} else {
setError(res.error ?? "pairing failed");
setError(pairErrorBody(res.error, host.name));
setPin("");
}
} catch (e) {
-657
View File
@@ -1,657 +0,0 @@
// Stream settings — the client's WHOLE settings store, written to the JSON the client reads on
// launch (main.py set_settings, merged onto what's on disk). This is the same
// `client-gtk-settings.json` the desktop client and the console's settings screen own, so a value
// changed in any of the three shows in the other two.
//
// SHAPE OF THIS SCREEN. Thirty rows is too many to scroll past on a thumbstick, so they are split
// across a `SidebarNavigation` — the same left-rail-of-categories layout SteamOS's own Settings
// uses, and the one Deck users already know. Every page fits on screen without scrolling, which is
// the whole point of the split: the rail is the index, so nothing is more than one hop away.
//
// The categories, their order, and the wording of the rows are the console's settings screen
// (pf-console-ui/src/screens/settings.rs) — that screen is the other settings editor a user
// reaches without leaving Gaming Mode, and two different orders for one store is how people stop
// trusting either. It shows them as one steppable list because it has no pointer and no room for
// a rail; here they become the rail's pages, same groups, same sequence. Three more rules:
//
// • A setting that depends on another is INDENTED under it and DISABLED, never hidden — the
// console dims those rows rather than dropping them, and a row that vanishes as you toggle
// the one above it is a moving target for a thumbstick.
// • A picker whose options this device doesn't have doesn't appear at all (the GPU row on a
// one-GPU Deck). A dead control is worse than an absent one.
// • Anything that behaves differently *here* than it does on a desktop says so in its own
// description, rather than being silently dropped from the screen.
//
// The accepted gamepad/compositor/codec/decoder names mirror punktfunk-core's `*Pref::from_name`
// and the console's tables; the tier/mode names mirror the `StatsVerbosity` / `TouchMode` /
// `MouseMode` enums, which serialize lowercase.
import {
DialogButton,
Dropdown,
Field,
SidebarNavigation,
SliderField,
Spinner,
ToggleField,
} from "@decky/ui";
import { CSSProperties, FC, ReactElement, ReactNode, useEffect, useState } from "react";
import {
FaDesktop,
FaGamepad,
FaHandPointer,
FaSlidersH,
FaTv,
FaVideo,
FaVolumeUp,
} from "react-icons/fa";
import {
AudioDevice,
DeviceLists,
getSettings,
listDevices,
refreshDevices,
setSettings,
StreamSettings,
} from "./backend";
import { actionButton, RowActions } from "./ui";
// Decky's Dropdown has no width prop — it fills whatever container it's in, and a
// `childrenContainerWidth="max"` Field is the whole row. Wrapping it in this fit-content shell
// (inside the right-aligned RowActions) shrinks the control to its selected label, with a floor
// so short values like "60 Hz" don't collapse to a nub and a ceiling so nothing runs edge to
// edge. Matches the right-aligned, content-sized buttons everywhere else.
const selectShell: CSSProperties = {
width: "fit-content",
minWidth: "10em",
maxWidth: "24em",
};
// ----------------------------------------------------------------------------------------
// Option tables — the console's, so the two Gaming-Mode editors offer the same choices.
// ----------------------------------------------------------------------------------------
// "native" and "match" are virtual: they store `width`/`height` of 0 with `match_window` off/on.
// Match window is offered even though this plugin's launches are always fullscreen (where it
// degenerates to the display's native mode) — leaving it out would make the row lie about a
// store the desktop client can set it in.
const MATCH_WINDOW = "match";
const RESOLUTIONS: [number, number, string][] = [
[0, 0, "Native display"],
[1280, 720, "1280 × 720"],
[1280, 800, "1280 × 800 (Deck)"],
[1920, 1080, "1920 × 1080"],
[2560, 1440, "2560 × 1440"],
[3840, 2160, "3840 × 2160"],
];
const resolutionKey = (w: number, h: number): string => (w === 0 && h === 0 ? "native" : `${w}x${h}`);
const REFRESH = [0, 30, 60, 90, 120];
// Render-resolution multipliers (mirrors punktfunk_core::render_scale::PRESETS). 1.0 = native.
const RENDER_SCALES = [0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0];
const renderScaleLabel = (x: number): string =>
x === 1 ? "Native (1×)" : x > 1 ? `${x}× · supersample` : `${x}×`;
const COMPOSITORS: [string, string][] = [
["auto", "Automatic"],
["kwin", "KDE Plasma (KWin)"],
["wlroots", "Sway (wlroots)"],
["mutter", "GNOME (Mutter)"],
["gamescope", "gamescope"],
];
const CODECS: [string, string][] = [
["auto", "Automatic"],
["hevc", "HEVC (H.265)"],
["h264", "H.264 (AVC)"],
["av1", "AV1"],
// Opt-in wired-LAN low-latency codec (100400 Mbit/s class, 8-bit SDR). Only ever selected
// when the host advertises it too; anything else falls back to HEVC.
["pyrowave", "PyroWave (wired LAN)"],
];
const DECODERS: [string, string][] = [
["auto", "Automatic"],
["vulkan", "Vulkan Video"],
["vaapi", "VAAPI"],
["software", "Software"],
];
// Presentation intent — the `present_priority` key shared with the Apple and Android clients, so
// one profile reads the same on every device.
const PRESENT_PRIORITIES: [string, string][] = [
["latency", "Lowest latency"],
["smooth", "Smoothness"],
];
// Smoothness buffer depth in frames; 0 = Automatic (resolves to 2).
const SMOOTH_BUFFERS: [number, string][] = [
[0, "Automatic"],
[1, "1 frame"],
[2, "2 frames"],
[3, "3 frames"],
];
const AUDIO_CHANNELS: [number, string][] = [
[2, "Stereo"],
[6, "5.1 surround"],
[8, "7.1 surround"],
];
const GAMEPADS: [string, string][] = [
["auto", "Automatic"],
["xbox360", "Xbox 360"],
["xboxone", "Xbox One"],
["dualsense", "DualSense"],
["dualshock4", "DualShock 4"],
["steamdeck", "Steam Deck"],
];
const TOUCH_MODES: [string, string][] = [
["trackpad", "Trackpad"],
["pointer", "Direct pointer"],
["touch", "Touch passthrough"],
];
const MOUSE_MODES: [string, string][] = [
["capture", "Capture (games)"],
["desktop", "Desktop (absolute)"],
];
const STATS_TIERS: [string, string][] = [
["off", "Off"],
["compact", "Compact"],
["normal", "Normal"],
["detailed", "Detailed"],
];
// ----------------------------------------------------------------------------------------
// Row primitives — every picker row is Field + right-aligned, content-sized Dropdown, so the
// twelve of them below stay one line each and can't drift apart.
// ----------------------------------------------------------------------------------------
const SelectRow = <T extends string | number>({
label,
description,
options,
value,
onChange,
formatUnknown,
disabled,
indent,
}: {
label: string;
description?: ReactNode;
options: [T, string][];
value: T;
onChange: (v: T) => void;
// How to name a stored value this table doesn't list (see below); defaults to the raw value.
formatUnknown?: (v: T) => string;
disabled?: boolean;
indent?: boolean;
}): ReactElement => {
// A Dropdown can only display a value that is one of its options, and this store has four other
// writers — the desktop client, the console, a settings profile, a newer client with presets
// this build doesn't know. Rather than render a blank control (or, worse, silently show a
// different value than the stream will actually use), carry the stored one as its own entry.
const shown: [T, string][] = options.some(([v]) => v === value)
? options
: [...options, [value, formatUnknown ? formatUnknown(value) : String(value)]];
return (
<Field
label={label}
description={description}
disabled={disabled}
indentLevel={indent ? 1 : undefined}
childrenContainerWidth="max"
>
<RowActions>
<div style={selectShell}>
<Dropdown
disabled={disabled}
rgOptions={shown.map(([data, l]) => ({ data, label: l }))}
selectedOption={value}
onChange={(o) => onChange(o.data as T)}
/>
</div>
</RowActions>
</Field>
);
};
// An audio-endpoint picker. The stored value is a PipeWire `node.name`; "" means "whatever the OS
// is using". A stored endpoint that isn't in the current enumeration still gets an entry — it is
// a real preference that simply isn't plugged in right now, and dropping it would silently
// re-point the next stream at the default without ever showing the user why.
const DeviceRow: FC<{
label: string;
description: string;
devices: AudioDevice[] | null;
value: string;
onChange: (v: string) => void;
disabled?: boolean;
indent?: boolean;
}> = ({ label, description, devices, value, onChange, disabled, indent }) => {
const options: [string, string][] = [["", "System default"]];
for (const d of devices ?? []) options.push([d.name, d.description]);
if (value && !options.some(([name]) => name === value)) {
options.push([value, `${value} (not connected)`]);
}
return (
<SelectRow
label={label}
description={devices === null ? "Reading this device's audio endpoints…" : description}
options={options}
value={value}
onChange={onChange}
disabled={disabled || devices === null}
indent={indent}
/>
);
};
// ----------------------------------------------------------------------------------------
// The pages. One settings object, seven views on it — every page takes the same context rather
// than fetching or holding state of its own, so a change on one page is visible on the others
// the moment you switch.
// ----------------------------------------------------------------------------------------
interface PageCtx {
s: StreamSettings;
patch: (p: Partial<StreamSettings>) => void;
devices: DeviceLists | null;
reading: boolean;
readDevices: (again: boolean) => void;
}
// SidebarNavigation gives each page Steam's own padding, but the routed page still renders
// UNDER Gaming Mode's footer hint bar, so the last row of a page needs to clear it (the same
// inset the tabs use).
const pageBody: CSSProperties = { paddingBottom: "80px" };
const StreamPage: FC<PageCtx> = ({ s, patch }) => {
const renderScale = s.render_scale ?? 1;
const resolution = s.match_window ? MATCH_WINDOW : resolutionKey(s.width, s.height);
return (
<div style={pageBody}>
<SelectRow
label="Resolution"
description="The host creates a virtual display at exactly this size — no scaling. Match window follows the stream window instead, which in Gaming Mode means the Deck's native size."
options={[
...RESOLUTIONS.map(([w, h, label]) => [resolutionKey(w, h), label] as [string, string]),
[MATCH_WINDOW, "Match window"] as [string, string],
]}
value={resolution}
// A size set from a desktop profile that isn't one of these presets, spelled the way the
// presets are rather than left as the raw "1600x900" key.
formatUnknown={(v) => v.replace("x", " × ")}
onChange={(v) => {
if (v === MATCH_WINDOW) {
// The tri-state the console stores: the flag on, the explicit size cleared.
patch({ match_window: true, width: 0, height: 0 });
return;
}
const found = RESOLUTIONS.find(([w, h]) => resolutionKey(w, h) === v);
patch({ match_window: false, width: found?.[0] ?? 0, height: found?.[1] ?? 0 });
}}
/>
<SelectRow
label="Refresh rate"
description="Native follows the display the stream is on."
options={REFRESH.map((r) => [r, r === 0 ? "Native" : `${r} Hz`] as [number, string])}
value={s.refresh_hz}
formatUnknown={(v) => `${v} Hz`}
onChange={(v) => patch({ refresh_hz: v })}
/>
<SelectRow
label="Render scale"
description="The host renders larger or smaller than the stream mode and the Deck resamples — above 1× supersamples for sharpness, below 1× saves bandwidth."
options={RENDER_SCALES.map((x) => [x, renderScaleLabel(x)] as [number, string])}
// Snap the stored value to the nearest preset so the dropdown always shows a match.
value={RENDER_SCALES.reduce((best, x) =>
Math.abs(x - renderScale) < Math.abs(best - renderScale) ? x : best,
)}
onChange={(v) => patch({ render_scale: v })}
/>
<SliderField
label="Bitrate"
description="0 = the host's own default (20 Mbit/s)."
value={Math.round(s.bitrate_kbps / 1000)}
min={0}
max={150}
step={5}
showValue
valueSuffix=" Mbit/s"
onChange={(v) => patch({ bitrate_kbps: v * 1000 })}
/>
<SelectRow
label="Host compositor"
description="Which compositor drives the virtual display — honoured only if it's available on the host. Automatic suits almost every host."
options={COMPOSITORS}
value={s.compositor}
onChange={(v) => patch({ compositor: v })}
/>
</div>
);
};
const VideoPage: FC<PageCtx> = ({ s, patch, devices }) => {
// Only worth a row on a box that actually has a choice to make. A Deck has one adapter, and a
// picker with a single option is a control that can't do anything.
const showGpuRow = (devices?.adapters.length ?? 0) > 1;
return (
<div style={pageBody}>
<SelectRow
label="Video codec"
description="A preference — the host falls back when its GPU can't encode this one."
options={CODECS}
value={s.codec ?? "auto"}
onChange={(v) => patch({ codec: v })}
/>
<SelectRow
label="Video decoder"
description="How the Deck decodes the stream. Automatic prefers Vulkan Video, then VAAPI, then software."
options={DECODERS}
value={s.decoder ?? "auto"}
onChange={(v) => patch({ decoder: v })}
/>
{showGpuRow && (
<SelectRow
label="Decode GPU"
description="Which adapter decodes and presents the stream. Automatic picks the discrete GPU where there is one."
options={[
["", "Automatic"],
...(devices?.adapters ?? []).map((a) => [a, a] as [string, string]),
]}
value={s.adapter ?? ""}
onChange={(v) => patch({ adapter: v })}
/>
)}
<ToggleField
label="10-bit HDR"
description="Advertise HDR10 so the host sends 10-bit when the content is HDR. Off means never ask for 10-bit."
checked={s.hdr_enabled ?? true}
onChange={(v) => patch({ hdr_enabled: v })}
/>
<ToggleField
label="Full chroma (4:4:4)"
description="Full-colour video: crisp small text and thin lines, at more bandwidth. Needs an NVIDIA host (NVENC) or the PyroWave codec — other encoders stream 4:2:0 and the session falls back silently."
checked={s.enable_444 ?? false}
onChange={(v) => patch({ enable_444: v })}
/>
</div>
);
};
const PresentationPage: FC<PageCtx> = ({ s, patch }) => {
const smooth = (s.present_priority ?? "latency") === "smooth";
return (
<div style={pageBody}>
<SelectRow
label="Prioritize"
description="What to optimise for when a decoded frame is ready. Lowest latency shows each frame the moment the display can take it — a network hiccup becomes an occasional repeated or skipped frame. Smoothness buffers a little to even those out."
options={PRESENT_PRIORITIES}
value={s.present_priority ?? "latency"}
onChange={(v) => patch({ present_priority: v })}
/>
<SelectRow
label="Smoothness buffer"
description="Frames held back before showing. Each one absorbs about a refresh of network hiccup and adds a refresh of delay. Automatic holds two."
options={SMOOTH_BUFFERS}
value={s.smooth_buffer ?? 0}
formatUnknown={(v) => `${v} frames`}
onChange={(v) => patch({ smooth_buffer: v })}
disabled={!smooth}
indent
/>
<ToggleField
label="V-Sync"
description="Tear-free. Off removes the wait for the screen's refresh — the lowest possible delay, at the cost of visible tearing. Best-effort: not every driver offers it, and the Detailed stats overlay names the mode actually in use."
checked={s.vsync ?? true}
onChange={(v) => patch({ vsync: v })}
/>
<ToggleField
label="Follow variable refresh"
description="On a VRR screen, let the panel refresh in step with the stream instead of on a fixed cadence. Applies to fullscreen sessions — which a Gaming-Mode stream always is — and is harmless on a fixed-refresh screen."
checked={s.allow_vrr ?? true}
onChange={(v) => patch({ allow_vrr: v })}
/>
</div>
);
};
const AudioPage: FC<PageCtx> = ({ s, patch, devices, reading, readDevices }) => {
const micOn = s.mic_enabled;
// What the pickers get: null while the enumeration is in flight (they show a loading state),
// [] when it answered but couldn't read the endpoints (System default plus whatever is
// stored), and the real list otherwise.
const endpoints = (list: AudioDevice[] | undefined): AudioDevice[] | null =>
reading || !devices ? null : devices.ok ? (list ?? []) : [];
return (
<div style={pageBody}>
<SelectRow
label="Audio channels"
description="The speaker layout requested from the host, which clamps it to what it can capture."
options={AUDIO_CHANNELS}
value={s.audio_channels ?? 2}
formatUnknown={(v) => `${v} channels`}
onChange={(v) => patch({ audio_channels: v })}
/>
<DeviceRow
label="Output device"
description="Where stream audio plays. System default follows whatever the Deck is using, including a headset you plug in mid-stream."
devices={endpoints(devices?.sinks)}
value={s.speaker_device ?? ""}
onChange={(v) => patch({ speaker_device: v })}
/>
<ToggleField
label="Stream microphone"
description="Send the Deck's microphone to the host's virtual mic. Ctrl+Alt+Shift+V mutes and unmutes it mid-stream."
checked={micOn}
onChange={(v) => patch({ mic_enabled: v })}
/>
<DeviceRow
label="Microphone device"
description="Which input the mic uplink captures from."
devices={endpoints(devices?.sources)}
value={s.mic_device ?? ""}
onChange={(v) => patch({ mic_device: v })}
disabled={!micOn}
indent
/>
<ToggleField
label="Echo cancellation"
description="Stops the host's audio, playing from the Deck's speakers, being picked up and sent back. Turn it off if your microphone already runs its own processing."
checked={s.echo_cancel ?? true}
onChange={(v) => patch({ echo_cancel: v })}
disabled={!micOn}
indentLevel={1}
/>
{/* The escape hatch for a headset plugged in after this page was opened, and the honest
answer when the enumeration failed outright (a client too old to ship the session
binary). Rendered unconditionally, including while it is reading: a row that comes and
goes under a thumbstick is a moving target, so only its wording changes. */}
<Field
label={
!reading && devices && !devices.ok ? "Couldn't read this device's hardware" : "Devices"
}
description={
reading
? "Reading this device's audio endpoints and GPUs…"
: devices && !devices.ok
? "The output, microphone and GPU pickers fall back to Automatic. Reading them needs the client's session binary, which a client older than the two-binary split doesn't ship — update it from the About tab."
: "Plugged something in just now? Read the audio endpoints and GPUs again."
}
childrenContainerWidth="max"
>
<RowActions>
<DialogButton style={actionButton} disabled={reading} onClick={() => readDevices(true)}>
{reading ? <Spinner style={{ height: "1em" }} /> : "Refresh"}
</DialogButton>
</RowActions>
</Field>
</div>
);
};
const ControllersPage: FC<PageCtx> = ({ s, patch }) => {
const forwarding = s.gamepad_forwarding ?? true;
return (
<div style={pageBody}>
<ToggleField
label="Forward controllers"
description="Send controllers connected to the Deck to the host. Turn it off when your controller already reaches the host another way — USB passthrough such as VirtualHere, or a pad plugged into the host — so games don't see two of them."
checked={forwarding}
onChange={(v) => patch({ gamepad_forwarding: v })}
/>
<SelectRow
label="Controller type"
description="The virtual pad the host creates. Automatic matches the controller you're holding."
options={GAMEPADS}
value={s.gamepad}
onChange={(v) => patch({ gamepad: v })}
disabled={!forwarding}
indent
/>
{forwarding && (s.gamepad === "steamdeck" || s.gamepad === "auto") && (
<Field
label="⚠ Disable Steam Input"
description="On a Deck, Automatic forwards the built-in controller as a Steam Deck pad — paddles, both trackpads, and gyro included. For that, Steam Input must be OFF for Punktfunk: on the game page tap ⚙ → Controller Settings → set Steam Input to Off. Otherwise Steam keeps the Deck's controls and only the sticks + buttons reach the host."
indentLevel={1}
/>
)}
</div>
);
};
const PointerPage: FC<PageCtx> = ({ s, patch }) => (
<div style={pageBody}>
<SelectRow
label="Touch mode"
description="How the touchscreen drives the host: Trackpad (relative cursor, tap to click), Direct pointer (the cursor jumps to your finger), or Touch passthrough (every finger is a host contact — only helps apps that understand touch)."
options={TOUCH_MODES}
value={s.touch_mode ?? "trackpad"}
onChange={(v) => patch({ touch_mode: v })}
/>
<SelectRow
label="Mouse mode"
description="How a physical mouse drives the host: Capture locks the pointer for games, Desktop leaves it free and sends absolute positions. Ctrl+Alt+Shift+M switches it live mid-stream."
options={MOUSE_MODES}
value={s.mouse_mode ?? "capture"}
onChange={(v) => patch({ mouse_mode: v })}
/>
<ToggleField
label="Invert scroll direction"
description="Reverses the wheel and trackpad scroll direction sent to the host."
checked={s.invert_scroll ?? false}
onChange={(v) => patch({ invert_scroll: v })}
/>
<ToggleField
label="Capture system shortcuts"
description="Sends Alt+Tab, Super and friends to the host while input is captured, instead of leaving them to the local desktop. Gaming Mode is gamescope, which has no shortcuts to hold back — this is for a keyboard attached to the Deck in Desktop Mode, and for the desktop client sharing these settings."
checked={s.inhibit_shortcuts}
onChange={(v) => patch({ inhibit_shortcuts: v })}
/>
</div>
);
const InterfacePage: FC<PageCtx> = ({ s, patch }) => {
// `Settings::stats_verbosity`: no tier = a pre-tier store, resolved through the legacy bool,
// which itself defaults to true.
const statsTier = s.stats_verbosity ?? ((s.show_stats ?? true) ? "normal" : "off");
return (
<div style={pageBody}>
<SelectRow
label="Statistics overlay"
description="How much the in-stream overlay shows: Compact (fps · latency · bitrate on one line) → Normal → Detailed. A three-finger tap on the touchscreen cycles it mid-stream."
options={STATS_TIERS}
value={statsTier}
// Both keys, in sync — the same pairing `Settings::set_stats_verbosity` keeps, so a
// client too old for the tiers still honours an Off chosen here.
onChange={(v) => patch({ stats_verbosity: v, show_stats: v !== "off" })}
/>
<ToggleField
label="Wake hosts automatically"
description="Send Wake-on-LAN to a sleeping host before connecting and wait for it to boot. Turn it off for hosts reached over a VPN, where an offline-looking host is really just unreachable by broadcast and the wait only adds delay."
checked={s.auto_wake ?? true}
onChange={(v) => patch({ auto_wake: v })}
/>
<ToggleField
label="Show game library in the client"
description="Lets the client's own host cards browse a paired host's games. This plugin's library browser works either way — this is for the client's screens."
checked={s.library_enabled ?? false}
onChange={(v) => patch({ library_enabled: v })}
/>
<ToggleField
label="Start streams fullscreen"
description="Streams open fullscreen instead of windowed. Launches from this plugin are always fullscreen whatever this says — it's here because the desktop client reads the same settings."
checked={s.fullscreen_on_stream ?? true}
onChange={(v) => patch({ fullscreen_on_stream: v })}
/>
</div>
);
};
// ----------------------------------------------------------------------------------------
export const SettingsSection: FC = () => {
const [s, setS] = useState<StreamSettings | null>(null);
// null until the enumeration answers — the pickers show a loading state rather than briefly
// claiming this device has no endpoints.
const [devices, setDevices] = useState<DeviceLists | null>(null);
const [reading, setReading] = useState(true);
const readDevices = (again: boolean) => {
setReading(true);
void (again ? refreshDevices() : listDevices())
.then(setDevices)
.finally(() => setReading(false));
};
useEffect(() => {
void getSettings().then(setS);
// Deliberately not awaited together with the settings: a cold flatpak initialising Vulkan
// takes seconds, and the rest of the screen must not wait for it.
readDevices(false);
}, []);
const patch = (p: Partial<StreamSettings>) => {
setS((cur) => {
if (!cur) return cur;
const next = { ...cur, ...p };
void setSettings(next);
return next;
});
};
if (!s) return <Spinner style={{ height: "1.5em" }} />;
const ctx: PageCtx = { s, patch, devices, reading, readDevices };
return (
<SidebarNavigation
// We are already inside the plugin's own `/punktfunk` route, rendered in a tab. Route
// reporting would have this nav push entries of its own onto the router and fight the
// page for the back gesture; the pages are addressed by `identifier` instead.
disableRouteReporting
pages={[
{ title: "Stream", identifier: "stream", icon: <FaDesktop />, content: <StreamPage {...ctx} /> },
{ title: "Video", identifier: "video", icon: <FaVideo />, content: <VideoPage {...ctx} /> },
{
title: "Presentation",
identifier: "presentation",
icon: <FaTv />,
content: <PresentationPage {...ctx} />,
},
{ title: "Audio", identifier: "audio", icon: <FaVolumeUp />, content: <AudioPage {...ctx} /> },
{
title: "Controllers",
identifier: "controllers",
icon: <FaGamepad />,
content: <ControllersPage {...ctx} />,
},
{
title: "Touch & mouse",
identifier: "pointer",
icon: <FaHandPointer />,
content: <PointerPage {...ctx} />,
},
{
title: "Interface",
identifier: "interface",
icon: <FaSlidersH />,
content: <InterfacePage {...ctx} />,
},
]}
/>
);
};
+64 -59
View File
@@ -8,16 +8,16 @@
//
// TWO shortcuts, both named "Punktfunk" (so they share ONE Steam Input controller-config key —
// see applyControllerConfig):
// • STREAM — hidden, stateful: the per-session launcher. Its launch options carry the host /
// pinned game (PF_HOST/PF_LAUNCH/PF_BROWSE), rewritten per launch, so one shortcut serves
// every host. Driven by the QAM/pins/host-library actions. Hidden — an implementation detail.
// • STREAM — hidden, stateful: the per-session launcher. Its launch options carry the host
// reference and the card's profile (PF_REF/PF_PROFILE/PF_REQUEST_ACCESS), rewritten per
// launch, so one shortcut serves every host. Hidden — an implementation detail.
// • GAMEPAD UI — visible, stateless: fixed launch options = bare `--browse` (PF_BROWSE, no
// host) → the client's console home (host picker + pairing + settings, gamepad-navigable).
// This is the library-visible "Punktfunk" app the user opens directly.
//
// Both get the shipped artwork and the native-touch controller config.
import { applyControllerConfig, runnerInfo, shortcutArt, wake } from "./backend";
import { applyControllerConfig, runnerInfo, shortcutArt } from "./backend";
// SteamClient is a Steam-internal global injected into the CEF context; it is not fully typed
// by @decky/ui, so declare the surface we use. Signatures verified against MoonDeck + the
@@ -257,11 +257,12 @@ export async function ensureGamepadUiShortcut(): Promise<number | null> {
}
const startDir = info.runner.replace(/\/[^/]*$/, "");
void ensureControllerConfig();
// Bare browse: PF_BROWSE with no PF_HOST → the wrapper runs `--browse --fullscreen` (console
// home). %command% expands to the shortcut exe (/bin/sh); the wrapper rides behind as an arg.
// PF_CLIENT_BIN only when the backend resolved a NATIVE client — else the wrapper's flatpak
// default stands and this shortcut is exactly what it always was.
const clientBin = info.client_bin ? `PF_CLIENT_BIN=${info.client_bin} ` : "";
// PF_BROWSE → the wrapper runs the SESSION's `--browse --fullscreen` (console home), which is
// the one branch this rework deliberately left alone. %command% expands to the shortcut exe
// (/bin/sh); the wrapper rides behind as an arg. PF_CLIENT_BIN only when the backend resolved
// a NATIVE client — else the wrapper's flatpak default stands and this shortcut is exactly
// what it always was.
const clientBin = safeClientBin(info.client_bin) ? `PF_CLIENT_BIN=${info.client_bin} ` : "";
const launchOpts = `${clientBin}PF_BROWSE=1 %command% "${info.runner}"`;
// Reuse the remembered entry only if it still exists; a stale appId (deleted shortcut whose
@@ -319,77 +320,81 @@ export async function launchGamepadUi(): Promise<void> {
}
}
/** Per-launch extras beyond the host target (all optional — {} is the plain stream). */
/** Per-launch extras beyond the host reference (all optional — {} is the plain stream). */
export interface LaunchOpts {
/** Library id to launch on connect (a pinned game) — rides PF_LAUNCH → `--launch`. */
launchId?: string;
/** Open the gamepad library launcher instead of streaming (PF_BROWSE → `--browse`). */
browse?: boolean;
/** Management-API port for the launcher's library fetch (PF_MGMT; 0/absent = default). */
mgmt?: number;
/** A pinned card: stream with this settings profile, one-off (PF_PROFILE → `--profile`). */
profileId?: string;
/**
* Ask the host's operator to admit this Deck rather than typing a PIN (PF_REQUEST_ACCESS).
* The connect PARKS until somebody approves it, and the launch runs SUPERVISED see the
* wrapper for why `--exec` is dropped on this path alone.
*/
requestAccess?: boolean;
}
// Launch ids ride Steam launch options as an env-prefix token (`PF_LAUNCH=<id>`), so they
// must be space/quote-free — Steam's tokenizer and the wrapper's env both break otherwise.
// Real ids are `steam:<digits>` / `custom:<slug>`, so this rejects nothing in practice;
// it's VALIDATION, never encoding (the host must match the opaque token verbatim).
const UNSAFE_LAUNCH_ID = /["'\\$`\s]/;
// Host refs and profile ids ride Steam launch options as env-prefix tokens (`PF_REF=<ref>`),
// so they must be space/quote-free — Steam's tokenizer and the wrapper's env both break
// otherwise. Real values are UUIDs or `addr:port`, so this rejects nothing in practice; it is
// VALIDATION, never encoding (the client must receive the opaque token verbatim).
const UNSAFE_TOKEN = /["'\\$`\s]/;
export function isSafeLaunchId(id: string): boolean {
return (
id.length > 0 &&
id.length <= 128 &&
UNSAFE_LAUNCH_ID.exec(id) === null &&
UNSAFE_TOKEN.exec(id) === null &&
/^[\x21-\x7e]+$/.test(id)
);
}
/**
* Launch a stream to `host:port` fullscreen in Gaming Mode (optionally straight into a
* library title, or into a host's gamepad library). Encodes the target into the STREAM
* shortcut's launch options (so one hidden shortcut serves every host and every pinned game),
* then RunGame.
* Is a resolved native-client path safe to put in Steam's launch options? Same rule, separate
* name because the failure is different: an unsafe id is a bug in our own data, an unsafe path
* is just where the user installed the client so the browse shortcut degrades to its flatpak
* default rather than refusing to exist.
*/
export async function launchStream(
host: string,
port: number,
opts: LaunchOpts = {},
): Promise<void> {
// Wake-on-LAN: if this host is asleep, nudge it awake before the stream connects. Kicked off now
// so it races with the shortcut setup (near-zero added latency); its outcome is needed below
// (the connect budget), and RunGame follows the await either way, so nothing is slower for it.
// Best-effort — the flatpak client's --wake looks up the host's learned MAC (a no-op if none is
// known), and the connect that follows has its own retry window, so a failure never blocks launch.
const waking = wake(host, port).catch(() => ({ ok: false }));
const [{ appId, runner, clientBin }, woke] = await Promise.all([ensureStreamShortcut(), waking]);
const target = port && port !== 9777 ? `${host}:${port}` : host;
const env = [`PF_HOST=${target}`];
function safeClientBin(bin: string | undefined): bin is string {
return !!bin && isSafeLaunchId(bin);
}
/**
* Stream `ref` fullscreen in Gaming Mode, optionally with a pinned card's profile. Encodes the
* target into the STREAM shortcut's launch options one hidden shortcut serves every host
* then RunGame.
*
* No Wake-on-LAN here any more. The plugin used to fire a magic packet itself and then stretch
* the connect budget to 75 s to cover the host's resume, which was a workaround for the era
* before the CLI existed. `punktfunk launch` now runs the real wake-and-wait loop (packet at
* t=0, re-sent every 6 s, presence polled every second) and only dials once the host answers
* strictly better, and it deletes a backend method, a frontend call and a shell branch.
*/
export async function launchStream(ref: string, opts: LaunchOpts = {}): Promise<void> {
if (!isSafeLaunchId(ref)) {
throw new Error(`unsupported host reference: ${ref}`);
}
if (opts.profileId && !isSafeLaunchId(opts.profileId)) {
throw new Error(`unsupported profile id: ${opts.profileId}`);
}
const { appId, runner, clientBin } = await ensureStreamShortcut();
const env = [`PF_REF=${ref}`];
// Set only for a NATIVE client install; absent, the wrapper takes its flatpak default, so every
// existing Deck install produces byte-identical launch options to before.
if (clientBin) {
// The one launch-option value that comes from the backend rather than a store id, and so
// the one that could carry a space: a path like `/home/deck/my apps/punktfunk-client` would
// split Steam's tokenizer and land its tail in front of %command% as a bogus env token.
if (!isSafeLaunchId(clientBin)) {
throw new Error(`client path can't ride Steam's launch options: ${clientBin}`);
}
env.push(`PF_CLIENT_BIN=${clientBin}`);
}
// A magic packet actually went out (a MAC was known), so the host may be mid-resume from
// suspend — that takes far longer than the client's default 15 s connect budget. Stretch the
// budget so the client's wake-tolerant dial keeps retrying across the resume; against an
// already-awake host the connect still lands in under a second, so this costs nothing.
if (woke.ok) {
env.push("PF_CONNECT_TIMEOUT=75");
if (opts.profileId) {
env.push(`PF_PROFILE=${opts.profileId}`);
}
if (opts.browse) {
env.push("PF_BROWSE=1");
if (opts.mgmt) {
env.push(`PF_MGMT=${Math.floor(opts.mgmt)}`);
}
} else if (opts.launchId) {
if (!isSafeLaunchId(opts.launchId)) {
// Enforced at pin time too (the picker disables Pin) — this is the backstop.
throw new Error(`unsupported launch id: ${opts.launchId}`);
}
env.push(`PF_LAUNCH=${opts.launchId}`);
if (opts.requestAccess) {
env.push("PF_REQUEST_ACCESS=1");
}
// KEY=value ... %command% args — %command% expands to the shortcut exe (/bin/sh); the wrapper
// script rides behind it as an argument and reads PF_* from the environment. The wake was
// awaited above, so the magic packet is out before the connect attempt.
// script rides behind it as an argument and reads PF_* from the environment.
SteamClient.Apps.SetAppLaunchOptions(appId, `${env.join(" ")} %command% "${runner}"`);
SteamClient.Apps.RunGame(gameIdFromAppId(appId), "", -1, 100);
}
+164
View File
@@ -0,0 +1,164 @@
// The trust sheet — the step between "I can see a host" and "I can stream it".
//
// Two ways in, in the order the GTK dialog and the console's pair screen offer them:
//
// • REQUEST ACCESS (default) — no PIN. Save the host with the fingerprint it ADVERTISED,
// then launch. The host parks that connect until its operator approves this Deck in the
// console or web UI, admits it, and the stream starts by itself. It is not a second
// pairing ceremony; it is an ordinary identified connect with a stretched budget, which
// is why it costs no ceremony surface here at all.
// • USE A PIN INSTEAD — the existing gamepad-navigable keypad (pair.tsx).
//
// NO FINGERPRINT, NO REQUEST ACCESS. The parked connect pins the advertised fingerprint, and
// that pin is the only thing standing between a 185 s wait and an impostor answering for the
// host. A host typed in by address advertises nothing, so it gets the PIN path only — and is
// told why, rather than being shown a button that could only fail. Under no circumstances does
// this sheet trust-on-first-use its way past a missing fingerprint.
import { DialogButton, Focusable, ModalRoot, Spinner, showModal } from "@decky/ui";
import { toaster } from "@decky/api";
import { FC, useRef, useState } from "react";
import { trustHost } from "./backend";
import { HostView } from "./hooks";
import { PairModal } from "./pair";
/** User-facing copy for a `trustHost` failure code. */
function trustErrorBody(error: string | undefined, name: string): string {
switch (error) {
case "refused":
return `${name} is already saved under a different identity. Forget it in the Punktfunk app before trusting it again.`;
case "client-outdated":
return "Update the Punktfunk client to use request access.";
case "client-unavailable":
return "Couldnt reach the Punktfunk client — is it still installed?";
default:
return `Couldnt save ${name}.`;
}
}
export const TrustSheet: FC<{
host: HostView;
closeModal?: () => void;
/** Stream this host, having just been let in. */
onStream: (opts: { requestAccess?: boolean }) => void;
/** Re-read the host list — the record changed underneath the panel. */
onChanged: () => void;
}> = ({ host, closeModal, onStream, onChanged }) => {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// ⚠ This sheet is a `showModal` PORTAL: it captures its callbacks ONCE and never re-renders
// from panel state. Anything it needs to act on later must be read through a ref, not out of
// a captured value — reading a captured array is exactly what made pinning a second game
// compute from a stale base and clobber the first.
const props = useRef({ host, onStream, onChanged });
props.current = { host, onStream, onChanged };
// Request access pins what the host ADVERTISES. The record's own pin is a different thing:
// a host that already has one streams without ever opening this sheet.
const hasIdentity = host.advertisedFp !== "";
// A host advertising `pair=optional` admits anyone who pins its identity — there is no
// operator decision to wait for, and asking for one would be a wait that never ends and a
// record claiming somebody approved this Deck when nobody did. `paired` means the PIN
// ceremony or a real approval; the desktop client records exactly this case as *trusted*.
const needsApproval = host.pairPolicy !== "optional";
const canRequestAccess = hasIdentity && needsApproval;
const canTrustDirectly = hasIdentity && !needsApproval;
/**
* Pin the advertised identity, then stream.
*
* `approval` is what differs between the two doors, and it is not cosmetic: it decides whether
* the launch waits ~185 s for an operator AND whether the record ends up marked paired.
*/
const letIn = async (approval: boolean) => {
setBusy(true);
setError(null);
const { host: h, onStream: stream, onChanged: changed } = props.current;
try {
// Step 1: save it with the ADVERTISED fingerprint, pinned but unpaired ("trusted").
// Idempotent, so a retry after a declined approval is free.
const r = await trustHost(h.addr, h.port, h.advertisedFp, h.name);
if (!r.ok) {
setError(trustErrorBody(r.error, h.name));
setBusy(false);
return;
}
changed();
// Step 2: the launch. Under approval it PARKS — and the session's plain connecting screen
// looks identical whether it is parked or hanging, so say what is about to happen BEFORE
// it starts. That toast is a patch over that, and the real fix belongs in the session.
if (approval) {
toaster.toast({
title: "Punktfunk",
body: `Approve this Deck in ${h.name}s console — the stream starts by itself`,
duration: 10_000,
});
}
stream({ requestAccess: approval });
closeModal?.();
} catch (e) {
setError(String(e));
setBusy(false);
}
};
const usePin = () => {
// Hand off to the keypad. Closing first keeps one modal on screen at a time, which is what
// the gamepad focus model expects.
const { host: h, onStream: stream, onChanged: changed } = props.current;
closeModal?.();
showModal(
<PairModal
host={h}
onPaired={() => {
changed();
stream({});
}}
/>,
);
};
return (
<ModalRoot closeModal={closeModal}>
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.3em" }}>
Connect to {host.name}
</div>
<div style={{ opacity: 0.8, marginBottom: "1em" }}>
{!hasIdentity
? "No advertised identity for this host — pair with a PIN instead."
: canTrustDirectly
? `${host.name} accepts new devices. Connecting pins its identity so later streams are silent.`
: `${host.name} needs to let this device in before it can stream.`}
</div>
{error && (
<div style={{ color: "#ff6b6b", marginBottom: "0.6em" }}>{error}</div>
)}
<Focusable style={{ display: "flex", flexDirection: "column", gap: "0.5em" }}>
{canRequestAccess && (
<DialogButton disabled={busy} onClick={() => void letIn(true)}>
{busy ? <Spinner style={{ height: "1em" }} /> : "Request access"}
</DialogButton>
)}
{canTrustDirectly && (
<DialogButton disabled={busy} onClick={() => void letIn(false)}>
{busy ? <Spinner style={{ height: "1em" }} /> : "Connect"}
</DialogButton>
)}
<DialogButton disabled={busy} onClick={usePin}>
Use a PIN instead
</DialogButton>
<DialogButton disabled={busy} onClick={() => closeModal?.()}>
Cancel
</DialogButton>
</Focusable>
{canRequestAccess && (
<div style={{ opacity: 0.6, fontSize: "0.85em", marginTop: "0.8em" }}>
Request access asks {host.name}s operator to approve this Deck in its console or web
UI. No PIN to type the stream starts as soon as they do.
</div>
)}
</ModalRoot>
);
};
-46
View File
@@ -1,46 +0,0 @@
// Shared UI primitives for the fullscreen page + modals. The one rule that keeps every row
// looking consistent: a Field's action(s) always sit right-aligned, with real space between
// them and the label text — never hugging it.
//
// Decky lays a Field out as `[ label .......... children ]`. When the children container is
// grown (`childrenContainerWidth="max"`, which we want so multi-button clusters have room), a
// bare `fit-content` button LEFT-aligns inside that grown container and ends up pressed against
// the label with the space wasted to its right. Wrapping the action(s) in `RowActions` pushes
// them to the right edge and evenly spaces multiples — the same treatment every row now gets.
import { Focusable } from "@decky/ui";
import { CSSProperties, FC, ReactNode } from "react";
export const RowActions: FC<{ children: ReactNode }> = ({ children }) => (
<Focusable
style={{
display: "flex",
gap: "0.5em",
justifyContent: "flex-end",
alignItems: "center",
}}
>
{children}
</Focusable>
);
// A single action button sized to its content (not the gamepad-UI default of 100% width), with
// a floor so short labels ("Pair", "Remove") don't render as tiny nubs and every row's button
// reads at the same weight.
export const actionButton: CSSProperties = {
width: "fit-content",
minWidth: "7em",
flexShrink: 0,
};
// Square icon-only button (details ⓘ, header back arrow). Needs an explicit height or the zero
// padding collapses it to the icon's line height.
export const iconButton: CSSProperties = {
width: "40px",
minWidth: "40px",
height: "40px",
padding: 0,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
};
+4
View File
@@ -558,6 +558,10 @@ async fn session(args: Args) -> Result<()> {
} else {
0
},
// Like STREAMED_AU above: the shared-core reassembler pins geometry per-frame, so
// the probe accepts a mid-session shard change (and jumbo growth) up to the
// receive ceiling — and it's exactly the tool to measure both.
max_shard_payload: punktfunk_core::config::max_shard_payload() as u16,
}
.encode(),
)
+86 -12
View File
@@ -79,20 +79,22 @@ pub fn run(target: Option<&str>) -> u8 {
can_wake: false,
last_used: k.and_then(|h| h.last_used),
os: k.map(|h| h.os.clone()).unwrap_or_default(),
pin: None,
bound_profile: None,
};
let label = row.name.clone();
if k.is_none() {
seed = Some(row.clone());
}
if row.paired {
(ConsoleEntry::Library(row), Some(label))
(ConsoleEntry::Library(Box::new(row)), Some(label))
} else {
(ConsoleEntry::Home, Some(label))
}
}
None if fake => {
let row = fake_host_row();
(ConsoleEntry::Library(row), None)
(ConsoleEntry::Library(Box::new(row)), None)
}
None => (ConsoleEntry::Home, None),
};
@@ -207,6 +209,7 @@ pub fn run(target: Option<&str>) -> u8 {
launch,
title,
request_access,
profile,
} => {
let Some(pin) = trust::parse_hex32(&fp_hex) else {
// Connect (and request-access) pin the host's advertised fingerprint;
@@ -221,9 +224,11 @@ pub fn run(target: Option<&str>) -> u8 {
// have changed the defaults since the last stream, and the host may carry
// a profile binding. Console (and therefore Decky, which spawns this
// binary) honors bindings with no console-side work — the resolver is the
// same one `--connect` goes through. No one-off here: picking a profile is
// a desktop-shell affordance in v1, pinned cards are the console's.
let (settings, profile) = trust::effective_settings(&addr, port, None);
// same one `--connect` goes through. A pinned card's connect arrives as a
// one-off profile id; the resolver prefers it over the binding, and a
// dangling id falls back to the defaults without blocking the connect.
let (settings, profile) =
trust::effective_settings(&addr, port, profile.as_deref());
let mut params = session_params(
&settings,
profile.map(|p| p.name),
@@ -303,6 +308,8 @@ fn fake_host_row() -> HostRow {
can_wake: false,
last_used: None,
os: "linux/arch/steamos".into(),
pin: None,
bound_profile: None,
}
}
@@ -506,6 +513,38 @@ impl ServiceState {
ConsoleCmd::Probe => {
self.last_probe = Instant::now() - Duration::from_secs(60);
}
ConsoleCmd::SetPin {
key,
profile_id,
pin,
} => {
// Presentation only (design §5.2a): order = card order, appended at the
// end; never touches `profile_id` (the default binding). Idempotent, so
// a repeated press inside one refresh window can't double-pin.
let mut known = trust::KnownHosts::load();
let idx = known
.hosts
.iter()
.position(|h| !h.fp_hex.is_empty() && h.fp_hex == key)
.or_else(|| {
let (addr, port) = key.rsplit_once(':')?;
known.index_by_addr(addr, port.parse().ok()?)
});
let Some(h) = idx.and_then(|i| known.hosts.get_mut(i)) else {
tracing::warn!(%key, "pin toggle for an unknown host — ignoring");
return;
};
if pin && !h.pinned_profiles.contains(&profile_id) {
h.pinned_profiles.push(profile_id);
} else if !pin {
h.pinned_profiles.retain(|id| *id != profile_id);
}
if let Err(e) = known.save() {
tracing::warn!(error = %format!("{e:#}"), "saving known hosts");
}
// `run` refreshes the rows right after this drain, so the carousel and
// the pin screen reflect the new card within the same service pass.
}
}
}
@@ -544,12 +583,21 @@ impl ServiceState {
})
}
/// The console home's rows: saved hosts (most recent first), then
/// discovered-but-unsaved ones, then a still-uncovered `--browse` seed.
/// The console home's rows: saved hosts (most recent first) — each followed by its
/// pinned profile cards (design §5.2a) — then discovered-but-unsaved ones, then a
/// still-uncovered `--browse` seed.
fn rows(&self) -> Vec<HostRow> {
let known = trust::KnownHosts::load();
let catalog = pf_client_core::profiles::ProfilesFile::load();
let probed = self.probed.lock().unwrap();
let mut rows: Vec<HostRow> = known
let chip = |p: &pf_client_core::profiles::StreamProfile| pf_console_ui::ProfileChip {
id: p.id.clone(),
name: p.name.clone(),
accent: p.accent.clone(),
};
// Primary rows paired with their pinned cards, so the sort below can order hosts
// while every host's cards stay glued behind its primary tile.
let mut saved: Vec<(HostRow, Vec<HostRow>)> = known
.hosts
.iter()
.map(|h| {
@@ -563,8 +611,8 @@ impl ServiceState {
|| (d.addr == h.addr && d.port == h.port)
});
let online = advert.is_some() || probed.get(&key).copied().unwrap_or(false);
HostRow {
key,
let row = HostRow {
key: key.clone(),
name: host_display_name(&h.name, &h.addr),
addr: h.addr.clone(),
port: h.port,
@@ -581,10 +629,34 @@ impl ServiceState {
.filter(|d| !d.os.is_empty())
.map(|d| d.os.clone())
.unwrap_or_else(|| h.os.clone()),
}
pin: None,
bound_profile: h
.profile_id
.as_deref()
.and_then(|id| catalog.find_by_id(id))
.map(chip),
};
// A pinned card shares the primary tile's live state; its key rides the
// profile id behind a NUL (impossible in a fingerprint or `addr:port`),
// so cursor-follow and the wake path address the card itself.
let pins = h
.resolved_pins(&catalog)
.into_iter()
.map(|p| HostRow {
key: format!("{key}\0{}", p.id),
pin: Some(chip(p)),
bound_profile: None,
..row.clone()
})
.collect();
(row, pins)
})
.collect();
rows.sort_by(|a, b| b.last_used.cmp(&a.last_used).then(a.name.cmp(&b.name)));
saved.sort_by(|(a, _), (b, _)| b.last_used.cmp(&a.last_used).then(a.name.cmp(&b.name)));
let mut rows: Vec<HostRow> = saved
.into_iter()
.flat_map(|(row, pins)| std::iter::once(row).chain(pins))
.collect();
let mut extra: Vec<HostRow> = self
.discovered
@@ -612,6 +684,8 @@ impl ServiceState {
can_wake: false,
last_used: None,
os: d.os.clone(),
pin: None,
bound_profile: None,
})
.collect();
extra.sort_by(|a, b| a.name.cmp(&b.name));
+11 -6
View File
@@ -1045,13 +1045,18 @@ pub(crate) fn settings_page(
let ss = set_screen.clone();
button("Third-party licenses").on_click(move || ss.call(Screen::Licenses))
};
// The client log's home (%LOCALAPPDATA%\punktfunk\logs) — the file every "check the
// client log" message means, which until this row had no way in from the UI at all.
// The folder rather than the file so the rotated `.old` generation is in reach too.
// Best-effort, like the log itself: a missing dir or a failed spawn stays silent.
// The client log's home — the file every "check the client log" message means, which until
// this row had no way in from the UI at all. The folder rather than the file so the rotated
// `.old` generation is in reach too.
//
// `real_dir` (not the literal %LOCALAPPDATA% path) because Explorer lives outside our MSIX
// container: handed a path the package redirection keeps from ever existing, it silently
// opens the user's Documents folder instead of failing, which is precisely what this button
// shipped doing. The `is_dir` guard keeps that fallback unreachable — if the resolve ever
// comes back wrong, the click does nothing rather than landing somewhere misleading.
// Best-effort otherwise, like the log itself: a failed spawn stays silent.
let logs_button = button("Open log folder").on_click(|| {
if let Some(dir) = crate::logfile::log_dir() {
let _ = std::fs::create_dir_all(&dir);
if let Some(dir) = crate::logfile::real_dir().filter(|d| d.is_dir()) {
let _ = std::process::Command::new("explorer.exe").arg(&dir).spawn();
}
});
+131 -3
View File
@@ -10,6 +10,10 @@
//! Mirrors the host's convention (`%ProgramData%\punktfunk\logs`, size-capped): a file over
//! 10 MB is rotated to `.old` at the next client start, one generation kept. Everything is
//! best-effort — a missing/locked directory degrades to plain stderr, never a startup failure.
//!
//! Two paths, deliberately: [`log_dir`] is what we open files through, [`real_dir`] is where
//! they actually land. Under MSIX those differ, and only the second one is fit to show a user
//! or hand to Explorer.
use std::fs::{File, OpenOptions};
use std::io::{self, BufRead, Write};
@@ -21,14 +25,74 @@ const ROTATE_BYTES: u64 = 10 * 1024 * 1024;
static SINK: OnceLock<Option<Arc<Mutex<File>>>> = OnceLock::new();
/// The log directory — Settings ▸ About's "Open log folder" opens it in Explorer.
pub(crate) fn log_dir() -> Option<PathBuf> {
/// The log directory we WRITE through: `%LOCALAPPDATA%\punktfunk\logs`.
///
/// Correct to open files under, but NOT necessarily where the bytes land — see [`real_dir`].
/// Anything shown to a user or handed to another process wants that one instead.
fn log_dir() -> Option<PathBuf> {
Some(PathBuf::from(std::env::var_os("LOCALAPPDATA")?).join(r"punktfunk\logs"))
}
/// The log directory as it exists ON DISK — Settings ▸ About's "Open log folder" opens this in
/// Explorer, and [`path`] names it in the startup line and the failed-spawn banner.
///
/// The shipping client is a full-trust MSIX package, and Windows redirects a packaged app's
/// `%LOCALAPPDATA%` writes into its private `…\Packages\<family>\LocalCache\Local\…`. We create
/// and append through that redirection without ever seeing it, so [`log_dir`] is the right path
/// to WRITE to yet names a directory that never exists on disk. Explorer runs OUTSIDE the
/// container: it resolves the literal path, finds nothing, and silently falls back to the user's
/// Documents folder — which is exactly what "Open log folder" did in every packaged install, and
/// what the two "check <path>" messages pointed at. An unpackaged dev run creates the literal
/// directory for real, which is why this only ever showed up in the field.
///
/// Canonicalizing the directory we just created resolves through the redirection on a packaged
/// run and changes nothing on an unpackaged one, so there is no package identity to detect.
pub(crate) fn real_dir() -> Option<PathBuf> {
let dir = log_dir()?;
std::fs::create_dir_all(&dir).ok()?;
Some(std::fs::canonicalize(&dir).map_or(dir, strip_verbatim))
}
/// Undo the `\\?\` that [`std::fs::canonicalize`] always prefixes. Explorer refuses a verbatim
/// path — it would take the very same silent Documents fallback [`real_dir`] exists to avoid —
/// and it is noise in a line a user is meant to read and act on.
fn strip_verbatim(p: PathBuf) -> PathBuf {
use std::path::{Component, Prefix};
// Scoped so the borrow ends before the `return p` below can move it.
let head = match p.components().next() {
Some(Component::Prefix(pre)) => match pre.kind() {
// `\\?\C:\…` → `C:\…`
Prefix::VerbatimDisk(drive) => Some(PathBuf::from(format!(r"{}:\", drive as char))),
// `\\?\UNC\server\share\…` → `\\server\share\…` (a roaming profile on a share).
// Built through `OsString`, which appends verbatim — `PathBuf::push` would apply
// separator logic to the bare `\\` and mangle it.
Prefix::VerbatimUNC(server, share) => {
let mut unc = std::ffi::OsString::from(r"\\");
unc.push(server);
unc.push(r"\");
unc.push(share);
Some(PathBuf::from(unc))
}
// Already a plain path — nothing to undo.
_ => None,
},
_ => None,
};
let Some(mut out) = head else { return p };
// `skip(1)` drops the prefix; the `RootDir` that follows it is already in `head`.
out.extend(
p.components()
.skip(1)
.filter(|c| !matches!(c, Component::RootDir)),
);
out
}
/// The log file's path, for the "logs land here" startup line and the failed-spawn banner.
/// Resolved like [`real_dir`] — a path a user is told to check has to be the one on disk.
pub(crate) fn path() -> Option<PathBuf> {
Some(log_dir()?.join("client.log"))
Some(real_dir()?.join("client.log"))
}
/// Open (rotating first) and cache the sink. Called once at startup, before the tracing
@@ -97,3 +161,67 @@ pub(crate) fn forward_child_stderr(stderr: impl io::Read + Send + 'static) {
}
});
}
#[cfg(test)]
mod tests {
use super::*;
/// The shape `canonicalize` actually returns for a local profile. Explorer treats a `\\?\`
/// path as unresolvable and opens Documents instead, so the prefix has to come off.
#[test]
fn verbatim_disk_prefix_comes_off() {
let p = PathBuf::from(r"\\?\C:\Users\ada\AppData\Local\punktfunk\logs");
assert_eq!(
strip_verbatim(p),
PathBuf::from(r"C:\Users\ada\AppData\Local\punktfunk\logs")
);
}
/// The MSIX-redirected form is what the fix is for: same treatment, longer path.
#[test]
fn verbatim_disk_prefix_comes_off_for_the_package_local_cache() {
let p = PathBuf::from(
r"\\?\C:\Users\ada\AppData\Local\Packages\unom.Punktfunk_8wekyb3d8bbwe\LocalCache\Local\punktfunk\logs",
);
assert_eq!(
strip_verbatim(p),
PathBuf::from(
r"C:\Users\ada\AppData\Local\Packages\unom.Punktfunk_8wekyb3d8bbwe\LocalCache\Local\punktfunk\logs"
)
);
}
/// A roaming profile on a share canonicalizes to `\\?\UNC\…`; the plain UNC form is what
/// Explorer takes. `\\server\share` must survive intact — dropping either half, or letting
/// `PathBuf::push`'s separator logic at the bare `\\`, yields a path that opens nothing.
#[test]
fn verbatim_unc_prefix_becomes_a_plain_unc_path() {
let p = PathBuf::from(r"\\?\UNC\fileserv\profiles\ada\AppData\Local\punktfunk\logs");
assert_eq!(
strip_verbatim(p),
PathBuf::from(r"\\fileserv\profiles\ada\AppData\Local\punktfunk\logs")
);
}
/// An unpackaged dev run resolves to a path that was never verbatim — leave it alone.
#[test]
fn plain_path_is_untouched() {
let p = PathBuf::from(r"C:\Users\ada\AppData\Local\punktfunk\logs");
assert_eq!(strip_verbatim(p.clone()), p);
}
/// Whatever the run, the resolved directory is one Explorer can open: it exists, and it
/// carries no verbatim prefix. This is the button's actual precondition.
#[test]
fn real_dir_is_an_openable_directory() {
let Some(dir) = real_dir() else {
return; // no LOCALAPPDATA (not a normal user session) — nothing to assert
};
assert!(dir.is_dir(), "{} is not a directory", dir.display());
assert!(
!dir.to_string_lossy().starts_with(r"\\?\"),
"{} kept its verbatim prefix",
dir.display()
);
}
}
+50 -16
View File
@@ -168,9 +168,18 @@ struct PlayerData {
/// Drained chunk Vecs go back here for the decode side to refill (allocation pool).
recycle: SyncSender<Vec<f32>>,
ring: VecDeque<f32>,
primed: bool,
/// Shared ms-denominated de-jitter policy: prime depth, drift correction, de-prime
/// hysteresis. Replaces the old `3 × quantum` target, which meant 15 ms at a 5 ms graph
/// quantum and a silent 64 ms at a 20 ms one, and the `if ring.is_empty()` re-prime, where
/// one transient drain manufactured a whole target's worth of fresh silence.
policy: punktfunk_core::audio::JitterPolicy,
/// Interleaved channel count this stream was opened with (2/6/8).
channels: usize,
/// Diagnostics (WP0.3), logged ~every 10 s: the audio plane used to be entirely silent in a
/// client log, so a latency or dropout report had nothing to go on.
underruns: u64,
sheds: u64,
callbacks: u64,
}
fn pw_thread(
@@ -223,8 +232,14 @@ fn pw_thread(
rx: pcm_rx,
recycle: recycle_tx,
ring: VecDeque::new(),
primed: false,
policy: punktfunk_core::audio::JitterPolicy::new(
punktfunk_core::audio::JitterTuning::PIPEWIRE,
channels as u8,
),
channels,
underruns: 0,
sheds: 0,
callbacks: 0,
};
let _listener = stream
@@ -252,23 +267,29 @@ fn pw_thread(
let want_frames = data.data().map(|s| s.len() / stride).unwrap_or(0);
let want = want_frames * ud.channels;
// Adaptive jitter buffer (same shape as the host's virtual mic): prime to
// ~3 quanta, cap at ~1 quantum of slack beyond that, re-prime after a
// genuine drain.
let target = (3 * want).clamp(720 * ud.channels, 9600 * ud.channels);
while ud.ring.len() > target.max(want) + want {
ud.ring.pop_front();
}
if !ud.primed && ud.ring.len() >= target {
ud.primed = true;
// Shared de-jitter policy: prime depth in MILLISECONDS, smooth drift correction
// (a crossfaded 5 ms shed) so latency returns to target instead of ratcheting,
// and a hard cap as the backstop.
let step = ud.policy.step(ud.ring.len(), want);
if step.drop_front > 0 {
ud.sheds += 1;
punktfunk_core::audio::crossfade_drop(
&mut ud.ring,
step.drop_front,
step.crossfade,
);
}
let mut ran_short = false;
let n_frames = if let Some(slice) = data.data() {
for k in 0..want {
let s = if ud.primed {
ud.ring.pop_front().unwrap_or(0.0)
} else {
let s = if step.silence {
0.0
} else {
ud.ring.pop_front().unwrap_or_else(|| {
ran_short = true;
0.0
})
};
let off = k * 4;
slice[off..off + 4].copy_from_slice(&s.to_le_bytes());
@@ -277,8 +298,21 @@ fn pw_thread(
} else {
0
};
if ud.ring.is_empty() {
ud.primed = false;
// No-op while un-primed (the policy ignores it), so a deliberate priming silence
// is never miscounted as an underrun.
ud.policy.note_read(ran_short);
ud.underruns += u64::from(ran_short);
ud.callbacks += 1;
// ~10 s at a 5 ms quantum; the exact cadence does not matter, only that the
// plane stops being invisible.
if ud.callbacks % 2_000 == 0 {
tracing::debug!(
buffer_ms = ud.policy.avg_depth_ms(),
target_ms = ud.policy.target_ms(),
underruns = ud.underruns,
drift_sheds = ud.sheds,
"audio playback"
);
}
let chunk = data.chunk_mut();
*chunk.offset_mut() = 0;
+50 -29
View File
@@ -3,14 +3,15 @@
//!
//! The WASAPI twin of `audio.rs` (PipeWire) — same public surface (`AudioPlayer::spawn`/
//! `take_buffer`/`push`, `MicStreamer::spawn`), swapped in by lib.rs's `#[path]` so the
//! session pump compiles against one `crate::audio` on both OSes. Adapted from
//! `clients/windows/src/audio.rs` (which remains the WinUI shell's own copy until its
//! built-in streaming path is deleted).
//! session pump compiles against one `crate::audio` on both OSes. It began as a copy of the
//! WinUI shell's own audio path; that shell's built-in streaming path has since been deleted,
//! so this is now the only WASAPI client ring.
//!
//! Playback mirrors the host's virtual-mic producer's adaptive jitter buffer: the session
//! pump pushes 5 ms Opus-decoded chunks on the network clock; the WASAPI render thread
//! pulls whole event-driven quanta on the device clock. Prime to ~3 quanta before
//! producing, cap the ring so latency stays bounded, re-prime after a real drain.
//! Playback: the session pump pushes 5 ms Opus-decoded chunks on the network clock; the WASAPI
//! render thread pulls whole event-driven quanta on the device clock. The depth policy between
//! them is the SHARED `punktfunk_core::audio::JitterPolicy` (`JitterTuning::WASAPI`) — target in
//! milliseconds, crossfaded drift correction, de-prime hysteresis — so all four clients behave
//! the same way and none of them can ratchet latency upward.
//!
//! WASAPI objects are COM-apartment-bound and not `Send`, so they live on a dedicated
//! thread (the same discipline as the host's `wasapi_cap`); only the channels + stop flag
@@ -250,10 +251,20 @@ fn render_thread(
audio_client.start_stream().context("start render stream")?;
let _ = ready.send(Ok(()));
// Adaptive jitter buffer, in f32-byte units (same shape as the host's virtual mic).
let mut ring: VecDeque<u8> = VecDeque::new();
let mut primed = false;
// De-jitter ring, in interleaved f32 SAMPLES (it used to be raw bytes, which made the
// depth arithmetic byte-vs-sample and kept it from sharing the policy and the crossfade
// helper with the other three clients).
let mut ring: VecDeque<f32> = VecDeque::new();
// Shared ms-denominated policy: prime depth, crossfaded drift correction so latency
// returns to target instead of ratcheting, and de-prime hysteresis — the last replacing
// the old `if ring.is_empty()`, where a single transient drain manufactured a whole
// target's worth of fresh silence.
let mut policy = punktfunk_core::audio::JitterPolicy::new(
punktfunk_core::audio::JitterTuning::WASAPI,
channels,
);
let mut out = Vec::new(); // per-quantum scratch, reused across iterations
let (mut underruns, mut sheds, mut callbacks) = (0u64, 0u64, 0u64);
while !stop.load(Ordering::Relaxed) {
if h_event.wait_for_event(100).is_err() {
@@ -262,9 +273,7 @@ fn render_thread(
// Drain everything the pump has queued into the ring, returning each drained
// Vec to the pool (a full/closed pool drops it).
while let Ok(mut chunk) = pcm_rx.try_recv() {
for s in chunk.iter() {
ring.extend(s.to_le_bytes());
}
ring.extend(chunk.iter().copied());
chunk.clear();
let _ = recycle_tx.try_send(chunk);
}
@@ -274,28 +283,40 @@ fn render_thread(
if avail_frames == 0 {
continue;
}
let want_bytes = avail_frames * block_align;
let want = avail_frames * channels as usize;
// Prime to ~3 quanta; cap at ~1 quantum of slack beyond that; re-prime on drain.
let target = (3 * want_bytes).clamp(720 * block_align, 9600 * block_align);
let cap = target.max(want_bytes) + want_bytes;
if ring.len() > cap {
ring.drain(..ring.len() - cap);
}
if !primed && ring.len() >= target {
primed = true;
let step = policy.step(ring.len(), want);
if step.drop_front > 0 {
sheds += 1;
punktfunk_core::audio::crossfade_drop(&mut ring, step.drop_front, step.crossfade);
}
out.clear();
out.resize(want_bytes, 0);
if primed {
let n = ring.len().min(want_bytes);
for (dst, b) in out.iter_mut().zip(ring.drain(..n)) {
*dst = b;
out.resize(avail_frames * block_align, 0);
let mut ran_short = false;
if !step.silence {
// `out` is exactly `want` f32s wide (avail_frames × channels × 4 bytes).
for dst in out.chunks_exact_mut(4) {
let s = ring.pop_front().unwrap_or_else(|| {
ran_short = true;
0.0
});
dst.copy_from_slice(&s.to_le_bytes());
}
}
if ring.is_empty() {
primed = false;
// No-op while un-primed (the policy ignores it), so a deliberate priming silence is
// never miscounted as an underrun.
policy.note_read(ran_short);
underruns += u64::from(ran_short);
callbacks += 1;
if callbacks % 1_000 == 0 {
tracing::debug!(
buffer_ms = policy.avg_depth_ms(),
target_ms = policy.target_ms(),
underruns,
drift_sheds = sheds,
"audio playback"
);
}
render_client
.write_to_device(avail_frames, &out, None)
+186 -3
View File
@@ -4,6 +4,8 @@
//! cards and flip a saved host's online pip when its advert disappears.
use mdns_sd::{ServiceDaemon, ServiceEvent};
use std::collections::BTreeMap;
use std::time::{Duration, Instant};
#[derive(Clone, Debug)]
pub struct DiscoveredHost {
@@ -31,6 +33,19 @@ pub struct DiscoveredHost {
pub os: String,
}
impl DiscoveredHost {
/// The host's advertised stable id (mDNS TXT `id`), or `""` when it doesn't advertise one.
/// [`DiscoveredHost::key`] falls back to the mDNS fullname in that case, so the two being
/// equal is exactly the "no id" signal — read it through here rather than re-deriving it.
pub fn advertised_id(&self) -> &str {
if self.key == self.fullname {
""
} else {
&self.key
}
}
}
/// One discovery update for the UI's advert map.
pub enum DiscoveryEvent {
/// A host advert appeared or refreshed (new address, pairing flipped, …).
@@ -39,8 +54,8 @@ pub enum DiscoveryEvent {
Removed { fullname: String },
}
/// Browse continuously for the app's lifetime. The thread exits when the receiver is
/// dropped (the send fails) or the daemon dies.
/// Browse continuously. The worker exits when the returned receiver is dropped, or when the
/// daemon dies — checked on a tick, so it stops even on a LAN where no advert ever arrives.
pub fn browse() -> async_channel::Receiver<DiscoveryEvent> {
let (tx, rx) = async_channel::unbounded();
std::thread::Builder::new()
@@ -60,7 +75,24 @@ pub fn browse() -> async_channel::Receiver<DiscoveryEvent> {
return;
}
};
while let Ok(event) = receiver.recv() {
// Polled rather than blocked on: the worker has to notice that its consumer went
// away even when NOTHING is arriving, which is the normal state of a LAN with no
// hosts on it. A plain `recv()` parks forever there, and the ignored-event arm below
// never touches `tx` — so a bounded consumer like `discover_for` would leak this
// thread and its daemon (another thread, and a socket bound to :5353) on every call.
loop {
// Checked at the TOP so it also covers the arms below that `continue` without
// ever touching `tx` — the ignored event kinds, and an advert with no IPv4
// address. Those are the paths that would otherwise keep this thread alive with
// nobody to send to.
if tx.is_closed() {
break;
}
let event = match receiver.recv_timeout(Duration::from_millis(250)) {
Ok(event) => event,
Err(_) if receiver.is_disconnected() => break,
Err(_) => continue,
};
let update = match event {
ServiceEvent::ServiceResolved(info) => {
let props = info.get_properties();
@@ -117,3 +149,154 @@ pub fn browse() -> async_channel::Receiver<DiscoveryEvent> {
.expect("spawn mdns thread");
rx
}
/// The advert map one browse window folded down to. Kept separate from [`discover_for`] so the
/// fold — which is where dedupe and removal actually live — is testable without a network.
type Adverts = BTreeMap<String, DiscoveredHost>;
/// Apply one event to the map. A refreshed advert WINS over the one already there (it carries
/// the newer address — a host that changed DHCP lease re-announces), and a removal drops
/// whichever entry that mDNS fullname produced, whatever it was keyed under.
fn fold(adverts: &mut Adverts, event: DiscoveryEvent) {
match event {
DiscoveryEvent::Resolved(host) => {
adverts.insert(host.key.clone(), host);
}
DiscoveryEvent::Removed { fullname } => {
adverts.retain(|_, h| h.fullname != fullname);
}
}
}
/// Browse for `timeout`, then return what answered — deduped by `key`, address-sorted.
///
/// Blocking; intended for one-shot consumers (the CLI's `discover` verb, a plugin backend that
/// wants one bounded call rather than a stream). The streaming [`browse`] stays the UI's door:
/// a live hosts page wants adverts as they land, not a snapshot taken `timeout` after it opened.
pub fn discover_for(timeout: Duration) -> Vec<DiscoveredHost> {
let rx = browse();
let deadline = Instant::now() + timeout;
let mut adverts = Adverts::new();
while Instant::now() < deadline {
while let Ok(event) = rx.try_recv() {
fold(&mut adverts, event);
}
// A short tick rather than a blocking recv with a deadline: `async_channel`'s blocking
// receive has no timeout, and the whole point of this call is that it is bounded.
std::thread::sleep(Duration::from_millis(50).min(timeout));
}
while let Ok(event) = rx.try_recv() {
fold(&mut adverts, event);
}
// Dropping the receiver is what stops the worker — it polls for that, so this holds even
// when nothing is advertising. Without it a one-shot consumer would leak a browse per call.
drop(rx);
sorted(adverts)
}
/// The map as the list a caller gets: sorted by address, then port. IPv4 is compared
/// NUMERICALLY (a lexical sort puts `.10` before `.9`, which reads as scrambled in a host list).
fn sorted(adverts: Adverts) -> Vec<DiscoveredHost> {
let mut hosts: Vec<DiscoveredHost> = adverts.into_values().collect();
hosts.sort_by_key(|h| {
(
h.addr.parse::<std::net::Ipv4Addr>().ok().map(u32::from),
h.addr.clone(),
h.port,
)
});
hosts
}
#[cfg(test)]
mod tests {
use super::*;
fn host(key: &str, fullname: &str, addr: &str) -> DiscoveredHost {
DiscoveredHost {
key: key.into(),
fullname: fullname.into(),
name: fullname.split('.').next().unwrap_or("?").into(),
addr: addr.into(),
port: 9777,
fp_hex: "aa".into(),
pair: "required".into(),
mgmt_port: Some(47990),
mac: vec![],
os: String::new(),
}
}
/// Two adverts for the same host collapse to one row, and the LATER one wins — that is how
/// a host that moved to a new address stops being listed at the stale one.
#[test]
fn refreshed_advert_supersedes_the_earlier_one() {
let mut adverts = Adverts::new();
fold(
&mut adverts,
DiscoveryEvent::Resolved(host("id-1", "desk._punktfunk._udp.local.", "192.168.1.9")),
);
fold(
&mut adverts,
DiscoveryEvent::Resolved(host("id-1", "desk._punktfunk._udp.local.", "192.168.1.20")),
);
let out = sorted(adverts);
assert_eq!(out.len(), 1, "same key must not render twice");
assert_eq!(out[0].addr, "192.168.1.20", "the newer address wins");
}
/// A host that goes away during the browse window is not in the answer.
#[test]
fn removal_drops_the_advert_it_names() {
let mut adverts = Adverts::new();
fold(
&mut adverts,
DiscoveryEvent::Resolved(host("id-1", "desk._punktfunk._udp.local.", "192.168.1.9")),
);
fold(
&mut adverts,
DiscoveryEvent::Resolved(host("id-2", "tv._punktfunk._udp.local.", "192.168.1.10")),
);
fold(
&mut adverts,
DiscoveryEvent::Removed {
fullname: "desk._punktfunk._udp.local.".into(),
},
);
let out = sorted(adverts);
assert_eq!(out.len(), 1);
assert_eq!(out[0].key, "id-2");
}
/// A host with no `id` TXT is keyed by its fullname — and must not then report that
/// fullname as an id, which would send a caller launching against a nonexistent reference.
#[test]
fn advertised_id_is_empty_without_the_txt() {
let named = host("id-1", "desk._punktfunk._udp.local.", "10.0.0.1");
assert_eq!(named.advertised_id(), "id-1");
let anonymous = host(
"desk._punktfunk._udp.local.",
"desk._punktfunk._udp.local.",
"10.0.0.1",
);
assert_eq!(anonymous.advertised_id(), "");
}
/// Addresses sort the way a person reads them, not the way strings compare.
#[test]
fn addresses_sort_numerically() {
let mut adverts = Adverts::new();
for (i, addr) in ["192.168.1.20", "192.168.1.9", "192.168.1.100"]
.into_iter()
.enumerate()
{
fold(
&mut adverts,
DiscoveryEvent::Resolved(host(&format!("id-{i}"), &format!("h{i}."), addr)),
);
}
let out = sorted(adverts);
let addrs: Vec<&str> = out.iter().map(|h| h.addr.as_str()).collect();
assert_eq!(addrs, ["192.168.1.9", "192.168.1.20", "192.168.1.100"]);
}
}
+17 -5
View File
@@ -232,17 +232,29 @@ impl KnownHosts {
/// A read-only config dir just keeps re-minting in memory, which harms nothing: no lookup
/// is keyed by the id yet (design §4.5).
pub fn load() -> KnownHosts {
let mut k: KnownHosts = Self::path()
.and_then(|p| Ok(std::fs::read_to_string(p)?))
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
let mut k = Self::read();
if k.mint_missing_ids() {
let _ = k.save();
}
k
}
/// The store exactly as it is on disk — no mint, and so no write.
///
/// For a consumer that only needs to LOOK at the records (annotating a discovery result
/// against them, say) and never dials one by id. [`KnownHosts::load`]'s mint is a write, and
/// two processes started together against a pre-mint store will each mint a *different* id
/// for the same record and race to save it — after which whichever one already handed its
/// ids to a caller has handed out references that no longer resolve. A read that stays a
/// read cannot take part in that.
pub fn read() -> KnownHosts {
Self::path()
.and_then(|p| Ok(std::fs::read_to_string(p)?))
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
/// Give every record still missing one a stable id; returns true if anything changed
/// (i.e. whether this needs persisting). Idempotent — a store that has been through it
/// once is left byte-identical.
+3 -1
View File
@@ -35,7 +35,9 @@ mod widgets;
#[cfg(any(target_os = "linux", windows))]
pub use library::{LibraryGame, LibraryPhase, LibraryShared};
#[cfg(any(target_os = "linux", windows))]
pub use model::{ConsoleBus, ConsoleCmd, ConsoleShared, HostRow, PairPhase, WakeStatus};
pub use model::{
ConsoleBus, ConsoleCmd, ConsoleShared, HostRow, PairPhase, ProfileChip, WakeStatus,
};
#[cfg(any(target_os = "linux", windows))]
pub use shell::ConsoleOptions;
#[cfg(any(target_os = "linux", windows))]
+33 -2
View File
@@ -7,9 +7,20 @@
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
/// A settings profile as the console shows it (design client-settings-profiles.md §5.2a):
/// the resolved name and accent of a catalog entry, keyed by its stable id. The service
/// thread resolves these against the catalog; the shell never opens the profiles file.
#[derive(Clone, Debug, PartialEq)]
pub struct ProfileChip {
pub id: String,
pub name: String,
/// `#RRGGBB`, the catalog's optional tint for pinned cards.
pub accent: Option<String>,
}
/// One row on the console home carousel — a saved host, a discovered-but-unsaved one,
/// or (client-side) the trailing Add Host tile. Fully resolved by the service thread;
/// the shell renders it verbatim.
/// a pinned profile card, or (client-side) the trailing Add Host tile. Fully resolved by
/// the service thread; the shell renders it verbatim.
#[derive(Clone, Debug, PartialEq)]
pub struct HostRow {
/// Stable identity across refreshes: the pinned fingerprint when known, else
@@ -35,6 +46,14 @@ pub struct HostRow {
/// future tile OS glyph. Empty = unknown (older host). Plumbed now; drawing is a
/// follow-up — the Skia glyph set doesn't exist yet.
pub os: String,
/// `Some` = this row is a pinned profile card (§5.2a): a shortcut tile rendered right
/// after its host's primary tile, sharing its live state, that connects with THIS
/// profile. `None` = the host's primary tile.
pub pin: Option<ProfileChip>,
/// The primary tile's default-profile chip: the profile bound as this host's default
/// (`KnownHost::profile_id`), resolved, so the tile can say what a plain A-press uses.
/// Always `None` on pinned rows — there the profile IS `pin`.
pub bound_profile: Option<ProfileChip>,
}
/// The pairing ceremony's observable state (one at a time — the ceremony is modal).
@@ -143,6 +162,16 @@ pub enum ConsoleCmd {
CancelWake,
/// Sweep reachability now (the home screen refreshes its presence pips).
Probe,
/// Pin (or unpin) a profile as an extra connect card on a saved host
/// (`KnownHost::pinned_profiles`, design §5.2a). `key` is the HOST row's key
/// (fingerprint or `addr:port`); presentation only — never touches the host's
/// default binding or the profile itself. Idempotent: re-pinning a pinned profile
/// (or unpinning an absent one) is a no-op.
SetPin {
key: String,
profile_id: String,
pin: bool,
},
}
/// The overlay→binary command queue. A plain deque under the same locking discipline as
@@ -184,6 +213,8 @@ mod tests {
can_wake: false,
last_used: None,
os: String::new(),
pin: None,
bound_profile: None,
};
shared.set_hosts(vec![row.clone()]);
let g1 = shared.hosts_gen();
+9
View File
@@ -7,6 +7,7 @@ pub(crate) mod add_host;
pub(crate) mod home;
pub(crate) mod library;
pub(crate) mod pair;
pub(crate) mod pin_hosts;
pub(crate) mod settings;
use crate::glyphs::Hint;
@@ -57,6 +58,9 @@ pub(crate) struct ConnectIntent {
/// shell shows a "waiting for approval" takeover instead of "connecting", and the
/// binary parks on a long budget and persists the host as paired once let in.
pub request_access: bool,
/// One-off settings-profile id for this launch (a pinned card's connect); `None`
/// keeps the host's default binding.
pub profile: Option<String>,
}
pub(crate) enum Nav {
@@ -91,6 +95,7 @@ pub(crate) enum Screen {
Settings(settings::SettingsScreen),
AddHost(add_host::AddHostScreen),
Pair(pair::PairScreen),
PinHosts(pin_hosts::PinHostsScreen),
}
impl Screen {
@@ -106,6 +111,7 @@ impl Screen {
Screen::Settings(s) => s.menu(ev, ctx, fx),
Screen::AddHost(s) => s.menu(ev, ctx, fx),
Screen::Pair(s) => s.menu(ev, ctx, fx),
Screen::PinHosts(s) => s.menu(ev, ctx, fx),
}
}
@@ -152,6 +158,7 @@ impl Screen {
Screen::Settings(_) => "Settings".into(),
Screen::AddHost(_) => "Add Host".into(),
Screen::Pair(s) => format!("Pair with {}", s.host_name()),
Screen::PinHosts(s) => format!("Pin \u{201c}{}\u{201d}", s.profile_name()),
}
}
@@ -162,6 +169,7 @@ impl Screen {
Screen::Settings(s) => s.hints(ctx),
Screen::AddHost(s) => s.hints(ctx),
Screen::Pair(s) => s.hints(ctx),
Screen::PinHosts(s) => s.hints(ctx),
}
}
@@ -183,6 +191,7 @@ impl Screen {
Screen::Settings(s) => s.render(canvas, rect, k, dt, fonts, ctx),
Screen::AddHost(s) => s.render(canvas, rect, k, dt, fonts, ctx),
Screen::Pair(s) => s.render(canvas, rect, k, dt, fonts, ctx),
Screen::PinHosts(s) => s.render(canvas, rect, k, dt, fonts, ctx),
}
}
}
+117 -11
View File
@@ -94,13 +94,19 @@ impl HomeScreen {
Some(h) => {
// Dial-first even when the presence pips say offline — a
// routed/VPN host is mDNS-blind and probe-shy but dials fine.
// A pinned card connects with ITS profile (one-off, §5.2a);
// the primary tile keeps the host's default binding.
fx.connect = Some(ConnectIntent {
addr: h.addr.clone(),
port: h.port,
fp_hex: h.fp_hex.clone(),
launch: None,
title: h.name.clone(),
title: match &h.pin {
Some(p) => format!("{} · {}", h.name, p.name),
None => h.name.clone(),
},
request_access: false,
profile: h.pin.as_ref().map(|p| p.id.clone()),
});
}
}
@@ -295,16 +301,62 @@ fn draw_host_tile(canvas: &Canvas, fonts: &Fonts, h: &HostRow, rect: Rect, k: f6
let max_w = f64::from(rect.width()) - 2.0 * pad;
let sub_base = f64::from(rect.bottom) - pad;
fonts.draw_clipped(
canvas,
&format!("{}:{}", h.addr, h.port),
l,
sub_base,
W::Regular,
13.0 * k,
white(0.55),
max_w,
);
match (&h.pin, &h.bound_profile) {
// A pinned card: the profile name IS the subtitle, tinted with its accent —
// the card's whole point is "this host, with these settings" (§5.2a).
(Some(p), _) => {
fonts.draw_clipped(
canvas,
&p.name,
l,
sub_base,
W::SemiBold,
13.0 * k,
accent_color(p.accent.as_deref()),
max_w,
);
}
// The primary tile says which profile a plain press uses, after the address.
(None, Some(b)) => {
let addr = format!("{}:{}", h.addr, h.port);
let addr_w = f64::from(fonts.measure(&addr, W::Regular, 13.0 * k));
fonts.draw_clipped(
canvas,
&addr,
l,
sub_base,
W::Regular,
13.0 * k,
white(0.55),
max_w,
);
let x = l + addr_w + 8.0 * k;
if x < l + max_w {
fonts.draw_clipped(
canvas,
&format!("· {}", b.name),
x,
sub_base,
W::SemiBold,
13.0 * k,
accent_color(b.accent.as_deref()),
l + max_w - x,
);
}
}
(None, None) => {
fonts.draw_clipped(
canvas,
&format!("{}:{}", h.addr, h.port),
l,
sub_base,
W::Regular,
13.0 * k,
white(0.55),
max_w,
);
}
}
fonts.draw_clipped(
canvas,
&h.name,
@@ -317,6 +369,26 @@ fn draw_host_tile(canvas: &Canvas, fonts: &Fonts, h: &HostRow, rect: Rect, k: f6
);
}
/// A profile's `#RRGGBB` accent as a color, defaulting to the brand tint. Parsed
/// leniently — a malformed accent (hand-edited catalog) falls back rather than erroring.
fn accent_color(accent: Option<&str>) -> skia_safe::Color4f {
let Some(hex) = accent
.and_then(|a| a.strip_prefix('#'))
.filter(|h| h.len() == 6)
else {
return BRAND;
};
let Ok(v) = u32::from_str_radix(hex, 16) else {
return BRAND;
};
skia_safe::Color4f::new(
((v >> 16) & 0xff) as f32 / 255.0,
((v >> 8) & 0xff) as f32 / 255.0,
(v & 0xff) as f32 / 255.0,
1.0,
)
}
fn draw_add_tile(canvas: &Canvas, fonts: &Fonts, rect: Rect, k: f64) {
crate::theme::panel(
canvas,
@@ -484,6 +556,8 @@ mod tests {
can_wake,
last_used: None,
os: String::new(),
pin: None,
bound_profile: None,
}
}
@@ -551,6 +625,38 @@ mod tests {
));
}
/// A pinned card's A-press is a connect WITH its profile (one-off), titled so the
/// connecting takeover says which settings are coming (§5.2a).
#[test]
fn pinned_card_connects_with_its_profile() {
let mut settings = ctx_settings();
let mut pinned = host("ab\0p1", true, true, false);
pinned.name = "Tower".into();
pinned.pin = Some(crate::model::ProfileChip {
id: "p1".into(),
name: "Work".into(),
accent: None,
});
let hosts = [pinned];
let pads: Vec<pf_client_core::gamepad::PadInfo> = Vec::new();
let library = crate::library::LibraryShared::default();
let mut ctx = Ctx {
hosts: &hosts,
library: &library,
settings: &mut settings,
pads: &pads,
deck: false,
device_name: "test",
t: 0.0,
};
let mut s = HomeScreen::new();
let mut fx = Outbox::default();
s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
let intent = fx.connect.expect("a pinned card connects");
assert_eq!(intent.profile.as_deref(), Some("p1"));
assert_eq!(intent.title, "Tower · Work");
}
#[test]
fn add_tile_is_always_last() {
let mut settings = ctx_settings();
@@ -120,6 +120,8 @@ impl LibraryScreen {
launch: Some(g.id.clone()),
title: g.title.clone(),
request_access: false,
// Game launches follow the host's default binding.
profile: None,
});
Some(MenuPulse::Confirm)
}
+3
View File
@@ -221,6 +221,7 @@ impl PairScreen {
launch: None,
title: self.host_name.clone(),
request_access: true,
profile: None,
});
fx.pop();
}
@@ -430,6 +431,8 @@ mod tests {
can_wake: false,
last_used: None,
os: String::new(),
pin: None,
bound_profile: None,
}
}
@@ -0,0 +1,266 @@
//! "Pin “Work”" — choose which saved hosts show a profile as an extra connect card
//! (design/client-settings-profiles.md §5.2a), reached from the settings screen's
//! Profiles section. One toggle row per saved host; a toggle rides
//! [`ConsoleCmd::SetPin`] to the binary, which persists `KnownHost::pinned_profiles`
//! and refreshes the rows — the row's shown state follows the model, so what the list
//! says is always what the store holds (and what Decky's host list will render).
use crate::glyphs::{Hint, HintKey};
use crate::model::ConsoleCmd;
use crate::screens::{Ctx, Outbox};
use crate::theme::{Fonts, DIM, W};
use crate::widgets::{ListMsg, MenuList, RowSpec};
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
use skia_safe::{Canvas, Rect};
pub(crate) struct PinHostsScreen {
profile_id: String,
profile_name: String,
list: MenuList,
}
/// The toggle rows' domain: every SAVED host, primary tiles only (a pinned card is the
/// OUTPUT of this screen, not a row in it), in the model's carousel order.
fn host_indices(ctx: &Ctx) -> Vec<usize> {
ctx.hosts
.iter()
.enumerate()
.filter(|(_, h)| h.saved && h.pin.is_none())
.map(|(i, _)| i)
.collect()
}
impl PinHostsScreen {
pub(crate) fn new(profile_id: String, profile_name: String) -> PinHostsScreen {
PinHostsScreen {
profile_id,
profile_name,
list: MenuList::new(),
}
}
pub(crate) fn profile_name(&self) -> &str {
&self.profile_name
}
/// Is this profile currently pinned on the host at `ctx.hosts[host_idx]`? Read from
/// the model — the pinned card's row IS the state, so the toggle can never disagree
/// with what the carousel shows.
fn pinned(&self, ctx: &Ctx, host_idx: usize) -> bool {
let host = &ctx.hosts[host_idx];
ctx.hosts.iter().any(|r| {
r.addr == host.addr
&& r.port == host.port
&& r.pin.as_ref().is_some_and(|p| p.id == self.profile_id)
})
}
pub(crate) fn menu(
&mut self,
ev: MenuEvent,
ctx: &mut Ctx,
fx: &mut Outbox,
) -> Option<MenuPulse> {
if ev == MenuEvent::Back {
fx.pop();
return None;
}
let indices = host_indices(ctx);
let (msg, pulse) = self.list.menu(ev, indices.len());
let Some(&host_idx) = indices.get(self.list.cursor) else {
return pulse;
};
// Toggle semantics shared with the settings rows: left = unpin, right = pin,
// A flips; asking for the state it's already in is a boundary thud.
let target = match msg {
ListMsg::Adjust(delta) => delta > 0,
ListMsg::Activate => !self.pinned(ctx, host_idx),
ListMsg::None => return pulse,
};
if self.pinned(ctx, host_idx) == target {
return Some(MenuPulse::Boundary);
}
fx.cmds.push(ConsoleCmd::SetPin {
key: ctx.hosts[host_idx].key.clone(),
profile_id: self.profile_id.clone(),
pin: target,
});
Some(MenuPulse::Move)
}
pub(crate) fn hints(&self, ctx: &Ctx) -> Vec<Hint> {
if host_indices(ctx).is_empty() {
return vec![Hint::new(HintKey::Back, "Done")];
}
vec![
Hint::new(HintKey::Confirm, "Pin / Unpin"),
Hint::new(HintKey::Back, "Done"),
]
}
pub(crate) fn render(
&mut self,
canvas: &Canvas,
rect: Rect,
k: f64,
dt: f64,
fonts: &Fonts,
ctx: &mut Ctx,
) {
let indices = host_indices(ctx);
let cx = f64::from(rect.left) + f64::from(rect.width()) / 2.0;
if indices.is_empty() {
fonts.centered(
canvas,
"No saved hosts yet — pair with a host first, then pin this profile to it.",
W::Regular,
14.0 * k,
DIM,
cx,
f64::from(rect.top) + f64::from(rect.height()) / 2.0,
f64::from(rect.width()) * 0.7,
);
return;
}
// The explainer band under the list, like the settings screen's detail text.
let detail_h = 34.0 * k;
let list_rect = Rect::from_ltrb(
rect.left,
rect.top,
rect.right,
rect.bottom - detail_h as f32,
);
let rows: Vec<RowSpec> = indices
.iter()
.map(|&i| {
let h = &ctx.hosts[i];
let pinned = self.pinned(ctx, i);
RowSpec {
header: None,
label: h.name.clone(),
value: Some(if pinned {
"Pinned".into()
} else {
"Off".into()
}),
value_dim: !pinned,
caret: false,
adjustable: true,
enabled: true,
}
})
.collect();
self.list
.render(canvas, list_rect, &rows, fonts, k, dt, true);
fonts.centered(
canvas,
"A pinned profile appears as its own card on the host — one press connects with it.",
W::Regular,
13.0 * k,
DIM,
cx,
f64::from(rect.bottom) - detail_h + 6.0 * k,
f64::from(rect.width()) * 0.8,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{HostRow, ProfileChip};
use crate::screens::Outbox;
use pf_client_core::trust::Settings;
fn host(key: &str, saved: bool, pin: Option<&str>) -> HostRow {
HostRow {
key: key.into(),
name: key.into(),
addr: "10.0.0.9".into(),
port: 9777,
fp_hex: key.into(),
paired: true,
saved,
online: true,
mgmt_port: 47990,
can_wake: false,
last_used: None,
os: String::new(),
pin: pin.map(|id| ProfileChip {
id: id.into(),
name: "Work".into(),
accent: None,
}),
bound_profile: None,
}
}
#[test]
fn toggling_sends_set_pin_for_the_focused_host() {
let mut settings = Settings::default();
let pads = Vec::new();
let library = crate::library::LibraryShared::default();
let hosts = [host("aa", true, None), host("bb", true, None)];
let mut ctx = Ctx {
hosts: &hosts,
library: &library,
settings: &mut settings,
pads: &pads,
deck: false,
device_name: "t",
t: 0.0,
};
let mut s = PinHostsScreen::new("p1".into(), "Work".into());
let mut fx = Outbox::default();
s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
assert_eq!(
fx.cmds,
vec![ConsoleCmd::SetPin {
key: "aa".into(),
profile_id: "p1".into(),
pin: true,
}]
);
// Left on an unpinned host = already off = boundary, no command.
let mut fx = Outbox::default();
let pulse = s.menu(
MenuEvent::Move(pf_client_core::gamepad::MenuDir::Left),
&mut ctx,
&mut fx,
);
assert!(fx.cmds.is_empty());
assert!(matches!(pulse, Some(MenuPulse::Boundary)));
}
#[test]
fn state_reads_from_the_models_pinned_rows() {
let mut settings = Settings::default();
let pads = Vec::new();
let library = crate::library::LibraryShared::default();
// Host "aa" already carries a pinned card for p1; its primary row toggles OFF.
let hosts = [host("aa", true, None), host("aa\0p1", true, Some("p1"))];
let mut ctx = Ctx {
hosts: &hosts,
library: &library,
settings: &mut settings,
pads: &pads,
deck: false,
device_name: "t",
t: 0.0,
};
let mut s = PinHostsScreen::new("p1".into(), "Work".into());
// Only the primary row is a toggle row.
assert_eq!(host_indices(&ctx).len(), 1);
let mut fx = Outbox::default();
s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
assert_eq!(
fx.cmds,
vec![ConsoleCmd::SetPin {
key: "aa".into(),
profile_id: "p1".into(),
pin: false,
}]
);
}
}
+237 -19
View File
@@ -6,7 +6,7 @@
//! read the same file, so values round-trip freely.
use crate::glyphs::{Hint, HintKey};
use crate::screens::{Ctx, Outbox};
use crate::screens::{Ctx, Outbox, Screen};
use crate::theme::{Fonts, DIM, W};
use crate::widgets::{ListMsg, MenuList, RowSpec};
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
@@ -15,8 +15,13 @@ use skia_safe::{Canvas, Rect};
/// Stable row identity — adjust/activate dispatch by id so nothing acts on a stale
/// index when the pad list under the "Use controller" row churns.
#[derive(Clone, Copy, PartialEq, Eq)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum RowId {
/// A catalog profile (index into [`SettingsScreen::profiles`]) — activating opens
/// the pin-to-hosts screen. The console never edits profiles (design §5.4).
Profile(usize),
/// The Profiles section's placeholder while the catalog is empty.
NoProfiles,
Resolution,
Refresh,
RenderScale,
@@ -50,7 +55,8 @@ enum RowId {
// Gaming Mode, so a field it omits is simply unreachable there (render scale, 4:4:4,
// scroll/shortcut behavior, fullscreen-on-stream, auto-wake, the library toggle and echo
// cancellation all were). Still deliberately smaller than the desktop dialogs — device
// pickers (GPU/speaker/mic) and the profile catalog stay desktop-only.
// pickers (GPU/speaker/mic) stay desktop-only, and profiles are pinnable here (the
// trailing Profiles section) but created and edited only in the desktop app (design §5.4).
const ROWS: [RowId; 27] = [
RowId::Resolution,
RowId::Refresh,
@@ -149,15 +155,42 @@ const PAD_TYPES: [(&str, &str); 6] = [
pub(crate) struct SettingsScreen {
list: MenuList,
/// The profile catalog's `(id, name)` pairs, loaded once at construction — the console
/// can't create profiles (design §5.4: the desktop app does), so the list is stable
/// for the screen's lifetime.
profiles: Vec<(String, String)>,
}
impl SettingsScreen {
pub(crate) fn new() -> SettingsScreen {
Self::with_profiles(
pf_client_core::profiles::ProfilesFile::load()
.profiles
.into_iter()
.map(|p| (p.id, p.name))
.collect(),
)
}
fn with_profiles(profiles: Vec<(String, String)>) -> SettingsScreen {
SettingsScreen {
list: MenuList::new(),
profiles,
}
}
/// The full row list: the fixed settings rows, then the Profiles section — one row
/// per catalog profile, or the explainer placeholder while there are none.
fn row_ids(&self) -> Vec<RowId> {
let mut ids = ROWS.to_vec();
if self.profiles.is_empty() {
ids.push(RowId::NoProfiles);
} else {
ids.extend((0..self.profiles.len()).map(RowId::Profile));
}
ids
}
pub(crate) fn menu(
&mut self,
ev: MenuEvent,
@@ -168,7 +201,31 @@ impl SettingsScreen {
fx.pop();
return None;
}
let (msg, pulse) = self.list.menu(ev, ROWS.len());
let ids = self.row_ids();
let (msg, pulse) = self.list.menu(ev, ids.len());
// The Profiles rows navigate instead of editing the settings file.
match ids[self.list.cursor] {
RowId::Profile(i) => {
return match msg {
ListMsg::Activate => {
let (id, name) = self.profiles[i].clone();
fx.push(Screen::PinHosts(super::pin_hosts::PinHostsScreen::new(
id, name,
)));
pulse
}
ListMsg::Adjust(_) => Some(MenuPulse::Boundary),
ListMsg::None => pulse,
}
}
RowId::NoProfiles => {
return match msg {
ListMsg::Adjust(_) | ListMsg::Activate => Some(MenuPulse::Boundary),
ListMsg::None => pulse,
}
}
_ => {}
}
// Rebase the shell-lifetime snapshot on the file before an adjust-then-save: this
// screen is one of the settings file's several whole-file writers (profiles.rs
// documents the no-merge debt), and adjusting a stale snapshot would silently
@@ -180,7 +237,7 @@ impl SettingsScreen {
}
match msg {
ListMsg::Adjust(delta) => {
let changed = adjust(ROWS[self.list.cursor], delta, false, ctx);
let changed = adjust(ids[self.list.cursor], delta, false, ctx);
if changed {
ctx.settings.save();
Some(MenuPulse::Move)
@@ -190,7 +247,7 @@ impl SettingsScreen {
}
ListMsg::Activate => {
// A cycles forward WRAPPING, so every option is reachable one-handed.
if adjust(ROWS[self.list.cursor], 1, true, ctx) {
if adjust(ids[self.list.cursor], 1, true, ctx) {
ctx.settings.save();
}
pulse
@@ -200,11 +257,18 @@ impl SettingsScreen {
}
pub(crate) fn hints(&self, _ctx: &Ctx) -> Vec<Hint> {
vec![
Hint::new(HintKey::Adjust, "Adjust"),
Hint::new(HintKey::Confirm, "Change"),
Hint::new(HintKey::Back, "Done"),
]
match self.row_ids()[self.list.cursor] {
RowId::Profile(_) => vec![
Hint::new(HintKey::Confirm, "Pin to hosts…"),
Hint::new(HintKey::Back, "Done"),
],
RowId::NoProfiles => vec![Hint::new(HintKey::Back, "Done")],
_ => vec![
Hint::new(HintKey::Adjust, "Adjust"),
Hint::new(HintKey::Confirm, "Change"),
Hint::new(HintKey::Back, "Done"),
],
}
}
pub(crate) fn render(
@@ -224,10 +288,14 @@ impl SettingsScreen {
rect.right,
rect.bottom - detail_h as f32,
);
let rows: Vec<RowSpec> = ROWS.iter().map(|id| row_spec(*id, ctx)).collect();
let ids = self.row_ids();
let rows: Vec<RowSpec> = ids
.iter()
.map(|id| row_spec(*id, ctx, &self.profiles))
.collect();
self.list
.render(canvas, list_rect, &rows, fonts, k, dt, true);
let detail = detail(ROWS[self.list.cursor]);
let detail = detail(ids[self.list.cursor]);
fonts.centered(
canvas,
detail,
@@ -241,7 +309,38 @@ impl SettingsScreen {
}
}
fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
// The Profiles section: name + how many hosts pin it (counted from the live rows, so
// it reflects what the carousel shows). Read-only here beyond opening the pin screen.
match id {
RowId::Profile(i) => {
let (pid, name) = &profiles[i];
let pins = ctx
.hosts
.iter()
.filter(|h| h.pin.as_ref().is_some_and(|p| &p.id == pid))
.count();
return RowSpec {
header: (i == 0).then_some("Profiles"),
label: name.clone(),
value: Some(match pins {
0 => "Not pinned".into(),
1 => "Pinned to 1 host".into(),
n => format!("Pinned to {n} hosts"),
}),
value_dim: pins == 0,
caret: false,
adjustable: false,
enabled: true,
};
}
RowId::NoProfiles => {
let mut row = RowSpec::action("No profiles yet", false);
row.header = Some("Profiles");
return row;
}
_ => {}
}
let s = &ctx.settings;
// Several rows follow another: echo cancellation only means anything while the mic
// streams, the pad rows only while any controller is forwarded at all, and the
@@ -382,6 +481,7 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
),
RowId::AutoWake => (None, "Wake hosts automatically", on_off(s.auto_wake).into()),
RowId::Library => (None, "Game library", on_off(s.library_enabled).into()),
RowId::Profile(_) | RowId::NoProfiles => unreachable!("returned above"),
};
RowSpec {
header,
@@ -477,6 +577,16 @@ fn detail(id: RowId) -> &'static str {
reached over a VPN, where the wake wait only adds delay."
}
RowId::Library => "Show paired hosts' game libraries (tap a title to stream it).",
RowId::Profile(_) => {
"Pin this profile to a host and it appears as its own card — one press \
connects with these settings. Profiles are created and edited in the \
Punktfunk desktop app."
}
RowId::NoProfiles => {
"Profiles bundle stream settings for different uses (a low-latency one, a \
quality one). Create them in the Punktfunk desktop app, then pin them \
here as one-press connect cards."
}
}
}
@@ -611,6 +721,8 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
RowId::Fullscreen => toggle(&mut s.fullscreen_on_stream, delta, wrap),
RowId::AutoWake => toggle(&mut s.auto_wake, delta, wrap),
RowId::Library => toggle(&mut s.library_enabled, delta, wrap),
// Navigation rows, handled before the settings path in `menu` — never a value edit.
RowId::Profile(_) | RowId::NoProfiles => None,
}
.is_some()
}
@@ -736,7 +848,7 @@ mod tests {
device_name: "t",
t: 0.0,
};
assert!(!row_spec(RowId::EchoCancel, &ctx).enabled);
assert!(!row_spec(RowId::EchoCancel, &ctx, &[]).enabled);
assert!(
!adjust(RowId::EchoCancel, -1, false, &mut ctx),
"mic off = thud"
@@ -745,7 +857,7 @@ mod tests {
assert!(ctx.settings.echo_cancel, "and nothing was written");
ctx.settings.mic_enabled = true;
assert!(row_spec(RowId::EchoCancel, &ctx).enabled);
assert!(row_spec(RowId::EchoCancel, &ctx, &[]).enabled);
assert!(adjust(RowId::EchoCancel, -1, false, &mut ctx));
assert!(!ctx.settings.echo_cancel);
assert!(adjust(RowId::EchoCancel, 1, true, &mut ctx));
@@ -771,7 +883,7 @@ mod tests {
device_name: "t",
t: 0.0,
};
assert!(!row_spec(RowId::SmoothBuffer, &ctx).enabled);
assert!(!row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled);
assert!(
!adjust(RowId::SmoothBuffer, 1, false, &mut ctx),
"latency intent = thud"
@@ -781,14 +893,14 @@ mod tests {
// Stepping the intent to Smoothness brings the buffer row to life.
assert!(adjust(RowId::PresentPriority, 1, false, &mut ctx));
assert_eq!(ctx.settings.present_priority, "smooth");
assert!(row_spec(RowId::SmoothBuffer, &ctx).enabled);
assert!(row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled);
assert!(adjust(RowId::SmoothBuffer, 1, false, &mut ctx));
assert_eq!(ctx.settings.smooth_buffer, 1);
// The intent wraps back and the row goes inert again.
assert!(adjust(RowId::PresentPriority, -1, false, &mut ctx));
assert_eq!(ctx.settings.present_priority, "latency");
assert!(!row_spec(RowId::SmoothBuffer, &ctx).enabled);
assert!(!row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled);
}
#[test]
@@ -864,4 +976,110 @@ mod tests {
assert!(adjust(RowId::Bitrate, 1, false, &mut ctx));
assert_eq!(ctx.settings.bitrate_kbps, 0, "snapped to Automatic");
}
/// The Profiles section trails the settings rows: one row per catalog profile whose
/// value counts the pinned cards in the live model, activating opens the pin screen,
/// and left/right (which edits every other row) is a boundary — a profile row
/// navigates, it must never fall into the settings save path.
#[test]
fn profile_rows_navigate_instead_of_editing() {
let (mut settings, pads) = ctx_parts();
let library = crate::library::LibraryShared::default();
let mut pinned = crate::model::HostRow {
key: "aa\0p1".into(),
name: "Tower".into(),
addr: "10.0.0.9".into(),
port: 9777,
fp_hex: "aa".into(),
paired: true,
saved: true,
online: true,
mgmt_port: 47990,
can_wake: false,
last_used: None,
os: String::new(),
pin: Some(crate::model::ProfileChip {
id: "p1".into(),
name: "Work".into(),
accent: None,
}),
bound_profile: None,
};
let hosts = [pinned.clone(), {
pinned.key = "aa".into();
pinned.pin = None;
pinned
}];
let mut ctx = Ctx {
hosts: &hosts,
library: &library,
settings: &mut settings,
pads: &pads,
deck: false,
device_name: "t",
t: 0.0,
};
let mut s = SettingsScreen::with_profiles(vec![
("p1".into(), "Work".into()),
("p2".into(), "Game".into()),
]);
let ids = s.row_ids();
assert_eq!(ids.len(), ROWS.len() + 2);
assert_eq!(ids[ROWS.len()], RowId::Profile(0));
let spec = row_spec(RowId::Profile(0), &ctx, &s.profiles);
assert_eq!(spec.header, Some("Profiles"));
assert_eq!(spec.label, "Work");
assert_eq!(spec.value.as_deref(), Some("Pinned to 1 host"));
let spec = row_spec(RowId::Profile(1), &ctx, &s.profiles);
assert_eq!(spec.header, None, "only the first row carries the header");
assert_eq!(spec.value.as_deref(), Some("Not pinned"));
s.list.cursor = ROWS.len(); // onto "Work"
let mut fx = Outbox::default();
s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
assert!(
matches!(fx.nav, Some(crate::screens::Nav::Push(b))
if matches!(*b, Screen::PinHosts(ref p) if p.profile_name() == "Work")),
"A on a profile row opens its pin screen"
);
let mut fx = Outbox::default();
let pulse = s.menu(
MenuEvent::Move(pf_client_core::gamepad::MenuDir::Right),
&mut ctx,
&mut fx,
);
assert!(matches!(pulse, Some(MenuPulse::Boundary)));
assert!(fx.nav.is_none() && fx.cmds.is_empty());
}
/// An empty catalog shows the explainer placeholder — present, inert, and dimmed —
/// so the section still tells the user where profiles come from.
#[test]
fn empty_catalog_shows_the_placeholder() {
let (mut settings, pads) = ctx_parts();
let library = crate::library::LibraryShared::default();
let mut ctx = Ctx {
hosts: &[],
library: &library,
settings: &mut settings,
pads: &pads,
deck: false,
device_name: "t",
t: 0.0,
};
let mut s = SettingsScreen::with_profiles(Vec::new());
let ids = s.row_ids();
assert_eq!(*ids.last().unwrap(), RowId::NoProfiles);
let spec = row_spec(RowId::NoProfiles, &ctx, &s.profiles);
assert_eq!(spec.header, Some("Profiles"));
assert!(!spec.enabled);
s.list.cursor = ids.len() - 1;
let mut fx = Outbox::default();
let pulse = s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
assert!(matches!(pulse, Some(MenuPulse::Boundary)));
assert!(fx.nav.is_none());
}
}
+8 -1
View File
@@ -239,8 +239,14 @@ impl Shell {
port: h.port,
fp_hex: h.fp_hex.clone(),
launch: None,
title: h.name.clone(),
// A wake started from a pinned card carries its profile
// through to the connect (the row's key found it again).
title: match &h.pin {
Some(p) => format!("{} · {}", h.name, p.name),
None => h.name.clone(),
},
request_access: false,
profile: h.pin.as_ref().map(|p| p.id.clone()),
})
});
self.bus.send(ConsoleCmd::CancelWake);
@@ -269,6 +275,7 @@ impl Shell {
launch: intent.launch,
title: intent.title,
request_access: intent.request_access,
profile: intent.profile,
});
}
+2
View File
@@ -32,6 +32,8 @@ fn hosts() -> Vec<HostRow> {
can_wake: false,
last_used: None,
os: String::new(),
pin: None,
bound_profile: None,
};
vec![
HostRow {
+3 -2
View File
@@ -88,8 +88,9 @@ pub enum ConsoleEntry {
/// The host list (bare `--browse`).
Home,
/// Home with this host's library already pushed (`--browse host` — the Decky
/// per-host launch; B backs out to Home).
Library(HostRow),
/// per-host launch; B backs out to Home). Boxed: `HostRow` outgrew the dataless
/// `Home` variant when it learned its profile chips.
Library(Box<HostRow>),
}
/// The binary's ends of the console: models to write, commands to serve.
+143
View File
@@ -57,6 +57,82 @@ pub fn env_on(name: &str) -> Option<bool> {
})
}
/// Where desktop audio should be audible — which decides the render endpoint the loopback captures.
///
/// Supersedes the two env-only knobs that used to encode this (`PUNKTFUNK_HOST_AUDIO`,
/// `PUNKTFUNK_KEEP_DEFAULT`), which stay honoured as back-compat spellings so nobody's `host.env`
/// breaks. Named modes exist because "which endpoint do we capture" is a routing decision an
/// operator has to be able to make deliberately — the 2026-08-03 field report is what happens when
/// the only way to express it is an undocumented environment variable.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AudioOutputMode {
/// Default. Prefer a render endpoint that is silent on the host, so streamed audio does not
/// also play out of the host's speakers. Since 2026-08 a silent sink has to be able to carry
/// the mix without narrowing it — otherwise real hardware wins anyway.
#[default]
ClientOnly,
/// Prefer real hardware: audio plays on the host as well as the client. The old
/// `PUNKTFUNK_HOST_AUDIO=1`.
HostAndClient,
/// Touch nothing — capture whatever the operator's own default playback device is, and never
/// write the default-device policy. The old `PUNKTFUNK_KEEP_DEFAULT=1`.
FollowDefault,
}
impl AudioOutputMode {
/// `PUNKTFUNK_AUDIO_OUTPUT_MODE` wins; otherwise fall back to the legacy flags, `follow_default`
/// first (it is the more restrictive promise — "do not touch my devices" must not be overridden
/// by a stale `PUNKTFUNK_HOST_AUDIO` in the same `host.env`).
fn from_env() -> AudioOutputMode {
if let Ok(raw) = std::env::var("PUNKTFUNK_AUDIO_OUTPUT_MODE") {
if !raw.trim().is_empty() {
if let Some(m) = AudioOutputMode::parse(&raw) {
return m;
}
// Never silently fall through to a different routing than the operator asked for.
eprintln!(
"punktfunk: PUNKTFUNK_AUDIO_OUTPUT_MODE={raw:?} is not one of \
client_only/host_and_client/follow_default using client_only"
);
}
}
if std::env::var_os("PUNKTFUNK_KEEP_DEFAULT").is_some() {
return AudioOutputMode::FollowDefault;
}
if std::env::var_os("PUNKTFUNK_HOST_AUDIO").is_some() {
return AudioOutputMode::HostAndClient;
}
AudioOutputMode::ClientOnly
}
pub fn parse(s: &str) -> Option<AudioOutputMode> {
match s.trim().to_ascii_lowercase().replace('-', "_").as_str() {
"client_only" | "client" => Some(AudioOutputMode::ClientOnly),
"host_and_client" | "both" | "host" => Some(AudioOutputMode::HostAndClient),
"follow_default" | "follow" => Some(AudioOutputMode::FollowDefault),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
AudioOutputMode::ClientOnly => "client_only",
AudioOutputMode::HostAndClient => "host_and_client",
AudioOutputMode::FollowDefault => "follow_default",
}
}
/// The loopback plan should prefer real hardware over a silent sink.
pub fn prefers_host_hardware(self) -> bool {
matches!(self, AudioOutputMode::HostAndClient)
}
/// Leave the operator's default playback/recording devices completely alone.
pub fn keeps_default(self) -> bool {
matches!(self, AudioOutputMode::FollowDefault)
}
}
/// Resolved host configuration. Holds the genuinely-constant operator/dispatch knobs (see module docs for
/// what is deliberately excluded). Fields read on only one platform are kept alive cross-platform by the
/// derived `Debug` impl, so the parser can stay a single platform-neutral function.
@@ -99,6 +175,24 @@ pub struct HostConfig {
/// e.g. webOS TVs, whose GCM decrypt caps at ~100 Mbps); everyone else stays AES-128-GCM.
/// `PUNKTFUNK_CHACHA20=0`/`false`/`off`/`no` disables.
pub chacha20: bool,
/// `PUNKTFUNK_AUDIO_OUTPUT_MODE` — where desktop audio should be audible, and therefore which
/// render endpoint the loopback captures (`client_only` / `host_and_client` / `follow_default`).
///
/// A first-class setting because the 2026-08-03 field report needed one: the default
/// client-only routing sent that box's whole desktop mix through Steam's voice-carrier virtual
/// endpoint for 25 sessions, and the only way to change it was an undocumented environment
/// variable. See [`AudioOutputMode`].
pub audio_output_mode: AudioOutputMode,
/// `PUNKTFUNK_AUDIO_QUALITY` — desktop-audio encode tier (`low` / `standard` / `high`; default
/// `high`). Kept as the raw string here because the tier table lives in `punktfunk-core`, and
/// this crate is deliberately dependency-free (see the crate doc). The audio thread resolves it
/// via `punktfunk_core::audio::AudioTier::parse` and warns on an unknown spelling rather than
/// silently downgrading someone's audio.
pub audio_quality: Option<String>,
/// `PUNKTFUNK_AUDIO_REDUNDANCY` — force the redundant `0xD2` audio plane on or off. `None`
/// (the default) = automatic: sent only to a client that asked for it, and only while the link
/// is actually losing packets.
pub audio_redundancy: Option<bool>,
/// `PUNKTFUNK_PERF` — per-stage timing instrumentation.
pub perf: bool,
/// `PUNKTFUNK_VIDEO_SOURCE` — GameStream video source select. `virtual` (the default — a
@@ -246,6 +340,9 @@ impl HostConfig {
// Default ON, explicit-off grammar (the client's VIDEO_CAP_CHACHA20 bit is the real
// per-session switch; see the field doc).
chacha20: env_on("PUNKTFUNK_CHACHA20").unwrap_or(true),
audio_output_mode: AudioOutputMode::from_env(),
audio_quality: val("PUNKTFUNK_AUDIO_QUALITY").map(|s| s.trim().to_lowercase()),
audio_redundancy: env_on("PUNKTFUNK_AUDIO_REDUNDANCY"),
perf: flag("PUNKTFUNK_PERF"),
// Default ON while the interval-stutter field program runs (see the field doc).
stall_probes: env_on("PUNKTFUNK_STALL_PROBES").unwrap_or(true),
@@ -348,4 +445,50 @@ mod tests {
// An invalid rate stays invalid rather than being laundered into a real one.
assert_eq!(c.game_fps(0), 0);
}
#[test]
fn audio_output_mode_parses_its_spellings() {
for (s, want) in [
("client_only", AudioOutputMode::ClientOnly),
("client-only", AudioOutputMode::ClientOnly),
(" CLIENT ", AudioOutputMode::ClientOnly),
("host_and_client", AudioOutputMode::HostAndClient),
("both", AudioOutputMode::HostAndClient),
("follow_default", AudioOutputMode::FollowDefault),
("follow", AudioOutputMode::FollowDefault),
] {
assert_eq!(AudioOutputMode::parse(s), Some(want), "{s:?}");
}
// Unknown spellings are rejected so the caller can say so, not silently re-routed.
for s in ["", "silent", "off", "true"] {
assert_eq!(AudioOutputMode::parse(s), None, "{s:?}");
}
// Round-trip through the canonical spelling.
for m in [
AudioOutputMode::ClientOnly,
AudioOutputMode::HostAndClient,
AudioOutputMode::FollowDefault,
] {
assert_eq!(AudioOutputMode::parse(m.as_str()), Some(m));
}
}
/// The two predicates are what the wiring plan and the capture loop actually branch on, and
/// they must stay mutually exclusive: "prefer host hardware" and "touch nothing" are different
/// promises, and conflating them would either silence the host or stomp the operator's devices.
#[test]
fn audio_output_mode_predicates_are_disjoint() {
assert_eq!(AudioOutputMode::default(), AudioOutputMode::ClientOnly);
for m in [
AudioOutputMode::ClientOnly,
AudioOutputMode::HostAndClient,
AudioOutputMode::FollowDefault,
] {
assert!(!(m.prefers_host_hardware() && m.keeps_default()), "{m:?}");
}
assert!(AudioOutputMode::HostAndClient.prefers_host_hardware());
assert!(AudioOutputMode::FollowDefault.keeps_default());
assert!(!AudioOutputMode::ClientOnly.prefers_host_hardware());
assert!(!AudioOutputMode::ClientOnly.keeps_default());
}
}
+5
View File
@@ -84,6 +84,11 @@ pub enum OverlayAction {
fp_hex: String,
launch: Option<String>,
title: String,
/// One-off settings-profile override for THIS launch (a profile id — a pinned
/// card's connect). `None` resolves the host's default binding as before; the
/// binary feeds it to `trust::effective_settings`, so a dangling id quietly
/// falls back to the defaults and never blocks the connect.
profile: Option<String>,
/// The no-PIN delegated-approval path: pin the host's advertised fingerprint and
/// open a connect the host PARKS until the operator approves this device in its
/// console (a long connect budget), then persist it as paired. `false` = an
+7
View File
@@ -48,6 +48,13 @@ exclude = ["MsghdrX", "recvmsg_x", "mmsghdr", "sendmmsg", "recvmmsg"]
"AXIS_RT" = "PUNKTFUNK_AXIS_RT"
"AUDIO_MAGIC" = "PUNKTFUNK_AUDIO_MAGIC"
"RUMBLE_MAGIC" = "PUNKTFUNK_RUMBLE_MAGIC"
"AUDIO_RED_MAGIC" = "PUNKTFUNK_AUDIO_RED_MAGIC"
"AUDIO_RED_HEADER" = "PUNKTFUNK_AUDIO_RED_HEADER"
# Same hazard as the BTN_* block above, one step worse: `FRAME_MS` and `SAMPLE_RATE_HZ` are
# generic enough that an embedder is likely to have its own, and a clashing #define silently
# takes the last definition rather than failing to compile.
"FRAME_MS" = "PUNKTFUNK_AUDIO_FRAME_MS"
"SAMPLE_RATE_HZ" = "PUNKTFUNK_AUDIO_SAMPLE_RATE_HZ"
# QualifiedScreamingSnakeCase already qualifies each variant with the enum name
# (PunktfunkStatus::Ok -> PUNKTFUNK_STATUS_OK); do NOT also set prefix_with_name or it doubles.
+536 -155
View File
@@ -150,11 +150,14 @@ const ENCODE_SEVERE_US: i64 = 12_000;
/// the same reason: the decoder's knee moves with content and thermals.
const CAP_REPROBE_WINDOWS_MIN: u32 = 16;
const CAP_REPROBE_WINDOWS_MAX: u32 = 128;
/// Two consecutive decode-driven backoffs latch the
/// Two decode-driven backoffs latch the
/// [`decode cap`](BitrateController::decode_cap_kbps) only when their pre-backoff rates agree
/// within ±1/8: the decoder's knee is a RATE, so repeated chokes at the same rate are its
/// signature — two unrelated events (a Wi-Fi flush at 300 Mbps, a decode spike at 500) share
/// no knee and must not teach one.
/// no knee and must not teach one. Each sample must come from a rate the controller CLIMBED
/// back to (`climb_since_backoff`) — the knee's real signature is choke, recover, re-climb,
/// choke again at the same place, and only backoffs at a climbed-to rate can agree within the
/// band (a cascade's second backoff sits at ×0.7 of the first: outside it by construction).
const DECODE_CAP_SIMILAR_DIV: u32 = 8;
/// Rolling window (in 750 ms report windows, ~30 s) whose minimum mean is the OWD baseline.
/// Long enough to remember the uncongested floor, short enough to follow genuine path changes.
@@ -286,6 +289,20 @@ pub(crate) struct BitrateController {
/// decode-driven): the reference the next one must land near ([`DECODE_CAP_SIMILAR_DIV`])
/// to latch the cap — one spurious flush teaches nothing.
decode_backoff_kbps: u32,
/// Decode-flagged windows in the CURRENT bad-window streak. The ordinary two-window backoff
/// path is the decoder knee's most common presentation (a standing 1545 ms decode rise —
/// deep enough to hurt, not deep enough for the severe tier), and judging decode evidence
/// from the FINAL window alone threw that attribution away: the backoff the decode signal
/// itself caused then RESET the knee streak. Counted per bad window, cleared with the streak.
streak_decode_windows: u32,
/// Whether `current_kbps` has RISEN (via an ack — ours or a host-initiated re-target) since
/// the last backoff. A knee sample is only meaningful for a rate the controller climbed to
/// or held; a backoff that fires while the previous backoff's damage is still draining
/// samples a rate the decoder never choked at (the host acks a ×0.7 request in ~100 ms, so
/// a cascade's second backoff ALWAYS sits at the already-reduced rate — dissimilar to the
/// knee by construction, 0.7 < 7/8). Such a backoff neither samples nor erases the
/// reference.
climb_since_backoff: bool,
/// Clean windows spent parked at the learned decode cap (its re-probe clock), and that
/// clock's own backoff interval — same schedule as the host cap's.
decode_cap_probe_windows: u32,
@@ -341,6 +358,10 @@ impl BitrateController {
cap_reprobe_after: CAP_REPROBE_WINDOWS_MIN,
decode_cap_kbps: None,
decode_backoff_kbps: 0,
streak_decode_windows: 0,
// The negotiated start rate was held, not drained to — the first backoff ever is a
// legitimate knee sample.
climb_since_backoff: true,
decode_cap_probe_windows: 0,
decode_cap_reprobe_after: CAP_REPROBE_WINDOWS_MIN,
proven_kbps: 0,
@@ -433,6 +454,13 @@ impl BitrateController {
}
}
}
if kbps > self.current_kbps {
// The rate ROSE — whatever the pipeline chokes on next, it will choke at a rate
// it was driven up to: a fresh knee sample (see `climb_since_backoff`). An ack'd
// decrease deliberately does not arm this — the drain after a backoff is not a
// knee encounter.
self.climb_since_backoff = true;
}
self.current_kbps = kbps;
// The host may run ABOVE our climb ceiling, and be right to: it sends an unsolicited
// `BitrateChanged` when a rebuild re-resolves an Automatic rate for what it actually
@@ -472,6 +500,8 @@ impl BitrateController {
self.cap_reprobe_after = CAP_REPROBE_WINDOWS_MIN;
self.decode_cap_kbps = None;
self.decode_backoff_kbps = 0;
self.streak_decode_windows = 0;
self.climb_since_backoff = true;
self.decode_cap_probe_windows = 0;
self.owd_means.clear();
self.decode_means.clear();
@@ -571,12 +601,20 @@ impl BitrateController {
}
if bad {
self.bad_windows += 1;
if decode_bad {
// Per-window decode attribution for the streak (see `streak_decode_windows`) —
// scored HERE because at backoff time only the final window's signals are in
// scope, and on the two-window path the first bad window never even reaches a
// decision (the cooldown eats it).
self.streak_decode_windows += 1;
}
self.clean_windows = 0;
// Any congestion signal ends slow start for good — from here on, climbs are additive.
self.probing = false;
} else {
self.clean_windows += 1;
self.bad_windows = 0;
self.streak_decode_windows = 0;
}
// The learned host cap re-probe (see [`CAP_REPROBE_WINDOWS_MIN`]): after a clean run
// parked at the cap, lift it one step (+12.5 %, ceiling-bounded) so a scene-dependent
@@ -635,21 +673,42 @@ impl BitrateController {
&& self.current_kbps > self.floor_kbps
{
// Decode-cap learning (see [`decode_cap_kbps`](Self::decode_cap_kbps)): a backoff
// with decode-severe evidence — the deep decode excursion, or the flush that
// drained the queue behind a stalled decoder — remembers its pre-backoff rate; the
// SECOND consecutive one at a similar rate latches that rate as the decoder's
// knee. One event never latches (a spurious flush must stay a one-off), and a
// backoff without decode evidence in between breaks the streak — whatever it saw,
// it wasn't the same knee.
// A bare flush counts as decode evidence only where the decode signal can't speak
// for itself. On an embedder that reports decode latency, a flush with FLAT decode
// is a network event (a stall, a clock step) that drained a queue the decoder was
// keeping up with — teaching a "decoder knee" from it caps the session on the wrong
// end of the pipe. Where the signal is absent the old reading stands: the flush is
// the only decoder-saturation evidence there is.
let decode_evidence =
decode_severe || (flushed && (decode_bad || decode_mean_us.is_none()));
if decode_evidence {
// with decode evidence remembers its pre-backoff rate; the next one at a similar
// rate latches that rate as the decoder's knee. One event never latches (a spurious
// flush must stay a one-off), and a decode-free backoff in between breaks the
// streak — whatever it saw, it wasn't the same knee.
//
// Decode evidence, in order:
// - a decode-SEVERE excursion in the deciding window;
// - the ordinary two-window path where EVERY bad window was decode-flagged
// (`streak_decode_windows`) — the knee's most common presentation is a standing
// 1545 ms rise, below the severe tier, and the deciding window alone can't see
// that the streak it ends was decode's doing;
// - a keyframe-ask storm without meaningful loss: a decoder begging for fresh
// pictures on a clean link is being overdriven, whatever its latency figure says
// (some decoders wedge rather than queue — the Steam Deck presentation). With
// real loss present the asks are network-attributed and teach nothing here;
// - a flush, where the decode signal can't speak against it: on an embedder that
// reports decode latency, a flush with FLAT decode is a network event (a stall, a
// clock step) that drained a queue the decoder was keeping up with — teaching a
// "decoder knee" from it caps the session on the wrong end of the pipe. Where the
// signal is absent the flush is the only decoder-saturation evidence there is.
let decode_evidence = decode_severe
|| self.streak_decode_windows >= BAD_WINDOWS_TO_DECREASE
|| (recovery_kf >= RECOVERY_KF_BAD && loss_ppm < HEAVY_LOSS_PPM)
|| (flushed && (decode_bad || decode_mean_us.is_none()));
if !self.climb_since_backoff {
// Still draining the previous backoff: the host acks a ×0.7 request in ~100 ms,
// so this window's rate is one the decoder never choked at while keeping up —
// its distress is residue of the choke above. Not a knee sample either way:
// neither latch against it nor let it erase the reference the real knee set.
tracing::debug!(
at_kbps = self.current_kbps,
reference_kbps = self.decode_backoff_kbps,
"adaptive bitrate: backoff without an intervening climb — draining the \
previous choke, not a knee sample"
);
} else if decode_evidence {
let rate = self.current_kbps;
let similar = self.decode_backoff_kbps > 0
&& rate.abs_diff(self.decode_backoff_kbps)
@@ -683,8 +742,10 @@ impl BitrateController {
} else {
self.decode_backoff_kbps = 0;
}
self.climb_since_backoff = false;
let next = ((self.current_kbps as u64 * 7 / 10) as u32).max(self.floor_kbps);
self.bad_windows = 0;
self.streak_decode_windows = 0;
return self.request(next, now);
}
// Climbs only fire off a UTILIZED clean window (actual delivered ≥ ¾ of the target — the
@@ -1945,71 +2006,100 @@ mod tests {
assert_eq!(run_clean(&mut c, start, 24, 20), None);
}
fn calm_window(c: &mut BitrateController, at: Instant) {
// One calm, unutilized window (2 Mb/s actual): seeds the latency baselines without
// authorizing climbs, and must decide nothing.
assert_eq!(
c.on_window(at, 0, 0, Some(10_000), Some(8_000), None, 2_000, false, 0),
None
);
}
/// Drive clean, fully-utilized windows (1 Gb/s actual), acking every climb the controller
/// asks for — a live host answers in ~100 ms — until `current_kbps` reaches `target`.
/// Bounded so a climb-path regression fails loudly instead of spinning.
fn climb_to(c: &mut BitrateController, start: Instant, tick: &mut u32, target: u32) {
for _ in 0..600 {
if c.current_kbps >= target {
return;
}
if let Some(k) = c.on_window(
ticks(start, *tick),
0,
0,
Some(10_000),
Some(8_000),
None,
1_000_000,
false,
0,
) {
c.on_ack(k);
}
*tick += 1;
}
panic!(
"no climb to {target} within 600 windows (stuck at {})",
c.current_kbps
);
}
/// One decode-SEVERE window (60 ms against the ~8 ms baseline) at the current rate — a
/// knee choke. Steps past the change cooldown first so the decision can fire.
fn choke(c: &mut BitrateController, start: Instant, tick: &mut u32) -> Option<u32> {
*tick += 2;
let r = c.on_window(
ticks(start, *tick),
0,
0,
Some(10_000),
Some(60_000),
None,
c.current_kbps,
false,
0,
);
*tick += 1;
r
}
/// The latch's only production-reachable shape: choke at the knee, the host ACKS the ×0.7
/// (a live host answers in ~100 ms, so a cascade's second backoff always sits at the
/// already-reduced rate — dissimilar by construction), the controller climbs back, and the
/// re-climb chokes inside the ±1/8 band. Latches, acks the backoff, returns the cap.
fn latch_knee(c: &mut BitrateController, start: Instant, tick: &mut u32) -> u32 {
for _ in 0..4 {
calm_window(c, ticks(start, *tick));
*tick += 1;
}
let knee = c.current_kbps;
let r1 = choke(c, start, tick).expect("first choke must back off");
assert!(c.decode_cap_kbps.is_none(), "one event must not latch");
c.on_ack(r1);
climb_to(c, start, tick, knee - knee / DECODE_CAP_SIMILAR_DIV);
let rate = c.current_kbps;
let r2 = choke(c, start, tick).expect("re-climb choke must back off");
assert_eq!(c.decode_cap_kbps, Some(rate - rate / 16));
c.on_ack(r2);
rate - rate / 16
}
#[test]
fn decode_cap_latches_after_two_consecutive_decode_severe_backoffs() {
fn decode_cap_latches_when_the_reclimb_chokes_at_the_same_knee() {
// The 1440p120 field sawtooth: a decoder knee (~500 Mbps) well under the (inflated)
// link ceiling — nothing ever LEARNED the knee, so every re-climb ended in a flush +
// dropped-frame burst. Establish a decode baseline on calm windows, choke twice at the
// same rate, and the second decode-severe backoff must latch the knee.
// dropped-frame burst. Choke, recover, climb back, choke again inside the band: latch.
let mut c = BitrateController::new(500_000);
c.set_ceiling(900_000);
let start = Instant::now();
// Calm baseline windows (2 Mb/s actual: unutilized, so no climb interferes).
for i in 0..4 {
assert_eq!(
c.on_window(
ticks(start, i),
0,
0,
Some(10_000),
Some(8_000),
None,
2_000,
false,
0
),
None
);
}
// First deep decode excursion → immediate ×0.7, but ONE event must not latch.
assert_eq!(
c.on_window(
ticks(start, 4),
0,
0,
Some(10_000),
Some(60_000),
None,
490_000,
false,
0
),
Some(350_000)
);
assert!(c.decode_cap_kbps.is_none());
// Second consecutive decode-severe backoff at the same pre-backoff rate: latch.
assert_eq!(
c.on_window(
ticks(start, 6),
0,
0,
Some(10_000),
Some(60_000),
None,
490_000,
false,
0
),
Some(350_000)
);
assert_eq!(c.decode_cap_kbps, Some(500_000 - 500_000 / 16));
// The backoff applies; from here every climb must stop AT the knee — not the 900 Mbps
let mut t = 0;
latch_knee(&mut c, start, &mut t);
// The latch applies; from here every climb must stop AT the knee — not the 900 Mbps
// link ceiling the old sawtooth kept re-poking.
c.on_ack(350_000);
let mut max_req = 0;
for i in 8..70 {
for _ in 0..62 {
if let Some(k) = c.on_window(
ticks(start, i),
ticks(start, t),
0,
0,
Some(10_000),
@@ -2030,6 +2120,7 @@ mod tests {
max_req = max_req.max(k);
c.on_ack(k);
}
t += 1;
}
assert!(
max_req < 600_000,
@@ -2039,37 +2130,82 @@ mod tests {
#[test]
fn a_single_flush_or_dissimilar_backoffs_never_latch_a_decode_cap() {
// The latch's false-positive guards. A lone jump-to-live flush (a Wi-Fi clump can
// flush once at ANY rate) backs off but teaches nothing…
// The latch's false-positive guards, every event at a rate the controller climbed to
// or held (drain-time backoffs are no sample at all —
// `cascade_backoffs_neither_sample_nor_erase_the_knee_reference` owns those). A lone
// jump-to-live flush (a Wi-Fi clump can flush once at ANY rate) backs off but teaches
// nothing…
let mut c = BitrateController::new(500_000);
c.set_ceiling(900_000);
let start = Instant::now();
assert_eq!(
c.on_window(ticks(start, 0), 0, 0, None, None, None, 490_000, true, 0),
Some(350_000)
);
let mut t = 0;
let r1 = c
.on_window(ticks(start, t), 0, 0, None, None, None, 490_000, true, 0)
.expect("flush must back off");
assert_eq!(r1, 350_000);
assert!(c.decode_cap_kbps.is_none());
c.on_ack(350_000);
// …a LOSS-driven backoff in between breaks the streak
assert_eq!(
c.on_window(ticks(start, 2), 1, 0, None, None, None, 340_000, false, 0),
Some(245_000)
);
c.on_ack(r1);
// …a LOSS-driven backoff at the re-climbed rate breaks the streak (whatever choked
// there, it wasn't the decoder — even inside the similarity band)…
climb_to(&mut c, start, &mut t, 460_000);
t += 2;
let r2 = c
.on_window(
ticks(start, t),
1,
0,
None,
None,
None,
c.current_kbps,
false,
0,
)
.expect("loss must back off");
t += 1;
assert!(c.decode_cap_kbps.is_none());
c.on_ack(245_000);
assert_eq!(
c.decode_backoff_kbps, 0,
"a climbed-to non-decode backoff must reset the knee reference"
);
c.on_ack(r2);
// …so the next flush counts as a FIRST decode event again — still no latch…
assert_eq!(
c.on_window(ticks(start, 4), 0, 0, None, None, None, 240_000, true, 0),
Some(171_500)
);
climb_to(&mut c, start, &mut t, 460_000);
t += 2;
let r3 = c
.on_window(
ticks(start, t),
0,
0,
None,
None,
None,
c.current_kbps,
true,
0,
)
.expect("flush must back off");
t += 1;
assert!(c.decode_cap_kbps.is_none());
c.on_ack(171_500);
// …and two consecutive decode events at DISSIMILAR rates (245 vs 171.5 Mbps — no
c.on_ack(r3);
// …and two decode events at DISSIMILAR climbed-to rates (~460 vs ~350 Mbps — no
// common knee) must not latch either.
assert_eq!(
c.on_window(ticks(start, 6), 0, 0, None, None, None, 170_000, true, 0),
Some(120_050)
);
let dissimilar_target = c.current_kbps + 20_000;
climb_to(&mut c, start, &mut t, dissimilar_target);
t += 2;
let _ = c
.on_window(
ticks(start, t),
0,
0,
None,
None,
None,
c.current_kbps,
true,
0,
)
.expect("flush must back off");
assert!(c.decode_cap_kbps.is_none());
}
@@ -2082,38 +2218,14 @@ mod tests {
let mut c = BitrateController::new(500_000);
c.set_ceiling(900_000);
let start = Instant::now();
for i in 0..4 {
let mut t = 0;
let knee = latch_knee(&mut c, start, &mut t);
// The host parks the session at the knee (an unsolicited re-target up to it — its
// clamp is authoritative).
c.on_ack(knee);
for _ in 0..CAP_REPROBE_WINDOWS_MIN {
let _ = c.on_window(
ticks(start, i),
0,
0,
Some(10_000),
Some(8_000),
None,
2_000,
false,
0,
);
}
for i in [4, 6] {
let _ = c.on_window(
ticks(start, i),
0,
0,
Some(10_000),
Some(60_000),
None,
490_000,
false,
0,
);
}
assert_eq!(c.decode_cap_kbps, Some(500_000 - 500_000 / 16));
// The host's ack parks the session at the knee (its clamp is authoritative).
c.on_ack(500_000 - 500_000 / 16);
for i in 0..CAP_REPROBE_WINDOWS_MIN {
let _ = c.on_window(
ticks(start, 8 + i),
ticks(start, t),
0,
0,
Some(10_000),
@@ -2123,8 +2235,8 @@ mod tests {
false,
0,
);
t += 1;
}
let knee = 500_000 - 500_000 / 16;
assert_eq!(c.decode_cap_kbps, Some(knee + knee / 8));
}
@@ -2135,38 +2247,307 @@ mod tests {
let mut c = BitrateController::new(500_000);
c.set_ceiling(900_000);
let start = Instant::now();
for i in 0..4 {
let _ = c.on_window(
ticks(start, i),
0,
0,
Some(10_000),
Some(8_000),
None,
2_000,
false,
0,
);
}
for i in [4, 6] {
let _ = c.on_window(
ticks(start, i),
0,
0,
Some(10_000),
Some(60_000),
None,
490_000,
false,
0,
);
}
assert_eq!(c.decode_cap_kbps, Some(500_000 - 500_000 / 16));
let mut t = 0;
let _ = latch_knee(&mut c, start, &mut t);
c.on_mode_switch();
assert!(c.decode_cap_kbps.is_none());
assert_eq!(c.ceiling_kbps, 900_000);
}
#[test]
fn ordinary_decode_bad_window_pairs_latch_the_knee_field_trace() {
// The 2026-08-03 780M field trace, numbers from the log. The knee's most common
// presentation is a standing ~26 ms decode rise — deep enough for the ordinary
// two-window backoff, below the 45 ms severe tier. Judging evidence from the deciding
// window alone read those backoffs as decode-free and RESET the knee streak each
// time; the session sawtoothed 220↔450 Mbps for its remaining minutes.
let mut c = BitrateController::new(20_000);
c.set_ceiling(657_788); // the log's probe ceiling
let start = Instant::now();
let mut t = 0;
for _ in 0..4 {
calm_window(&mut c, ticks(start, t));
t += 1;
}
// A single heavy-loss window ends slow start (as the field session's startup hitch
// did) so the climb below is the additive one the trace shows.
let _ = c.on_window(
ticks(start, t),
0,
HEAVY_LOSS_PPM,
Some(10_000),
Some(8_000),
None,
15_000,
false,
0,
);
t += 1;
// Choke #1 (00:35:56Z): flush + 40 ms decode at ~417 Mbps — evidence, first sample.
climb_to(&mut c, start, &mut t, 417_277);
let first = c.current_kbps;
t += 2;
let r1 = c
.on_window(
ticks(start, t),
0,
0,
Some(8_313),
Some(40_087),
None,
first,
true,
1,
)
.expect("flush choke must back off");
t += 1;
assert!(c.decode_cap_kbps.is_none());
assert_eq!(c.decode_backoff_kbps, first);
c.on_ack(r1);
// Choke #2 (00:36:32Z): TWO consecutive ~26 ms decode-bad windows at ~446 Mbps — the
// ordinary two-window path, no flush, nothing severe. This is the backoff the old
// evidence gate threw away.
climb_to(&mut c, start, &mut t, 440_000);
let second = c.current_kbps;
t += 2;
assert_eq!(
c.on_window(
ticks(start, t),
0,
0,
Some(6_877),
Some(26_474),
None,
second,
false,
0
),
None,
"the first bad window must not decide"
);
t += 1;
assert_eq!(
c.on_window(
ticks(start, t),
0,
0,
Some(6_877),
Some(26_474),
None,
second,
false,
0
),
Some(((second as u64 * 7 / 10) as u32).max(FLOOR_KBPS))
);
assert_eq!(
c.decode_cap_kbps,
Some(second - second / 16),
"two decode-bad windows are knee evidence"
);
}
#[test]
fn cascade_backoffs_neither_sample_nor_erase_the_knee_reference() {
// Choke at the knee (reference set), the host acks the ×0.7 within ~100 ms, and the
// drain flushes → a second backoff fires at the REDUCED rate. That rate is one the
// decoder never choked at while keeping up — the old code overwrote the reference
// with it (and could never latch from a cascade at all: ×0.7 sits outside the ±1/8
// band by construction). A drain backoff must neither latch nor erase; the eventual
// re-climb's choke latches against the ORIGINAL sample.
let mut c = BitrateController::new(500_000);
c.set_ceiling(900_000);
let start = Instant::now();
let mut t = 0;
for _ in 0..4 {
calm_window(&mut c, ticks(start, t));
t += 1;
}
let r1 = choke(&mut c, start, &mut t).expect("knee choke must back off");
assert_eq!(c.decode_backoff_kbps, 500_000);
c.on_ack(r1);
t += 2;
let r2 = c
.on_window(
ticks(start, t),
0,
0,
Some(10_000),
Some(43_305),
None,
r1,
true,
1,
)
.expect("drain flush must back off");
t += 1;
assert!(
c.decode_cap_kbps.is_none(),
"a drain backoff must not latch"
);
assert_eq!(
c.decode_backoff_kbps, 500_000,
"…nor erase the knee reference"
);
c.on_ack(r2);
climb_to(&mut c, start, &mut t, 460_000);
let rate = c.current_kbps;
choke(&mut c, start, &mut t).expect("re-climb choke must back off");
assert_eq!(c.decode_cap_kbps, Some(rate - rate / 16));
}
#[test]
fn keyframe_storms_on_a_clean_link_latch_the_knee() {
// The Steam Deck presentation of the knee: an overdriven decoder that WEDGES instead
// of queueing — decode latency reads absent-to-flat while the client begs for
// keyframes with zero loss (the field traces: 1419 asks at ~300 Mbps, loss_ppm=0).
// The asks are the decode evidence.
let mut c = BitrateController::new(300_000);
c.set_ceiling(900_000);
let start = Instant::now();
let mut t = 0;
for _ in 0..4 {
calm_window(&mut c, ticks(start, t));
t += 1;
}
t += 2;
let r1 = c
.on_window(
ticks(start, t),
0,
0,
Some(10_000),
None,
None,
300_000,
false,
RECOVERY_KF_SEVERE,
)
.expect("keyframe storm must back off");
t += 1;
assert!(c.decode_cap_kbps.is_none());
c.on_ack(r1);
climb_to(&mut c, start, &mut t, 280_000);
let rate = c.current_kbps;
t += 2;
let _ = c
.on_window(
ticks(start, t),
0,
0,
Some(10_000),
None,
None,
rate,
false,
RECOVERY_KF_SEVERE,
)
.expect("second storm must back off");
assert_eq!(c.decode_cap_kbps, Some(rate - rate / 16));
}
#[test]
fn keyframe_storms_with_real_loss_teach_no_knee() {
// The same storm WITH heavy loss is network-attributed (a lost reference forces
// recovery asks; loss_ppm already prices that path): it must not latch, and it must
// break the streak like any other non-decode backoff.
let mut c = BitrateController::new(300_000);
c.set_ceiling(900_000);
let start = Instant::now();
let mut t = 0;
for _ in 0..4 {
calm_window(&mut c, ticks(start, t));
t += 1;
}
t += 2;
let r1 = c
.on_window(
ticks(start, t),
0,
0,
Some(10_000),
None,
None,
300_000,
false,
RECOVERY_KF_SEVERE,
)
.expect("clean storm must back off");
t += 1;
assert_eq!(c.decode_backoff_kbps, 300_000);
c.on_ack(r1);
climb_to(&mut c, start, &mut t, 280_000);
t += 2;
let _ = c
.on_window(
ticks(start, t),
0,
SEVERE_LOSS_PPM,
Some(10_000),
None,
None,
c.current_kbps,
false,
RECOVERY_KF_SEVERE,
)
.expect("lossy storm must back off");
assert!(c.decode_cap_kbps.is_none());
assert_eq!(
c.decode_backoff_kbps, 0,
"a loss-attributed storm must reset the knee reference"
);
}
#[test]
fn a_mixed_streak_without_decode_attribution_is_no_knee_evidence() {
// Two bad windows, only ONE decode-flagged (OWD carried the other): the backoff is
// not decode-attributed — the reference must reset, not sample.
let mut c = BitrateController::new(500_000);
c.set_ceiling(900_000);
let start = Instant::now();
let mut t = 0;
for _ in 0..4 {
calm_window(&mut c, ticks(start, t));
t += 1;
}
t += 2;
assert_eq!(
c.on_window(
ticks(start, t),
0,
0,
Some(40_000),
Some(8_000),
None,
490_000,
false,
0
),
None,
"one OWD-bad window must not decide"
);
t += 1;
assert_eq!(
c.on_window(
ticks(start, t),
0,
0,
Some(10_000),
Some(26_000),
None,
490_000,
false,
0
),
Some(350_000)
);
assert!(c.decode_cap_kbps.is_none());
assert_eq!(
c.decode_backoff_kbps, 0,
"a mixed-attribution backoff must reset the knee reference"
);
}
#[test]
fn ack_silence_disables_the_controller() {
let mut c = BitrateController::new(20_000);
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -490,7 +490,13 @@ impl NativeClient {
video_codecs,
preferred_codec,
display_hdr,
client_caps,
// Redundant audio (`0xD2`) is advertised by CORE, not by the embedder: the
// recovery happens on the demux side (`AudioRedRecovery` in the datagram
// task) and re-inserts the rebuilt frame into the same queue, so every
// embedder benefits without knowing the plane exists — and none of them can
// forget to opt in. The bit is a pure "I can decode it"; the host still
// decides whether to spend the extra ~1 %.
client_caps: client_caps | crate::quic::CLIENT_CAP_AUDIO_RED,
frame_parts,
launch,
name,
@@ -243,6 +243,38 @@ impl ControlTask {
seq: offer.seq,
kinds: offer.kinds,
});
} else if let Ok(chg) = crate::quic::ShardPayloadChanged::decode(&msg) {
// Mid-session shard renegotiation (design/shard-payload-reneg.md): the
// host re-keys the sealed video geometry. Per-frame pinning means there
// is nothing to re-key on the receive path — the reassembler follows
// each frame's own header and every buffer is statically sized for the
// ceiling — so the dispatch is validate + ack. The ack is telemetry for
// a shrink and the GATE for a grow (the host emits nothing above the
// old size until it lands). Validate against our own receive bounds —
// the same ceiling we advertised in `Hello::max_shard_payload` — and
// answer an out-of-bounds request with SILENCE, not an ack: a buggy
// host must never read a granted grow out of garbage.
let n = chg.shard_payload as usize;
if (crate::config::MIN_SHARD_PAYLOAD..=crate::config::max_shard_payload())
.contains(&n)
&& n % 2 == 0
{
tracing::info!(
shard_payload = n,
"host re-keyed the wire shard payload — acking"
);
let ack = crate::quic::ShardPayloadAck {
shard_payload: chg.shard_payload,
};
if io::write_msg(&mut ctrl_send, &ack.encode()).await.is_err() {
break;
}
} else {
tracing::warn!(
shard_payload = n,
"out-of-bounds shard-payload change — ignoring (no ack)"
);
}
} else if let Ok(shape) = crate::quic::CursorShape::decode(&msg) {
// Pointer bitmap changed (cursor channel, only when negotiated). try_send:
// an overflowing ring drops the newest shape — the next change resends.
@@ -23,6 +23,10 @@ pub(super) async fn run(
// gate): a datagram the network reordered must not roll a stopped motor back on. Legacy v1
// datagrams carry no seq and bypass it (an old host's own periodic re-send is the only heal).
let mut rumble_last_seq: [Option<u8>; crate::input::MAX_PADS] = [None; crate::input::MAX_PADS];
// Redundant-audio-plane rebuild (`0xD2`). Recovery happens HERE rather than in the four
// client decoders: the recovered frame is re-inserted into this queue in order, so every
// embedder gets a complete stream without knowing the plane exists.
let mut audio_red = crate::audio::AudioRedRecovery::new();
while let Ok(d) = conn.read_datagram().await {
match d.first() {
Some(&crate::quic::AUDIO_MAGIC) => {
@@ -34,6 +38,26 @@ pub(super) async fn run(
});
}
}
Some(&crate::quic::AUDIO_RED_MAGIC) => {
if let Some((seq, pts_ns, opus, prev)) = crate::quic::decode_audio_red_datagram(&d)
{
if audio_red.recover_before(seq, prev.is_some()) {
// The copy is the frame BEFORE this one, so it carries the previous
// sequence and presentation time — one protocol frame earlier.
let _ = audio_tx.try_send(AudioPacket {
seq: seq.wrapping_sub(1),
pts_ns: pts_ns
.saturating_sub(crate::audio::FRAME_MS as u64 * 1_000_000),
data: prev.unwrap_or_default().to_vec(),
});
}
let _ = audio_tx.try_send(AudioPacket {
seq,
pts_ns,
data: opus.to_vec(),
});
}
}
Some(&crate::quic::RUMBLE_MAGIC) => {
if let Some(u) = crate::quic::decode_rumble_envelope(&d) {
// Gate v2 envelopes on their per-pad seq; forward v1 (envelope: None) as-is.
@@ -156,6 +156,12 @@ pub(super) async fn connect_and_handshake(args: &WorkerArgs) -> Result<Handshake
// stop compositing the pointer, so only an embedder that actually renders the
// cursor locally may set it (the embedder decides, we pass through).
client_caps: args.client_caps,
// Unconditional like STREAMED_AU: the shared reassembler pins geometry
// per-frame and every receive buffer is sized from MAX_DATAGRAM_BYTES, so
// every embedder accepts a mid-session shard change up to this ceiling
// (design/shard-payload-reneg.md W0.3 — the host only renegotiates, and only
// grows to jumbo, when this advertises it).
max_shard_payload: crate::config::max_shard_payload() as u16,
}
.encode(),
)
+173
View File
@@ -341,6 +341,88 @@ pub fn mtu1500_shard_payload_for(peer: core::net::IpAddr) -> usize {
}
}
/// Floor for a negotiated `shard_payload` (even, well under every real path). A path whose UDP
/// budget lands below this can't carry the QUIC control plane either (QUIC's own minimum is a
/// 1200-byte UDP payload), so shrinking video shards further buys nothing — the clamp helpers
/// bottom out here instead of producing degenerate confetti-sized shards.
pub const MIN_SHARD_PAYLOAD: usize = 512;
/// The sealed wire size of a video datagram carrying `shard_payload` bytes of shard — what
/// actually leaves the socket as UDP payload (punktfunk header + shard + crypto overhead).
pub const fn sealed_datagram_bytes(shard_payload: usize) -> usize {
HEADER_LEN + shard_payload + CRYPTO_OVERHEAD
}
/// The UDP-payload size a path must carry for full-size IPv4 video datagrams: the sealed size
/// of the [`mtu1500_shard_payload`] default (= 1472, the exact 1500-MTU IPv4 ceiling). Doubles
/// as the QUIC MTU-discovery probe ceiling (`quic/endpoint.rs`): with the ceiling set to
/// exactly this value, a control connection whose discovery settles AT the ceiling has proven
/// the path carries full-size video datagrams, and one that settles BELOW it has proven the
/// path cannot — a discrimination quinn's stock 1452 ceiling can't make in either direction.
pub const fn video_datagram_udp_ceiling() -> usize {
sealed_datagram_bytes(mtu1500_shard_payload())
}
/// Largest even shard payload whose sealed datagram fits in `udp_budget` bytes of UDP payload
/// (the quantity QUIC MTU discovery measures — [`video_datagram_udp_ceiling`] is its probe
/// ceiling). Clamped to the peer's family default ([`mtu1500_shard_payload_for`]) so a generous
/// budget never grows packets past today's wire, and floored at [`MIN_SHARD_PAYLOAD`].
pub fn shard_payload_for_udp_budget(udp_budget: usize, peer: core::net::IpAddr) -> usize {
let p = udp_budget.saturating_sub(HEADER_LEN + CRYPTO_OVERHEAD);
let p = p - p % 2; // FEC requires even shards
p.clamp(MIN_SHARD_PAYLOAD, mtu1500_shard_payload_for(peer))
}
/// The family's IP+UDP header bytes between an on-wire IP MTU and its UDP payload budget —
/// 28 for IPv4 (and IPv4-mapped), 48 for IPv6.
fn ip_udp_overhead(peer: core::net::IpAddr) -> usize {
match peer {
core::net::IpAddr::V4(_) => 28,
core::net::IpAddr::V6(v6) if v6.to_ipv4_mapped().is_some() => 28,
core::net::IpAddr::V6(_) => 48,
}
}
/// [`shard_payload_for_udp_budget`] for an operator-supplied ON-WIRE IP MTU (the number
/// `netsh interface ipv4 show subinterfaces` / `ip link` shows): subtracts the family's IP+UDP
/// headers first — 28 for IPv4 (and IPv4-mapped), 48 for IPv6.
pub fn shard_payload_for_wire_mtu(wire_mtu: usize, peer: core::net::IpAddr) -> usize {
shard_payload_for_udp_budget(wire_mtu.saturating_sub(ip_udp_overhead(peer)), peer)
}
/// The operator's jumbo-frames opt-in (design/shard-payload-reneg.md Phase 2): the target
/// on-wire IP MTU, or `None` = no opt-in (nothing above the 1500-default wire is ever probed
/// or grown to). One knob, one code path: a `PUNKTFUNK_WIRE_MTU` above the standard 1500
/// derives the target from the operator's number; `PUNKTFUNK_JUMBO=1` is the fixed 9000
/// profile for operators who don't want to think in MTUs. Raising the wire above 1500 is
/// only ever an ACK-GATED mid-session grow toward a client that advertised
/// [`max_shard_payload`] headroom — sessions still START at the family default.
pub fn jumbo_wire_mtu() -> Option<usize> {
if let Ok(v) = std::env::var("PUNKTFUNK_WIRE_MTU") {
if let Ok(mtu) = v.trim().parse::<usize>() {
if mtu > 1500 {
return Some(mtu);
}
}
}
match std::env::var("PUNKTFUNK_JUMBO") {
Ok(v) if v.trim() == "1" => Some(9000),
_ => None,
}
}
/// The jumbo sibling of [`shard_payload_for_wire_mtu`]: the largest even shard payload whose
/// sealed datagram fits `wire_mtu`, clamped to the RECEIVE ceiling ([`max_shard_payload`])
/// instead of the family 1500-default — the up-leg's grow target. Still floored at
/// [`MIN_SHARD_PAYLOAD`].
pub fn jumbo_shard_payload_for(wire_mtu: usize, peer: core::net::IpAddr) -> usize {
let p = wire_mtu
.saturating_sub(ip_udp_overhead(peer))
.saturating_sub(HEADER_LEN + CRYPTO_OVERHEAD);
let p = p - p % 2; // FEC requires even shards
p.clamp(MIN_SHARD_PAYLOAD, max_shard_payload())
}
/// Everything needed to construct a [`Session`](crate::session::Session).
///
/// `Debug` is implemented by hand to redact `key`/`salt`, and `key`/`salt` are zeroized
@@ -514,6 +596,97 @@ mod tests {
assert!(HEADER_LEN + (p + 2) + CRYPTO_OVERHEAD > 1452, "not maximal");
}
/// The video-datagram ceiling IS the exact v4 sealed size — the QUIC MTU-discovery probe
/// ceiling (endpoint.rs) relies on this equality for its settled-at-vs-below verdict.
#[test]
fn video_datagram_ceiling_is_the_sealed_default() {
assert_eq!(
video_datagram_udp_ceiling(),
HEADER_LEN + mtu1500_shard_payload() + CRYPTO_OVERHEAD
);
assert_eq!(video_datagram_udp_ceiling(), 1472);
}
/// Budget-derived sizing: even, sealed-fits-the-budget, clamped to the family default
/// above and [`MIN_SHARD_PAYLOAD`] below.
#[test]
fn shard_payload_for_udp_budget_math() {
use core::net::IpAddr;
let v4: IpAddr = "192.168.1.50".parse().unwrap();
let v6: IpAddr = "fd00::50".parse().unwrap();
// The full ceiling reproduces the default exactly.
assert_eq!(
shard_payload_for_udp_budget(video_datagram_udp_ceiling(), v4),
mtu1500_shard_payload()
);
// A WARP/Tailscale-shaped 1280 budget: sealed result must fit the budget, stay even.
let p = shard_payload_for_udp_budget(1280, v4);
assert_eq!(p % 2, 0);
assert!(sealed_datagram_bytes(p) <= 1280);
assert!(sealed_datagram_bytes(p + 2) > 1280, "not maximal");
// Odd budgets round down to even shards.
assert_eq!(shard_payload_for_udp_budget(1281, v4) % 2, 0);
// A generous budget never grows past the family default (either family).
assert_eq!(
shard_payload_for_udp_budget(9000, v4),
mtu1500_shard_payload()
);
assert_eq!(
shard_payload_for_udp_budget(9000, v6),
mtu1500_shard_payload_v6()
);
// Degenerate budgets bottom out at the floor instead of confetti.
assert_eq!(shard_payload_for_udp_budget(100, v4), MIN_SHARD_PAYLOAD);
}
/// Operator-facing wire-MTU sizing subtracts the right IP+UDP header per family, and 1500
/// reproduces today's defaults exactly.
#[test]
fn shard_payload_for_wire_mtu_math() {
use core::net::IpAddr;
let v4: IpAddr = "192.168.1.50".parse().unwrap();
let v6: IpAddr = "fd00::50".parse().unwrap();
let mapped: IpAddr = "::ffff:192.168.1.50".parse().unwrap();
assert_eq!(
shard_payload_for_wire_mtu(1500, v4),
mtu1500_shard_payload()
);
assert_eq!(
shard_payload_for_wire_mtu(1500, mapped),
mtu1500_shard_payload()
);
assert_eq!(
shard_payload_for_wire_mtu(1500, v6),
mtu1500_shard_payload_v6()
);
// 1280 wire 28 64 = 1188 (v4); 48 64 = 1168 (v6).
assert_eq!(shard_payload_for_wire_mtu(1280, v4), 1188);
assert_eq!(shard_payload_for_wire_mtu(1280, v6), 1168);
}
/// Jumbo grow-target sizing (the up-leg, design/shard-payload-reneg.md): even, sealed
/// fits the wire, clamped to the RECEIVE ceiling instead of the family 1500-default —
/// and the standard 9000 profile lands on the exact documented value.
#[test]
fn jumbo_shard_payload_math() {
use core::net::IpAddr;
let v4: IpAddr = "192.168.1.50".parse().unwrap();
let v6: IpAddr = "fd00::50".parse().unwrap();
// 9000 28 (IPv4+UDP) 64 (header+crypto) = 8908 even; sealed 8972 ≤ the 9216
// datagram ceiling. The v6 sibling: 9000 48 64 = 8888.
assert_eq!(jumbo_shard_payload_for(9000, v4), 8908);
assert_eq!(sealed_datagram_bytes(8908), 8972);
assert!(sealed_datagram_bytes(8908) <= MAX_DATAGRAM_BYTES);
assert_eq!(jumbo_shard_payload_for(9000, v6), 8888);
// An operator MTU larger than the receive path clamps to the ceiling, smaller ones
// track the wire, and degenerate ones floor at MIN_SHARD_PAYLOAD.
assert_eq!(jumbo_shard_payload_for(64_000, v4), max_shard_payload());
let p = jumbo_shard_payload_for(4000, v4);
assert_eq!(p % 2, 0);
assert!(sealed_datagram_bytes(p) <= 4000 - 28);
assert_eq!(jumbo_shard_payload_for(100, v4), MIN_SHARD_PAYLOAD);
}
/// Family selection: genuine v6 remotes get the v6 size; v4 — including the IPv4-mapped v6
/// form a dual-stack `[::]` socket reports for a v4 client — keeps the v4 size.
#[test]
+11 -1
View File
@@ -70,7 +70,17 @@ pub const CRYPTO_OVERHEAD: usize = 8 + crate::crypto::TAG_LEN;
/// Largest UDP datagram the core will send or accept. `Config::validate` bounds
/// `shard_payload` so `HEADER_LEN + shard_payload + CRYPTO_OVERHEAD ≤ MAX_DATAGRAM_BYTES`.
pub const MAX_DATAGRAM_BYTES: usize = 2048;
///
/// Sized for **jumbo frames** (design/shard-payload-reneg.md W0.2): a 9000-MTU LAN carries
/// ~8908-byte shards (sealed 8972-byte UDP payloads), and every receive path — the transport
/// `RECV_BUF`, the session's `recvmmsg` ring — is sized from this constant, so a deployed
/// client can accept a jumbo geometry the moment its host negotiates one. The ring cost is
/// 128 × ~9 KiB ≈ 1.1 MiB per **client** session (lazily allocated on first poll; hosts never
/// allocate it) — measured against the ~256 KiB it was at 2048, an acceptable static price
/// for never having to resize buffers on a mid-session grow. Senders still derive their
/// shard payload from the path MTU (`config::mtu1500_shard_payload*`, the wire-MTU clamps);
/// this is the acceptance ceiling, not a transmit size.
pub const MAX_DATAGRAM_BYTES: usize = 9216;
/// Fixed per-packet header. `#[repr(C)]`, no padding, zero-copy (de)serializable.
#[repr(C)]
+43 -15
View File
@@ -25,6 +25,10 @@ pub struct Packetizer {
next_probe_index: u32,
next_seq: u32,
shard_payload: usize,
/// The negotiated frame-size cap — kept so a live shard-payload swap
/// ([`set_shard_payload`](Self::set_shard_payload)) can re-derive the per-frame block
/// ceilings from the same formulas construction used.
max_frame_bytes: usize,
fec: crate::config::FecConfig,
version: u8,
/// Reusable zero-padded scratch for the frame's final data shard when the frame isn't an
@@ -47,10 +51,12 @@ pub struct Packetizer {
/// where every packet of the block is dropped wholesale, the frame never completes, and the
/// resulting loss pushes adaptive FEC *higher*. See the `recovery_for` clamp in `packetize_each`.
max_total_shards: usize,
/// The peer's per-frame block ceiling, mirroring [`ReassemblerLimits::from_config`]'s
/// `max_blocks` — the streamed path's bound on how many sentinel blocks it may emit (a
/// streamed AU's size isn't known up front, so this is the only pre-emission guard against
/// producing a frame the receiver must reject).
/// The peer's per-frame block ceiling — the streamed path's bound on how many sentinel
/// blocks it may emit (a streamed AU's size isn't known up front, so this is the only
/// pre-emission guard against producing a frame the receiver must reject). The receiver
/// derives the same ceiling per packet from the packet's own `shard_bytes`
/// (`Reassembler::push` — geometry is per-frame), so this stays in step as long as it is
/// computed from the shard size this packetizer actually stamps.
max_blocks: usize,
/// The streamed path's block-count ceiling in SLICE mode ([`USER_FLAG_SLICE_STREAM`]) —
/// variable-K blocks, floored at `min(MIN_STREAM_BLOCK_SHARDS, max_data_per_block)` shards.
@@ -105,15 +111,12 @@ impl StreamedAu {
impl Packetizer {
pub fn new(config: &Config) -> Self {
let max_data = config.fec.max_data_per_block as usize;
let total_data_max = config
.max_frame_bytes
.div_ceil(config.shard_payload.max(1))
.max(1);
Packetizer {
let mut p = Packetizer {
next_frame_index: 0,
next_probe_index: 0,
next_seq: 0,
shard_payload: config.shard_payload,
max_frame_bytes: config.max_frame_bytes,
fec: config.fec,
version: config.phase as u8,
tail: Vec::new(),
@@ -121,12 +124,37 @@ impl Packetizer {
// Mirrors `ReassemblerLimits::from_config` — keep the two in step.
max_total_shards: (max_data + config.fec.recovery_for(max_data))
.min(config.fec.scheme.max_total_shards()),
max_blocks: total_data_max.div_ceil(max_data).max(1),
// Every non-final SLICE block carries at least `min(MIN_STREAM_BLOCK_SHARDS, K)`
// data shards (the flush floor, clamped by the block size), so a max-size frame
// bounds the block count. Mirrors the receiver's slice firewall — keep in step.
slice_block_cap: total_data_max / MIN_STREAM_BLOCK_SHARDS.min(max_data.max(1)) + 2,
}
// Derived from the shard size below (single source of truth for the formulas).
max_blocks: 0,
slice_block_cap: 0,
};
p.set_shard_payload(config.shard_payload);
p
}
/// Live-swap the wire shard payload (mid-session shard renegotiation,
/// design/shard-payload-reneg.md Phase 1). Takes effect on the next packetized AU — call
/// ONLY between AUs, never with a [`StreamedAu`] in flight: an open streamed AU's
/// shard-aligned tiling derives from the size it began with, and re-keying under it would
/// corrupt the frame's layout. The per-frame block ceilings follow the new size here; the
/// receiver re-derives its side per packet from the header's own `shard_bytes` (geometry
/// is per-frame there), so the two stay in step by construction. Bounds are the caller's
/// contract — go through [`Session::set_shard_payload`](crate::session::Session::set_shard_payload),
/// which enforces the `Config::validate` rules.
pub fn set_shard_payload(&mut self, shard_payload: usize) {
let max_data = self.fec.max_data_per_block as usize;
let total_data_max = self.max_frame_bytes.div_ceil(shard_payload.max(1)).max(1);
self.shard_payload = shard_payload;
self.max_blocks = total_data_max.div_ceil(max_data).max(1);
// Every non-final SLICE block carries at least `min(MIN_STREAM_BLOCK_SHARDS, K)`
// data shards (the flush floor, clamped by the block size), so a max-size frame
// bounds the block count. Mirrors the receiver's slice firewall — keep in step.
self.slice_block_cap = total_data_max / MIN_STREAM_BLOCK_SHARDS.min(max_data.max(1)) + 2;
}
/// The wire shard payload AUs are currently packetized at.
pub fn shard_payload(&self) -> usize {
self.shard_payload
}
/// Allocate the next **probe-space** frame index (speed-test filler). A separate counter from
+64 -14
View File
@@ -76,6 +76,12 @@ struct BlockState {
}
struct FrameBuf {
/// The frame's PINNED shard payload — set by its first-arriving packet (bounds-checked by
/// the firewall), matched by every later packet of the frame. Geometry is per-frame so a
/// mid-session `shard_payload` change (design/shard-payload-reneg.md) is safe on an
/// unordered wire: frames in flight complete under their own pin while new frames arrive
/// under the new one, and no cross-geometry splice can land in one buffer.
shard_bytes: usize,
/// Exact AU size. 0 = unknown: the frame was opened by a streamed-AU SENTINEL packet
/// ([`crate::quic::VIDEO_CAP_STREAMED_AU`]) and the final block's real totals haven't
/// arrived yet — the frame can't complete before they do (and retro-validate).
@@ -105,16 +111,28 @@ struct FrameBuf {
/// Per-session bounds the reassembler enforces on every packet header *before*
/// allocating, so a hostile or corrupt header cannot drive unbounded memory use. All
/// derived from the negotiated [`Config`].
///
/// Shard geometry is PER-FRAME, not per-session (mid-session shard-payload renegotiation,
/// design/shard-payload-reneg.md W0.1): a frame's first-arriving packet pins the frame's
/// `shard_bytes` within `[min_shard_bytes, max_shard_bytes]`, later packets must match the
/// pin, and the per-frame block ceiling derives from the pinned size (a shrunk shard needs
/// more blocks for the same bytes). The reorder race between an ordered control-stream
/// geometry change and the unordered video datagrams is thereby killed structurally — every
/// frame is wholly one geometry, whichever order its packets and the change arrive in.
#[derive(Clone, Copy, Debug)]
pub struct ReassemblerLimits {
/// Expected shard payload length; every shard in the stream must match exactly.
pub shard_bytes: usize,
/// Floor for a frame's pinned shard payload — [`crate::config::MIN_SHARD_PAYLOAD`] in
/// production (or the negotiated value when a session legitimately starts below it).
pub min_shard_bytes: usize,
/// Ceiling for a frame's pinned shard payload — what this receive path accepts and what
/// the client advertises in `Hello::max_shard_payload`
/// ([`crate::config::max_shard_payload`]): the transport recv buffers are sized for a
/// sealed datagram of exactly this shard size.
pub max_shard_bytes: usize,
/// Max data shards per block (the negotiated `max_data_per_block`).
pub max_data_shards: usize,
/// Max total shards per block (data + recovery), capped by the FEC scheme ceiling.
pub max_total_shards: usize,
/// Max FEC blocks per frame.
pub max_blocks: usize,
/// Max accepted access-unit size.
pub max_frame_bytes: usize,
}
@@ -135,12 +153,13 @@ impl ReassemblerLimits {
// snapshot of it.
let max_total =
(max_data + (max_data * 90).div_ceil(100)).min(c.fec.scheme.max_total_shards());
let total_data = c.max_frame_bytes.div_ceil(c.shard_payload.max(1)).max(1);
ReassemblerLimits {
shard_bytes: c.shard_payload,
// `.min(c.shard_payload)`: never reject the session's own negotiated value — a
// hand-configured session below the production floor still reassembles itself.
min_shard_bytes: crate::config::MIN_SHARD_PAYLOAD.min(c.shard_payload),
max_shard_bytes: crate::config::max_shard_payload(),
max_data_shards: max_data,
max_total_shards: max_total,
max_blocks: total_data.div_ceil(max_data).max(1),
max_frame_bytes: c.max_frame_bytes,
}
}
@@ -179,6 +198,9 @@ const IN_FLIGHT_BUF_FACTOR: usize = 4;
/// Recovery-shard buffer pool ceiling (shard-sized buffers): enough for several max-recovery
/// blocks in flight, small enough (~720 KB at a 1408-byte shard) to keep after a loss burst.
/// Entries size themselves to the largest shard they ever held, so a jumbo session (opt-in,
/// desktop-LAN — shards up to [`ReassemblerLimits::max_shard_bytes`]) retains proportionally
/// more; it also needs ~6× fewer buffers per block, so the pool rarely fills there.
const RECOVERY_POOL_MAX: usize = 512;
/// Buffers incoming shards, recovers lost ones via FEC, and emits whole access units.
@@ -295,11 +317,16 @@ impl Reassembler {
// Bound every attacker-controllable header field against the negotiated limits
// BEFORE allocating anything keyed on it — this is the firewall against a tiny
// datagram triggering a huge `vec![None; total]` / `Vec::with_capacity`.
// `shard_bytes` is bounds-checked (not equality-checked) because geometry is
// per-frame — the frame-pin check below is what rejects a size CHANGE mid-frame;
// the even requirement mirrors `Config::validate` (FEC requires even shards).
let drop = |stats: &StatsCounters| {
StatsCounters::add(&stats.packets_dropped, 1);
};
if hdr.magic != PUNKTFUNK_MAGIC
|| shard_bytes != lim.shard_bytes
|| shard_bytes < lim.min_shard_bytes
|| shard_bytes > lim.max_shard_bytes
|| shard_bytes % 2 != 0
|| pkt.len() < HEADER_LEN + shard_bytes
|| data_shards == 0
|| data_shards > lim.max_data_shards
@@ -330,6 +357,11 @@ impl Reassembler {
// later pin — the maximum the negotiated limits allow (the design's "allocate at
// max_frame_bytes"; the existing in-flight budget bounds the amplification).
let total_data_max = lim.max_frame_bytes.div_ceil(shard_bytes).max(1);
// The per-frame FEC-block ceiling under THIS packet's shard size (geometry is
// per-frame: a shrunk shard needs more blocks for the same bytes, so a session-level
// cap from the negotiated size would reject legitimate post-shrink frames). Mirrors
// the sender's `Packetizer::new` for whatever size it currently packetizes at.
let max_blocks = total_data_max.div_ceil(lim.max_data_shards).max(1);
// The slice pipeline's per-frame block ceiling: every non-final slice block carries at
// least `min(MIN_STREAM_BLOCK_SHARDS, max_data_per_block)` data shards (the sender's
// flush floor, clamped by the block size), so a max-size frame bounds the block count
@@ -350,9 +382,7 @@ impl Reassembler {
return Ok(None);
}
} else if sentinel {
if frame_bytes != 0
|| data_shards != lim.max_data_shards
|| block_idx + 1 >= lim.max_blocks
if frame_bytes != 0 || data_shards != lim.max_data_shards || block_idx + 1 >= max_blocks
{
drop(stats);
return Ok(None);
@@ -361,7 +391,7 @@ impl Reassembler {
let block_cap = if slice_stream {
slice_block_cap
} else {
lim.max_blocks
max_blocks
};
if block_count > block_cap || block_idx >= block_count {
drop(stats);
@@ -513,6 +543,7 @@ impl Reassembler {
}
*in_flight_bytes += buf_len;
e.insert(FrameBuf {
shard_bytes,
// A slice-stream sentinel's `frame_bytes` is its block's BASE offset, not a
// frame size — the unpinned marker stays 0 until the final block's totals.
frame_bytes: if sentinel { 0 } else { frame_bytes },
@@ -527,6 +558,15 @@ impl Reassembler {
})
}
};
// Per-frame geometry pin: the frame's first packet pinned its shard size; a later
// packet claiming a different (even in-bounds) size is dropped — otherwise two
// geometries would compute different offsets into one buffer (a splice). This is
// also what makes a mid-session `shard_payload` change safe against reorder: a
// straggler of the old geometry can only ever land in ITS OWN frame's buffer.
if frame.shard_bytes != shard_bytes {
drop(stats);
return Ok(None);
}
// The slice marker must be frame-consistent: a mixed frame would firewall under one
// placement rule and place under the other. The per-packet checks above and the
// placement bounds guard below stay memory-safe without this — it's the tighter drop.
@@ -883,6 +923,16 @@ impl Reassembler {
// jump-to-live, exactly the stale content the flush existed to discard.
self.pending_partial = None;
}
/// Test-only: the current in-flight frame-buffer byte commitment (see
/// [`IN_FLIGHT_BUF_FACTOR`]). The mixed-geometry budget tests assert it returns to
/// exactly zero once every frame has terminated — the 0.23.0 lesson: geometry changes
/// breed sizing bugs, and accounting drift here surfaces in the field as a permanent
/// loss storm once the budget wedges.
#[cfg(test)]
pub(crate) fn in_flight(&self) -> usize {
self.in_flight_bytes
}
}
/// The data shards of a terminating frame that only exist because parity restored them
@@ -1024,10 +1074,10 @@ mod reset_tests {
#[test]
fn reset_drops_a_parked_partial() {
let mut r = Reassembler::new(ReassemblerLimits {
shard_bytes: 64,
min_shard_bytes: 64,
max_shard_bytes: 64,
max_data_shards: 8,
max_total_shards: 16,
max_blocks: 4,
max_frame_bytes: 4096,
});
r.pending_partial = Some(Frame {
+411 -3
View File
@@ -7,11 +7,14 @@ use crate::stats::StatsCounters;
use zerocopy::{FromBytes, IntoBytes};
fn limits() -> ReassemblerLimits {
// `min == max` pins the whole stream to 16-byte shards — the strictest geometry, so the
// firewall tests below exercise the bounds checks; per-frame-pinning tests build their own
// limits with a real range. Derived per-frame block ceiling: 4096/16 = 256 shards → 32.
ReassemblerLimits {
shard_bytes: 16,
min_shard_bytes: 16,
max_shard_bytes: 16,
max_data_shards: 8,
max_total_shards: 12,
max_blocks: 4,
max_frame_bytes: 4096,
}
}
@@ -840,7 +843,7 @@ fn streamed_sentinel_firewall_bounds() {
.unwrap()
.is_none());
// Sits on the last block the limits allow (no room for the final block after it).
let h = sentinel(|h| h.block_index = 3); // limits().max_blocks == 4
let h = sentinel(|h| h.block_index = 31); // derived max_blocks == 32 (see `limits()`)
assert!(r
.push(&packet(h), coder.as_ref(), &stats)
.unwrap()
@@ -1769,3 +1772,408 @@ fn slice_streamed_in_flight_budget_matches_legacy() {
);
}
}
// ---------------------------------------------------------------------------
// Per-frame shard geometry (mid-session shard-payload renegotiation — W0.1,
// design/shard-payload-reneg.md). The 0.23.0 lesson applies in full: geometry
// changes breed sizing bugs, so the slice/sentinel suite re-runs at every
// production shard size and mixed-geometry streams are tortured under reorder.
// ---------------------------------------------------------------------------
/// The shard sizes the renegotiation actually moves between: the clamp floor (512), a
/// WARP/Tailscale-shaped 1280-MTU path (1216), the 1500-MTU default (1408), and 9000-MTU
/// jumbo (8908 — sealed 8972, inside [`MAX_DATAGRAM_BYTES`]).
const PRODUCTION_SHARDS: [usize; 4] = [512, 1216, 1408, 8908];
/// [`prod_slice_config`] at an arbitrary shard payload.
fn geo_config(shard_payload: usize) -> Config {
let mut c = prod_slice_config();
c.shard_payload = shard_payload;
c.validate().expect("geometry config must be valid");
c
}
/// Packetize one legacy AU at the packetizer's CURRENT shard payload with an explicit
/// frame index, returning wire packets + source bytes.
fn legacy_packets_with(
pk: &mut Packetizer,
frame_index: u32,
pts_ns: u64,
len: usize,
coder: &dyn crate::fec::ErasureCoder,
) -> (Vec<Vec<u8>>, Vec<u8>) {
let src: Vec<u8> = (0..len)
.map(|i| (i * 131 + frame_index as usize * 7 + 3) as u8)
.collect();
let mut pkts: Vec<Vec<u8>> = Vec::new();
pk.packetize_each(&src, pts_ns, 0, Some(frame_index), coder, |h, b| {
let mut p = Vec::with_capacity(HEADER_LEN + b.len());
p.extend_from_slice(h.as_bytes());
p.extend_from_slice(b);
pkts.push(p);
Ok(())
})
.unwrap();
(pkts, src)
}
/// The slice-wire regression suite re-run at every production shard size (the design's
/// non-negotiable verification): the exact-multiple sweep (the 0.23.0 filler-shard bug
/// shape), lossy + reversed slice roundtrips, the legacy-streamed sentinel path, and the
/// in-flight budget — each asserting DELIVERED byte-identical frames, never just an
/// absence of errors.
#[test]
fn slice_wire_suite_at_production_shard_sizes() {
let coder = coder_for(FecScheme::Gf16);
for &shard in &PRODUCTION_SHARDS {
let cfg = geo_config(shard);
// Exact-shard-multiple AUs + the off-by-one sweep around one of them.
for shards in [16usize, 30, 64] {
for extra in 0..3usize {
let n = shards * shard + extra;
let (pkts, src) = streamed_packets_with(&cfg, 1, 1000, true, &[n]);
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
let stats = StatsCounters::default();
let f = push_all(&mut r, coder.as_ref(), &stats, &pkts)
.unwrap_or_else(|| panic!("shard {shard}: {n}-byte slice AU must complete"));
assert_eq!(
f.data, src,
"shard {shard}: {n}-byte AU must be byte-identical"
);
assert_eq!(
r.in_flight(),
0,
"shard {shard}: budget must return to zero"
);
}
}
// A multi-slice AU under loss (one data shard of the first flushed block — within
// its ≥ 20% parity) in both delivery orders. Reversed is the critical order: the
// final block's totals arrive first and every sentinel validates against the pin.
for reverse in [false, true] {
let chunks = [20 * shard + 13, 7 * shard + 1, 17 * shard];
let (pkts, src) = streamed_packets_with(&cfg, 2, 2000, true, &chunks);
let killed = pkts
.iter()
.position(|p| {
let h = PacketHeader::read_from_bytes(&p[..HEADER_LEN]).unwrap();
h.shard_index < h.data_shards && h.recovery_shards >= 1
})
.expect("suite frame must have a recoverable data shard");
let mut delivery: Vec<Vec<u8>> = pkts
.iter()
.enumerate()
.filter(|(i, _)| *i != killed)
.map(|(_, p)| p.clone())
.collect();
if reverse {
delivery.reverse();
}
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
let stats = StatsCounters::default();
let f = push_all(&mut r, coder.as_ref(), &stats, &delivery).unwrap_or_else(|| {
panic!("shard {shard} reverse={reverse}: lossy slice AU must complete")
});
assert_eq!(f.data, src, "shard {shard} reverse={reverse}");
assert_eq!(r.in_flight(), 0);
}
// Legacy-streamed (uniform full-K sentinel) path: one AU spanning a sentinel block
// (K = 200) plus a final block.
{
let (pkts, src) = streamed_packets_with(&cfg, 3, 3000, false, &[230 * shard]);
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
let stats = StatsCounters::default();
let f = push_all(&mut r, coder.as_ref(), &stats, &pkts)
.unwrap_or_else(|| panic!("shard {shard}: legacy-streamed AU must complete"));
assert_eq!(f.data, src);
assert_eq!(r.in_flight(), 0);
}
// The budget regression at this size: 12 ordinary AUs opened concurrently, no drops.
for slice in [false, true] {
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
let stats = StatsCounters::default();
for i in 0..12u32 {
let (pkts, _) =
streamed_packets_with(&cfg, i, 1_000_000 * i as u64, slice, &[40_000]);
r.push(&pkts[0], coder.as_ref(), &stats).unwrap();
}
assert_eq!(
stats
.packets_dropped
.load(std::sync::atomic::Ordering::Relaxed),
0,
"shard {shard} slice={slice}: 12 AUs in flight must fit the budget"
);
}
}
}
/// One packetizer, one reassembler, one continuous stream — the shard payload swapped
/// live between AUs ([`Packetizer::set_shard_payload`], the Phase 1 host seam): every
/// frame across shrink → grow-to-jumbo → shrink-again delivers byte-identically under its
/// own per-frame pin, and the budget returns to zero.
#[test]
fn mid_stream_shard_swap_delivers_every_frame() {
let cfg = geo_config(1408);
let coder = coder_for(FecScheme::Gf16);
let mut pk = Packetizer::new(&cfg);
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
let stats = StatsCounters::default();
// (shard size to swap to, AU length) — swaps happen between AUs, as Phase 1 will.
let schedule = [
(1408usize, 3 * 1408 + 100),
(1408, 9 * 1408),
(512, 5 * 512 + 17), // shrink (the VPN heal)
(512, 512),
(8908, 12 * 8908 + 1), // grow (jumbo)
(1216, 4 * 1216 + 9), // revert (a mis-proven jumbo hop self-corrects)
];
for (i, &(shard, len)) in schedule.iter().enumerate() {
pk.set_shard_payload(shard);
let pts = 1_000_000 * (i as u64 + 1);
let (pkts, src) = legacy_packets_with(&mut pk, i as u32, pts, len, coder.as_ref());
for p in &pkts {
let h = PacketHeader::read_from_bytes(&p[..HEADER_LEN]).unwrap();
assert_eq!(
h.shard_bytes as usize, shard,
"sender must stamp the live size"
);
}
let f = push_all(&mut r, coder.as_ref(), &stats, &pkts)
.unwrap_or_else(|| panic!("frame {i} at shard {shard} must complete"));
assert_eq!(
f.data, src,
"frame {i} at shard {shard} must be byte-identical"
);
assert!(f.complete);
}
assert_eq!(
r.in_flight(),
0,
"budget must be exact across geometry swaps"
);
assert_eq!(stats.snapshot().frames_dropped, 0);
}
/// The reorder race the design kills structurally: an old-geometry frame still in flight
/// when new-geometry frames start arriving completes under its OWN pin — its straggler
/// lands in its own buffer, not the new geometry's.
#[test]
fn old_geometry_frame_completes_after_new_geometry_arrived() {
let cfg = geo_config(1408);
let coder = coder_for(FecScheme::Gf16);
let mut pk = Packetizer::new(&cfg);
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
let stats = StatsCounters::default();
// Frame 0 at 1408: 7 data shards + 2 parity (20% FEC), data-first wire order. Withhold
// THREE data shards — more than parity can bridge — so the frame genuinely stays
// incomplete until a straggler returns (fewer, and FEC would complete it early).
let (pkts0, src0) = legacy_packets_with(&mut pk, 0, 1_000_000, 6 * 1408 + 50, coder.as_ref());
assert_eq!(
pkts0.len(),
9,
"expected geometry changed — update the split"
);
let head: Vec<Vec<u8>> = pkts0[..4].iter().chain(&pkts0[7..]).cloned().collect();
let straggler = &pkts0[4];
assert!(
push_all(&mut r, coder.as_ref(), &stats, &head).is_none(),
"frame 0 must still be incomplete"
);
// The stream re-keys to 512: frames 1..=2 arrive whole and deliver.
pk.set_shard_payload(512);
for i in 1..=2u32 {
let pts = 1_000_000 + 1_000_000 * i as u64;
let (pkts, src) = legacy_packets_with(&mut pk, i, pts, 3 * 512 + 7, coder.as_ref());
let f = push_all(&mut r, coder.as_ref(), &stats, &pkts).expect("new-geometry frame");
assert_eq!(f.data, src);
}
// Frame 0's old-geometry straggler arrives last — the frame completes byte-identically.
let f = r
.push(straggler, coder.as_ref(), &stats)
.unwrap()
.expect("old-geometry frame must complete under its own pin");
assert_eq!(f.data, src0);
assert_eq!(f.frame_index, 0);
assert_eq!(r.in_flight(), 0);
assert_eq!(stats.snapshot().frames_dropped, 0);
}
/// The anti-splice pin: a packet claiming a DIFFERENT (but in-bounds) shard size for an
/// already-pinned frame is dropped — and the frame still completes from its real packets.
#[test]
fn cross_geometry_packet_for_a_pinned_frame_is_dropped() {
let cfg = geo_config(1408);
let coder = coder_for(FecScheme::Gf16);
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
let stats = StatsCounters::default();
let mut pk_a = Packetizer::new(&geo_config(1408));
let mut pk_b = Packetizer::new(&geo_config(1216));
let (pkts, src) = legacy_packets_with(&mut pk_a, 0, 1_000_000, 5 * 1408 + 9, coder.as_ref());
// The impostor: the same frame index packetized at 1216 — self-consistent (it passes
// the firewall standalone), wrong for THIS frame's pin.
let (impostor, _) = legacy_packets_with(&mut pk_b, 0, 1_000_000, 5 * 1216, coder.as_ref());
assert!(r.push(&pkts[0], coder.as_ref(), &stats).unwrap().is_none());
let before = stats.snapshot().packets_dropped;
assert!(r
.push(&impostor[1], coder.as_ref(), &stats)
.unwrap()
.is_none());
assert_eq!(
stats.snapshot().packets_dropped,
before + 1,
"cross-geometry packet must be dropped by the frame pin"
);
let f = push_all(&mut r, coder.as_ref(), &stats, &pkts[1..])
.expect("the pinned frame must still complete from its real packets");
assert_eq!(f.data, src, "no impostor bytes may reach the frame");
}
/// The firewall bounds on a frame's pinned size: below the floor, above the receive
/// ceiling, or odd ⇒ dropped before any allocation; the exact floor and ceiling are
/// accepted AND deliver (proving the rejections aren't vacuous).
#[test]
fn shard_size_firewall_bounds() {
let cfg = geo_config(1408);
let lim = ReassemblerLimits::from_config(&cfg);
assert_eq!(lim.min_shard_bytes, crate::config::MIN_SHARD_PAYLOAD);
assert_eq!(lim.max_shard_bytes, crate::config::max_shard_payload());
let coder = coder_for(FecScheme::Gf16);
let mut r = Reassembler::new(lim);
let stats = StatsCounters::default();
let single = |shard: usize, frame_index: u32| {
let mut h = base_header();
h.frame_index = frame_index;
h.shard_bytes = shard as u16;
h.frame_bytes = shard as u32;
h
};
// Below the floor (even), above the ceiling (even), odd within bounds: all dropped.
for (i, shard) in [510usize, 9154, 1409].into_iter().enumerate() {
let before = stats.snapshot().packets_dropped;
assert!(r
.push(&packet(single(shard, i as u32)), coder.as_ref(), &stats)
.unwrap()
.is_none());
assert_eq!(
stats.snapshot().packets_dropped,
before + 1,
"shard {shard} must be firewalled"
);
}
// The exact bounds deliver whole single-shard frames.
for (i, shard) in [
crate::config::MIN_SHARD_PAYLOAD,
crate::config::max_shard_payload(),
]
.into_iter()
.enumerate()
{
let f = r
.push(
&packet(single(shard, 10 + i as u32)),
coder.as_ref(),
&stats,
)
.unwrap()
.unwrap_or_else(|| panic!("boundary shard {shard} must deliver"));
assert_eq!(f.data.len(), shard);
}
}
mod geometry_proptests {
use super::*;
use proptest::prelude::*;
/// One generated frame: shard size, slice-vs-legacy wire, size factor, and whether to
/// kill one recoverable data shard.
type GenFrame = (usize, bool, usize, bool);
fn frame_strategy() -> impl Strategy<Value = GenFrame> {
(
proptest::sample::select(&PRODUCTION_SHARDS[..]),
any::<bool>(),
1usize..30,
any::<bool>(),
)
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(48))]
/// Mixed-geometry reorder torture: frames of DIFFERENT shard sizes and wire shapes
/// interleaved into one shuffled delivery, with per-frame recoverable loss — every
/// frame must deliver byte-identically and the in-flight budget must return to
/// exactly zero (the 0.23.0 budget-drift shape, now across geometries).
#[test]
fn mixed_geometry_reorder_torture(
frames in proptest::collection::vec(frame_strategy(), 2..6),
seed in any::<u64>(),
) {
let coder = coder_for(FecScheme::Gf16);
let mut r = Reassembler::new(ReassemblerLimits::from_config(&geo_config(1408)));
let stats = StatsCounters::default();
let mut all: Vec<(u64, u32, Vec<u8>)> = Vec::new(); // (shuffle key, frame, pkt)
let mut sources: Vec<(u32, Vec<u8>)> = Vec::new();
for (i, &(shard, slice, factor, kill)) in frames.iter().enumerate() {
let cfg = geo_config(shard);
let pts = 1_000_000 * (i as u64 + 1);
let len = factor * shard + (factor % shard.min(7));
let (mut pkts, src) = if slice {
streamed_packets_with(&cfg, i as u32, pts, true, &[len.max(1)])
} else {
let mut pk = Packetizer::new(&cfg);
legacy_packets_with(&mut pk, i as u32, pts, len.max(1), coder.as_ref())
};
if kill {
if let Some(k) = pkts.iter().position(|p| {
let h = PacketHeader::read_from_bytes(&p[..HEADER_LEN]).unwrap();
h.shard_index < h.data_shards && h.recovery_shards >= 1
}) {
pkts.remove(k);
}
}
for (j, p) in pkts.into_iter().enumerate() {
// Deterministic pseudo-shuffle key: interleaves frames and reorders
// within a frame, differently per proptest case.
let key = (seed | 1)
.wrapping_mul(j as u64 + 1)
.wrapping_add((i as u64) << 17)
.rotate_left((j % 61) as u32);
all.push((key, i as u32, p));
}
sources.push((i as u32, src));
}
all.sort_by_key(|(k, _, _)| *k);
let mut delivered: std::collections::HashMap<u32, Vec<u8>> =
std::collections::HashMap::new();
for (_, _, p) in &all {
if let Some(f) = r.push(p, coder.as_ref(), &stats).unwrap() {
prop_assert!(f.complete);
prop_assert!(delivered.insert(f.frame_index, f.data).is_none(),
"a frame must deliver exactly once");
}
}
for (i, src) in &sources {
let got = delivered.get(i);
prop_assert!(got.is_some(), "frame {i} must be DELIVERED, not merely error-free");
prop_assert_eq!(got.unwrap(), src, "frame {} must be byte-identical", i);
}
prop_assert_eq!(r.in_flight(), 0, "budget must be exact after all frames terminate");
prop_assert_eq!(stats.snapshot().frames_dropped, 0u64);
}
}
}
+23
View File
@@ -111,6 +111,17 @@ pub const CLIENT_CAP_CURSOR: u8 = 0x01;
/// simply ignored — no behavior change in either direction.
pub const CLIENT_CAP_PHASE_LOCK: u8 = 0x02;
/// `Hello.client_caps` bit: this client can decode the redundant desktop-audio plane
/// ([`AUDIO_RED_MAGIC`](super::datagram::AUDIO_RED_MAGIC), `0xD2`), where every datagram also
/// carries a copy of the previous frame so a single lost packet is reconstructed instead of
/// papered over with packet-loss concealment.
///
/// Active only when the host answers with [`HOST_CAP_AUDIO_RED`] (capable-and-agreed, the
/// cursor/clipboard precedent). Toward an older host, or a host that declines because the link is
/// clean, the client keeps receiving the plain `0xC9` plane — so a client may always set this bit.
/// `0x04` — `0x01`/`0x02` are cursor / phase-lock.
pub const CLIENT_CAP_AUDIO_RED: u8 = 0x04;
/// [`Welcome::host_caps`] bit: the host CAN forward the cursor out-of-band (it captures cursor
/// metadata separately from the frame — the Linux portal `SPA_META_Cursor` path; NOT gamescope,
/// whose capture carries no cursor, and NOT Windows yet, where DWM composites into the IDD
@@ -132,6 +143,18 @@ pub const HOST_CAP_CURSOR: u8 = 0x08;
/// [`HOST_CAP_TEXT_INPUT`], `0x01`/`0x02` are gamepad-state / clipboard.
pub const HOST_CAP_PEN: u8 = 0x10;
/// [`Welcome::host_caps`] bit: the host is sending the REDUNDANT desktop-audio plane
/// ([`AUDIO_RED_MAGIC`](super::datagram::AUDIO_RED_MAGIC), `0xD2`) instead of plain `0xC9` — each
/// datagram carries its own frame plus a copy of the previous one.
///
/// Set only when the client asked via [`CLIENT_CAP_AUDIO_RED`]. It is a statement about the WIRE,
/// not a negotiation the client can decline: with the bit set the client must decode `0xD2`, and
/// without it `0xC9`. The host may also drop back to `0xC9` mid-session (the redundancy is
/// loss-gated — a clean LAN shouldn't pay for it), which is why clients decode BOTH tags
/// unconditionally and treat this bit as "expect redundancy", not "only redundancy".
/// `0x20` — `0x10` is [`HOST_CAP_PEN`], `0x08` is [`HOST_CAP_CURSOR`].
pub const HOST_CAP_AUDIO_RED: u8 = 0x20;
/// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
/// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
/// advertise this.
+101
View File
@@ -55,6 +55,36 @@ pub struct RfiRequest {
pub last_frame: u32,
}
/// `host → client`, any time after [`Start`]: the video data plane's sealed shard payload
/// changes mid-session (design/shard-payload-reneg.md Phase 1). Sent ONLY to a client whose
/// [`Hello::max_shard_payload`] advertised per-frame geometry (0/absent = legacy — the host
/// must never send this), and never above that advertised ceiling. Asymmetric semantics:
///
/// - **Shrink** (the mid-session MTU heal): the host may re-key its packetizer at the next
/// AU boundary immediately after sending — per-frame pinning on the client makes the
/// control-vs-datagram reorder race irrelevant and a smaller shard always fits existing
/// buffers. The [`ShardPayloadAck`] is telemetry.
/// - **Grow** (jumbo): the host must not emit a single sealed datagram above the OLD size
/// until the ack arrives — the ack IS the gate, even when the client's buffers would
/// happen to fit (the rule must not erode if the buffer strategy changes later).
///
/// No `effective_frame_index`: per-frame pinning makes it redundant — every video packet
/// carries its own `shard_bytes` and the receiver follows each frame's pin.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ShardPayloadChanged {
/// The new sealed shard payload in bytes (even, within the client's advertised bounds).
pub shard_payload: u16,
}
/// `client → host`: answer to [`ShardPayloadChanged`] — echoes the value the client applied.
/// Only sent for an in-bounds request; an out-of-bounds one is dropped WITHOUT an ack (a
/// buggy host must not read silence-then-garbage as a granted grow). The host treats the
/// echoed value as the grant for a pending grow.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ShardPayloadAck {
pub shard_payload: u16,
}
/// `client → host`, periodic: the client's observed data-plane loss, so the host can size FEC to
/// the link instead of a flat percentage (adaptive FEC). `loss_ppm` is parts-per-million of shards
/// that arrived missing-but-recovered (plus a bump when frames went unrecoverable) over the report
@@ -200,6 +230,10 @@ pub const MSG_SET_BITRATE: u8 = 0x05;
pub const MSG_BITRATE_CHANGED: u8 = 0x06;
/// Type byte of [`RfiRequest`].
pub const MSG_RFI_REQUEST: u8 = 0x07;
/// Type byte of [`ShardPayloadChanged`].
pub const MSG_SHARD_PAYLOAD_CHANGED: u8 = 0x08;
/// Type byte of [`ShardPayloadAck`].
pub const MSG_SHARD_PAYLOAD_ACK: u8 = 0x09;
/// Type byte of [`ProbeRequest`].
pub const MSG_PROBE_REQUEST: u8 = 0x20;
/// Type byte of [`ProbeResult`].
@@ -306,6 +340,46 @@ impl RfiRequest {
}
}
impl ShardPayloadChanged {
pub fn encode(&self) -> Vec<u8> {
// magic[0..4] type[4] shard_payload[5..7]
let mut b = Vec::with_capacity(7);
b.extend_from_slice(CTL_MAGIC);
b.push(MSG_SHARD_PAYLOAD_CHANGED);
b.extend_from_slice(&self.shard_payload.to_le_bytes());
b
}
pub fn decode(b: &[u8]) -> Result<ShardPayloadChanged> {
if b.len() != 7 || &b[0..4] != CTL_MAGIC || b[4] != MSG_SHARD_PAYLOAD_CHANGED {
return Err(PunktfunkError::InvalidArg("bad ShardPayloadChanged"));
}
Ok(ShardPayloadChanged {
shard_payload: u16::from_le_bytes(b[5..7].try_into().unwrap()),
})
}
}
impl ShardPayloadAck {
pub fn encode(&self) -> Vec<u8> {
// magic[0..4] type[4] shard_payload[5..7]
let mut b = Vec::with_capacity(7);
b.extend_from_slice(CTL_MAGIC);
b.push(MSG_SHARD_PAYLOAD_ACK);
b.extend_from_slice(&self.shard_payload.to_le_bytes());
b
}
pub fn decode(b: &[u8]) -> Result<ShardPayloadAck> {
if b.len() != 7 || &b[0..4] != CTL_MAGIC || b[4] != MSG_SHARD_PAYLOAD_ACK {
return Err(PunktfunkError::InvalidArg("bad ShardPayloadAck"));
}
Ok(ShardPayloadAck {
shard_payload: u16::from_le_bytes(b[5..7].try_into().unwrap()),
})
}
}
impl LossReport {
pub fn encode(&self) -> Vec<u8> {
// magic[0..4] type[4] loss_ppm[5..9]
@@ -1146,6 +1220,33 @@ mod tests {
assert!(SetBitrate::decode(&LossReport { loss_ppm: 7 }.encode()).is_err());
}
#[test]
fn shard_payload_messages_roundtrip() {
for shard_payload in [512u16, 1216, 1408, 8908] {
let chg = ShardPayloadChanged { shard_payload };
assert_eq!(ShardPayloadChanged::decode(&chg.encode()).unwrap(), chg);
let ack = ShardPayloadAck { shard_payload };
assert_eq!(ShardPayloadAck::decode(&ack.encode()).unwrap(), ack);
// Identical payload shape — the type byte alone must keep the pair disjoint (a
// change echoed back must never re-decode as a change).
assert!(ShardPayloadChanged::decode(&ack.encode()).is_err());
assert!(ShardPayloadAck::decode(&chg.encode()).is_err());
}
// Exact length — no trailing bytes, no truncation.
let bytes = ShardPayloadChanged { shard_payload: 512 }.encode();
assert!(ShardPayloadChanged::decode(&[bytes.as_slice(), &[0]].concat()).is_err());
assert!(ShardPayloadChanged::decode(&bytes[..bytes.len() - 1]).is_err());
// Disjoint from the neighboring ids either side (0x07 RfiRequest / 0x20 ProbeRequest).
assert!(ShardPayloadChanged::decode(
&RfiRequest {
first_frame: 1,
last_frame: 2
}
.encode()
)
.is_err());
}
#[test]
fn probe_messages_roundtrip() {
let req = ProbeRequest {
+153
View File
@@ -42,6 +42,80 @@ pub fn decode_audio_datagram(b: &[u8]) -> Option<(u32, u64, &[u8])> {
Some((seq, pts_ns, &b[13..]))
}
/// Redundant audio datagram, host → client: the [`AUDIO_MAGIC`] plane plus a copy of the PREVIOUS
/// frame, so a single lost datagram is *reconstructed* rather than concealed.
///
/// `[0xD2][u32 seq LE][u64 pts_ns LE][u16 primary_len LE][primary opus][previous opus]`
///
/// **Why this and not Opus in-band FEC.** LBRR is a SILK-layer feature: the desktop-audio encoder
/// runs `RESTRICTED_LOWDELAY` (CELT-only) at 5 ms frames, which is below SILK's 10 ms minimum, so
/// `set_inband_fec(true)` on that encoder is a no-op. Nothing in libopus can protect this plane —
/// the redundancy has to be at the application layer. (The mic uplink is a different encoder, VoIP
/// mode at 10 ms, and *does* use real in-band FEC.)
///
/// **Why it costs no latency.** The copy rides the SUCCESSOR of the frame it protects, and the
/// client is already holding 1590 ms of de-jitter buffer — far more than the 5 ms the successor
/// takes to arrive. So the recovery happens inside slack that already exists.
///
/// The previous frame's sequence is implicitly `seq - 1`; a host with nothing to duplicate yet
/// (the first frame of a session, or straight after a capture reopen) simply sends an empty tail,
/// which decodes to `None`.
///
/// Sent ONLY when the client advertised [`CLIENT_CAP_AUDIO_RED`](super::caps::CLIENT_CAP_AUDIO_RED)
/// and the host answered [`HOST_CAP_AUDIO_RED`](super::caps::HOST_CAP_AUDIO_RED) — the
/// capable-and-agreed handshake the cursor and 4:4:4 planes already use. Every other session keeps
/// the plain [`AUDIO_MAGIC`] wire byte-for-byte.
///
/// NB `0xD1` is deliberately skipped: the DualSense pad-audio program has reserved it for the
/// per-pad audio plane.
pub const AUDIO_RED_MAGIC: u8 = 0xD2;
/// Fixed header length of an [`AUDIO_RED_MAGIC`] datagram (tag + seq + pts + primary length).
pub const AUDIO_RED_HEADER: usize = 1 + 4 + 8 + 2;
/// Encode a redundant audio datagram. `prev` is the immediately-preceding frame's Opus payload
/// (empty when there is none yet).
pub fn encode_audio_red_datagram(seq: u32, pts_ns: u64, opus: &[u8], prev: &[u8]) -> Vec<u8> {
let mut b = Vec::with_capacity(AUDIO_RED_HEADER + opus.len() + prev.len());
b.push(AUDIO_RED_MAGIC);
b.extend_from_slice(&seq.to_le_bytes());
b.extend_from_slice(&pts_ns.to_le_bytes());
// A frame longer than u16::MAX cannot occur (5 ms of Opus is tens of bytes; the buffer the
// encoder writes into is 4 KiB) — but truncating silently would desync the split, so clamp
// the redundancy off instead of the primary.
let primary_len = u16::try_from(opus.len()).unwrap_or(u16::MAX);
b.extend_from_slice(&primary_len.to_le_bytes());
b.extend_from_slice(opus);
if opus.len() == primary_len as usize {
b.extend_from_slice(prev);
}
b
}
/// Parse a redundant audio datagram → `(seq, pts_ns, primary, previous)`. `previous` is `None`
/// when the host had nothing to duplicate. `None` overall on bad tag/length, including a
/// `primary_len` that overruns the datagram (a truncated or hostile packet must not panic).
///
/// The tuple shape deliberately mirrors [`decode_audio_datagram`] (one extra slot for the
/// redundant copy) so the two planes read the same at every call site; a named struct here would
/// be the odd one out on this module's decode surface, and cbindgen would then have to be taught
/// to skip it.
#[allow(clippy::type_complexity)]
pub fn decode_audio_red_datagram(b: &[u8]) -> Option<(u32, u64, &[u8], Option<&[u8]>)> {
if b.len() < AUDIO_RED_HEADER || b[0] != AUDIO_RED_MAGIC {
return None;
}
let seq = u32::from_le_bytes(b[1..5].try_into().unwrap());
let pts_ns = u64::from_le_bytes(b[5..13].try_into().unwrap());
let primary_len = u16::from_le_bytes(b[13..15].try_into().unwrap()) as usize;
let rest = &b[AUDIO_RED_HEADER..];
if primary_len > rest.len() {
return None; // truncated: the split point is outside the datagram
}
let (primary, prev) = rest.split_at(primary_len);
Some((seq, pts_ns, primary, (!prev.is_empty()).then_some(prev)))
}
/// Legacy rumble datagram (v1), host → client: `[0xCA][u16 pad LE][u16 low LE][u16 high LE]`.
/// Force-feedback state for pad `pad` (0xFFFF amplitudes, 0/0 = stop) as *level-triggered* state
/// — it persists until superseded, which is why the host re-sends it periodically as its loss
@@ -806,6 +880,8 @@ mod tests {
#[test]
fn audio_datagram_roundtrip() {
let opus = [0x42u8; 97];
let d = encode_audio_red_datagram(7, 42, &opus, &[]);
assert_eq!(d[0], AUDIO_RED_MAGIC);
let d = encode_audio_datagram(7, 1_000_000_123, &opus);
assert_eq!(d[0], AUDIO_MAGIC);
let (seq, pts, payload) = decode_audio_datagram(&d).unwrap();
@@ -820,6 +896,83 @@ mod tests {
assert!(empty.is_empty());
}
#[test]
fn audio_red_datagram_roundtrip() {
let cur = [0x42u8; 97];
let prev = [0x37u8; 88];
let d = encode_audio_red_datagram(7, 1_000_000_123, &cur, &prev);
assert_eq!(d[0], AUDIO_RED_MAGIC);
let (seq, pts, primary, previous) = decode_audio_red_datagram(&d).unwrap();
assert_eq!((seq, pts), (7, 1_000_000_123));
assert_eq!(primary, cur);
assert_eq!(previous, Some(&prev[..]));
// No predecessor yet (first frame of a session / after a capture reopen).
let d = encode_audio_red_datagram(0, 5, &cur, &[]);
let (_, _, primary, previous) = decode_audio_red_datagram(&d).unwrap();
assert_eq!(primary, cur);
assert_eq!(
previous, None,
"an empty tail must decode as absent, not as a zero-length frame"
);
// Frames of equal length must still split at the right place — the length prefix is the
// only thing that can tell them apart.
let a = [1u8; 64];
let b = [2u8; 64];
let d = encode_audio_red_datagram(9, 0, &a, &b);
let (_, _, primary, previous) = decode_audio_red_datagram(&d).unwrap();
assert_eq!(primary, a);
assert_eq!(previous, Some(&b[..]));
}
/// A truncated or hostile `0xD2` must be rejected, never panic — the split point comes off
/// the wire, so an over-long `primary_len` is the obvious attack on `split_at`.
#[test]
fn audio_red_datagram_rejects_bad_input() {
let d = encode_audio_red_datagram(1, 2, &[0xAAu8; 30], &[0xBBu8; 20]);
for n in 0..AUDIO_RED_HEADER {
assert!(decode_audio_red_datagram(&d[..n]).is_none(), "len {n}");
}
// primary_len larger than the datagram: must be refused, not sliced.
let mut bad = d.clone();
bad[13..15].copy_from_slice(&u16::MAX.to_le_bytes());
assert!(decode_audio_red_datagram(&bad).is_none());
// Wrong tag.
let mut wrong = d.clone();
wrong[0] = AUDIO_MAGIC;
assert!(decode_audio_red_datagram(&wrong).is_none());
}
/// The two audio planes must not alias each other or any neighbouring plane: a client
/// demultiplexes purely on the first byte.
#[test]
fn audio_red_tag_is_disjoint() {
for other in [
AUDIO_MAGIC,
RUMBLE_MAGIC,
MIC_MAGIC,
RICH_INPUT_MAGIC,
HIDOUT_MAGIC,
HDR_META_MAGIC,
HOST_TIMING_MAGIC,
CURSOR_STATE_MAGIC,
crate::input::INPUT_MAGIC,
] {
assert_ne!(AUDIO_RED_MAGIC, other);
}
let red = encode_audio_red_datagram(1, 2, &[9u8; 40], &[8u8; 40]);
assert!(
decode_audio_datagram(&red).is_none(),
"0xC9 must not accept a 0xD2"
);
let plain = encode_audio_datagram(1, 2, &[9u8; 40]);
assert!(
decode_audio_red_datagram(&plain).is_none(),
"0xD2 must not accept a 0xC9"
);
}
#[test]
fn rumble_datagram_roundtrip() {
let d = encode_rumble_datagram(1, 0x1234, 0xFFFF);
@@ -47,6 +47,38 @@ fn stream_transport_idle(idle: std::time::Duration) -> Arc<quinn::TransportConfi
// plane latest-wins at the source — ~200 ms of stereo Opus (proportionally less at
// surround bitrates), so sustained congestion costs concealable drops, never lag.
t.datagram_send_buffer_size(4 * 1024);
// MTU discovery probes up to EXACTLY the sealed size of a full IPv4 video datagram (1472)
// instead of quinn's stock 1452. Two reasons: (a) on a clean 1500-MTU path QUIC gets the
// last 20 bytes per packet; (b) the ceiling turns discovery into a video-path verdict the
// host's wire-MTU watcher reads (`punktfunk-host` `native/wire_mtu.rs`) — settled == ceiling
// proves the path carries full-size video datagrams, settled BELOW it proves it cannot (a
// VPN/overlay adapter at MTU ~1280 blackholes every video packet while all the small flows
// pass: the "connects fine, black screen forever" field shape). With the stock 1452 ceiling
// a healthy path and a constrained one are indistinguishable at the top. This is the ONLY
// behavioral change on healthy paths, and it's confined to discovery: probes are padded
// PINGs quinn already expects to lose above a constrained hop — a lost probe settles the
// search lower, exactly as it did before.
let mut mtud = quinn::MtuDiscoveryConfig::default();
// Jumbo opt-in (design/shard-payload-reneg.md Phase 2): with `PUNKTFUNK_JUMBO=1` /
// `PUNKTFUNK_WIRE_MTU` > 1500 set, discovery probes up to the sealed JUMBO datagram
// size so a settled connection can PROVE a jumbo path — the actual grow stays
// client-ack-gated (`native/wire_mtu.rs`). The ceiling is per-ENDPOINT, not
// per-connection: with the opt-in set, connections to non-jumbo peers spend a few extra
// failed probes (one PTO each) settling lower; zero cost for anyone who doesn't opt in.
// Derived with the IPv4 overhead — a v6 peer's sealed jumbo target is smaller, so the
// ceiling covers it and discovery settles at the v6 path's own budget.
let probe_ceiling = match crate::config::jumbo_wire_mtu() {
Some(mtu) => {
let shard = crate::config::jumbo_shard_payload_for(
mtu,
std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED),
);
crate::config::sealed_datagram_bytes(shard) as u16
}
None => crate::config::video_datagram_udp_ceiling() as u16,
};
mtud.upper_bound(probe_ceiling);
t.mtu_discovery_config(Some(mtud));
Arc::new(t)
}
+119 -7
View File
@@ -90,8 +90,19 @@ pub struct Hello {
/// disambiguated by REMAINING LENGTH at decode: fewer than `HDR_META_BODY_LEN` bytes after
/// `preferred_codec` ⇒ no HDR block, the tail bytes are the post-HDR fields directly. This
/// caps everything after `display_hdr` at `HDR_META_BODY_LEN 1` bytes total — document any
/// future field here and mind the budget. Omitted when zero and by older clients (→ `0`).
/// future field here and mind the budget (`client_caps` 1 + `max_shard_payload` 2 = 3 of the
/// 27 spent). Omitted when zero and by older clients (→ `0`).
pub client_caps: u8,
/// The largest video shard payload this client's receive path accepts — sealed datagrams for
/// shards up to this size fit its transport buffers ([`crate::config::max_shard_payload`]).
/// One field carries BOTH facts the host needs for mid-session shard renegotiation
/// (design/shard-payload-reneg.md W0.3): non-zero ⇒ the client reassembles per-frame
/// geometry (a mid-session `shard_payload` change is safe to send), and the value is the
/// hard ceiling a jumbo grow may never exceed. Appended after `client_caps` as 2 trailing
/// LE bytes (forcing the earlier placeholders). Omitted by older clients (decodes to `0`
/// = legacy: the host must not change the sealed geometry mid-session, and never above
/// the `Welcome` value).
pub max_shard_payload: u16,
}
/// QUIC application error code a punktfunk/1 client closes the control connection with on a
@@ -254,12 +265,14 @@ impl Hello {
let pref_present = self.preferred_codec != 0;
let hdr_present = self.display_hdr.is_some();
let ccaps_present = self.client_caps != 0;
let msp_present = self.max_shard_payload != 0;
let need_placeholders = self.video_caps != 0
|| ac_present
|| vcodecs_present
|| pref_present
|| hdr_present
|| ccaps_present;
|| ccaps_present
|| msp_present;
match (&self.name, &self.launch) {
(None, None) if !need_placeholders => {}
(name, _) => {
@@ -280,15 +293,21 @@ impl Hello {
b.push(self.video_caps);
}
// audio_channels: emitted when non-stereo OR a later field follows.
if ac_present || vcodecs_present || pref_present || hdr_present || ccaps_present {
if ac_present
|| vcodecs_present
|| pref_present
|| hdr_present
|| ccaps_present
|| msp_present
{
b.push(self.audio_channels);
}
// video_codecs: emitted when non-zero OR a later field follows.
if vcodecs_present || pref_present || hdr_present || ccaps_present {
if vcodecs_present || pref_present || hdr_present || ccaps_present || msp_present {
b.push(self.video_codecs);
}
// preferred_codec: emitted when non-zero OR a later field follows.
if pref_present || hdr_present || ccaps_present {
if pref_present || hdr_present || ccaps_present || msp_present {
b.push(self.preferred_codec);
}
// display_hdr: fixed HDR_META_BODY_LEN-byte HdrMeta body; omitted when `None` even if
@@ -297,10 +316,15 @@ impl Hello {
if let Some(m) = &self.display_hdr {
super::datagram::write_hdr_meta_body(m, &mut b);
}
// client_caps: single byte after the (optional) HDR block. Emitted when non-zero.
if ccaps_present {
// client_caps: single byte after the (optional) HDR block. Emitted when non-zero OR a
// later field follows.
if ccaps_present || msp_present {
b.push(self.client_caps);
}
// max_shard_payload: 2 trailing LE bytes after client_caps. Emitted when non-zero.
if msp_present {
b.extend_from_slice(&self.max_shard_payload.to_le_bytes());
}
b
}
@@ -386,6 +410,19 @@ impl Hello {
};
b.get(off).copied().unwrap_or(0)
},
// max_shard_payload: 2 LE bytes after client_caps (same post-HDR offset rule).
// Absent on an older client → 0 = no mid-session renegotiation, no jumbo.
max_shard_payload: {
let off = if b.len().saturating_sub(tail + 4) >= super::datagram::HDR_META_BODY_LEN
{
tail + 4 + super::datagram::HDR_META_BODY_LEN
} else {
tail + 4
};
b.get(off + 1..off + 3)
.map(|s| u16::from_le_bytes(s.try_into().unwrap()))
.unwrap_or(0)
},
})
}
}
@@ -867,6 +904,7 @@ mod tests {
preferred_codec: CODEC_H264,
display_hdr: None,
client_caps: 0,
max_shard_payload: 0,
};
let enc = h.encode();
let dec = Hello::decode(&enc).unwrap();
@@ -944,6 +982,7 @@ mod tests {
preferred_codec: CODEC_HEVC,
display_hdr: None,
client_caps: 0,
max_shard_payload: 0,
};
assert_eq!(Hello::decode(&h.encode()).unwrap(), h);
let s = Start {
@@ -975,6 +1014,7 @@ mod tests {
preferred_codec: 0,
display_hdr: None,
client_caps: 0,
max_shard_payload: 0,
};
let enc = h.encode();
assert_eq!(enc.len(), 26);
@@ -1093,6 +1133,7 @@ mod tests {
preferred_codec: 0,
display_hdr: None,
client_caps: 0,
max_shard_payload: 0,
};
let enc = base.encode();
assert_eq!(
@@ -1145,6 +1186,7 @@ mod tests {
preferred_codec: 0,
display_hdr: None,
client_caps: 0,
max_shard_payload: 0,
};
// launch alone (no name): a zero-length name placeholder keeps the offset deterministic.
let with_launch = Hello {
@@ -1205,6 +1247,7 @@ mod tests {
preferred_codec: 0,
display_hdr: None,
client_caps: 0,
max_shard_payload: 0,
};
// A real client-panel volume (P3 primaries, 800-nit peak, 0.05-nit floor, 400-nit FALL).
let vol = HdrMeta {
@@ -1273,6 +1316,7 @@ mod tests {
preferred_codec: 0,
display_hdr: None,
client_caps: 0,
max_shard_payload: 0,
}
.encode();
assert!(PairRequest::decode(&h).is_err(), "abi {abi} parsed as pair");
@@ -1306,6 +1350,7 @@ mod tests {
preferred_codec: 0,
display_hdr: None,
client_caps: 0,
max_shard_payload: 0,
};
let vol = HdrMeta {
display_primaries: [[13250, 34500], [7500, 3000], [34000, 16000]],
@@ -1319,6 +1364,7 @@ mod tests {
// fixed block length, so the decoder must NOT read it as a truncated HdrMeta).
let caps_only = Hello {
client_caps: CLIENT_CAP_CURSOR,
max_shard_payload: 0,
..base.clone()
};
assert_eq!(Hello::decode(&caps_only.encode()).unwrap(), caps_only);
@@ -1326,6 +1372,7 @@ mod tests {
let both = Hello {
display_hdr: Some(vol),
client_caps: CLIENT_CAP_CURSOR,
max_shard_payload: 0,
..base.clone()
};
assert_eq!(Hello::decode(&both.encode()).unwrap(), both);
@@ -1344,8 +1391,73 @@ mod tests {
Hello::decode(&enc[..enc.len() - 1]).unwrap(),
Hello {
client_caps: 0,
max_shard_payload: 0,
..both.clone()
}
);
}
/// `max_shard_payload` (mid-session shard renegotiation, design/shard-payload-reneg.md
/// W0.3): roundtrips, forces the earlier placeholders (deterministic offset), composes
/// with the optional HDR block, and degrades to 0 = legacy in BOTH directions.
#[test]
fn hello_max_shard_payload_roundtrip_and_back_compat() {
let base = Hello {
abi_version: 2,
mode: Mode {
width: 1920,
height: 1080,
refresh_hz: 60,
},
compositor: CompositorPref::Auto,
gamepad: GamepadPref::Auto,
bitrate_kbps: 0,
name: None,
launch: None,
video_caps: 0,
audio_channels: 2,
video_codecs: 0,
preferred_codec: 0,
display_hdr: None,
client_caps: 0,
max_shard_payload: 0,
};
// The advertisement alone: every earlier trailing field is emitted as a placeholder
// so the 2 LE bytes land at a deterministic offset — and the whole thing roundtrips.
let adv = Hello {
max_shard_payload: crate::config::max_shard_payload() as u16,
..base.clone()
};
assert_eq!(Hello::decode(&adv.encode()).unwrap(), adv);
// Composes with client_caps AND the fixed HDR block (the remaining-length
// disambiguation must still find both fields after it).
let vol = HdrMeta {
display_primaries: [[13250, 34500], [7500, 3000], [34000, 16000]],
white_point: [15635, 16450],
max_display_mastering_luminance: 8_000_000,
min_display_mastering_luminance: 500,
max_cll: 0,
max_fall: 400,
};
let full = Hello {
display_hdr: Some(vol),
client_caps: CLIENT_CAP_CURSOR,
max_shard_payload: 8908,
..base.clone()
};
assert_eq!(Hello::decode(&full.encode()).unwrap(), full);
// An older client (no trailing bytes at all) decodes to 0 = legacy: the host must
// not change the sealed geometry mid-session.
assert_eq!(Hello::decode(&base.encode()).unwrap().max_shard_payload, 0);
// An older HOST reading an advertising Hello never looks past the fields it knows —
// truncating the 2 trailing bytes yields the same Hello minus the advertisement.
let enc = full.encode();
assert_eq!(
Hello::decode(&enc[..enc.len() - 2]).unwrap(),
Hello {
max_shard_payload: 0,
..full.clone()
}
);
}
}
+168
View File
@@ -603,6 +603,31 @@ impl Session {
self.packetizer.set_fec_percent(pct);
}
/// Host: live-swap the wire shard payload between AUs (mid-session shard renegotiation,
/// design/shard-payload-reneg.md). Affects the next sealed AU; call only between AUs
/// (never with a `StreamedAu` in flight — see [`Packetizer::set_shard_payload`]). The new
/// value must satisfy the exact bounds `Config::validate` imposed on the negotiated one
/// (even, > 0, fits a datagram, block count fits the wire) — validated here against a
/// probe of the session config. The PROTOCOL side is the caller's contract: a current
/// client reassembles any in-bounds size per-frame, but a shrink may be sent immediately
/// while a grow must be client-acked and never exceed the client's advertised
/// `Hello::max_shard_payload` ceiling.
pub fn set_shard_payload(&mut self, shard_payload: usize) -> Result<()> {
if self.config.role != Role::Host {
return Err(PunktfunkError::InvalidArg(
"set_shard_payload called on a client session",
));
}
// Full `Config::validate` parity, zero drift: probe a copy (its key/salt copies are
// zeroized on drop) rather than re-spelling the shard clauses here.
let mut probe = self.config.clone();
probe.shard_payload = shard_payload;
probe.validate()?;
self.config.shard_payload = shard_payload;
self.packetizer.set_shard_payload(shard_payload);
Ok(())
}
/// The current FEC recovery percentage (host side).
pub fn fec_percent(&self) -> u8 {
self.packetizer.fec_percent()
@@ -1060,4 +1085,147 @@ mod wire_equivalence_tests {
"unflagged AUs must never be delivered partial"
);
}
/// The low-MTU PyroWave guarantee (design/shard-payload-reneg.md): mid-session
/// renegotiation is gated OFF for chunk-aligned sessions, so a constrained path serves
/// them through the leg-1 SESSION-START clamp instead — the learned budget (or
/// `PUNKTFUNK_WIRE_MTU`) sizes `Welcome::shard_payload`, and everything chunk-aligned
/// derives from that ONE number fixed at the handshake: the host packetizes at it, the
/// client's parse window reads it back ([`Session::shard_payload`] → the C-ABI
/// `punktfunk_connection_shard_payload` every embedder walks windows with), and partial
/// delivery zero-fills exact windows of it. Pin that consistency at the clamp shapes a
/// constrained path actually produces: the WARP/Tailscale budget (1216) and the floor
/// (512) — chunk-aligned frames deliver, lose whole windows (never splice), and the
/// window arithmetic matches the session value end to end.
#[test]
fn chunk_aligned_sessions_work_at_clamped_shard_sizes() {
use crate::packet::USER_FLAG_CHUNK_ALIGNED;
for shard in [1216usize, crate::config::MIN_SHARD_PAYLOAD] {
let mk = |role| Config {
role,
phase: ProtocolPhase::P2Punktfunk,
fec: FecConfig {
scheme: FecScheme::Gf16,
fec_percent: 0, // no parity — any drop leaves a hole
max_data_per_block: 64,
},
shard_payload: shard,
max_frame_bytes: 8 * 1024 * 1024,
encrypt: true,
key: SessionKey::Aes128Gcm([7u8; 16]),
salt: [3, 1, 4, 1],
loopback_drop_period: 0,
};
let (h, c) = crate::transport::loopback_pair(3, 1);
let mut host = Session::new(mk(Role::Host), Box::new(h)).unwrap();
let mut client = Session::new(mk(Role::Client), Box::new(c)).unwrap();
client.set_deliver_partial_frames(true);
// The window every embedder parses with IS the clamped session value.
assert_eq!(client.shard_payload(), shard);
assert_eq!(host.shard_payload(), shard);
let frame = pattern(8 * shard);
host.submit_frame(&frame, 1_000, USER_FLAG_CHUNK_ALIGNED)
.unwrap();
let mut got_partial = None;
let mut completes = 0;
for i in 0..80u64 {
host.submit_frame(&pattern(shard), 2_000 + i, USER_FLAG_CHUNK_ALIGNED)
.unwrap();
loop {
match client.poll_frame() {
Ok(f) if !f.complete => got_partial = Some(f),
Ok(_) => completes += 1,
Err(PunktfunkError::NoFrame) => break,
Err(e) => panic!("shard {shard}: unexpected: {e}"),
}
}
}
let p = got_partial.expect("the lossy frame must be delivered partial");
assert_eq!(p.data.len(), frame.len(), "shard {shard}");
// Loss lands on exact `shard`-sized window boundaries: zeroed windows for the
// dropped datagrams, byte-identical survivors — nothing spliced across windows.
let mut zero_windows = 0;
for w in 0..8 {
let win = &p.data[w * shard..(w + 1) * shard];
if win.iter().all(|&b| b == 0) {
zero_windows += 1;
} else {
assert_eq!(
win,
&frame[w * shard..(w + 1) * shard],
"shard {shard}: window {w} corrupt"
);
}
}
assert!(
(1..8).contains(&zero_windows),
"shard {shard}: dropped shards zero-filled (got {zero_windows})"
);
assert!(
completes > 40,
"shard {shard}: surviving filler frames flow normally"
);
}
}
/// Mid-session shard renegotiation end to end over the SEALED loopback wire
/// (design/shard-payload-reneg.md): one host session re-keys its packetizer between AUs
/// — shrink, jumbo grow, revert — through one continuous crypto/replay stream, and one
/// client session must DELIVER every frame byte-identically (the vacuous-green lesson:
/// assert delivered frames, never the absence of errors).
#[test]
fn mid_session_shard_swap_delivers_frames_over_the_sealed_wire() {
let mk = |role: Role| {
let mut c = host_cfg(FecScheme::Gf16, 20, true);
c.role = role;
c.shard_payload = 1408;
c.fec.max_data_per_block = 64;
c
};
let (ht, ct) = loopback_pair(0, 0);
let mut host = Session::new(mk(Role::Host), Box::new(ht)).unwrap();
let mut client = Session::new(mk(Role::Client), Box::new(ct)).unwrap();
let phases: [(usize, &[usize]); 4] = [
(1408, &[3000, 3 * 1408]), // the negotiated default (incl. exact multiple)
(512, &[2000, 5 * 512 + 17]), // shrink — the mid-session VPN heal
(8908, &[100_000]), // grow — jumbo on a 9000-MTU LAN
(1216, &[2 * 1216 + 9]), // revert — a mis-proven jumbo hop self-corrects
];
let mut pts = 0u64;
let mut delivered = 0usize;
for (shard, lens) in phases {
host.set_shard_payload(shard).unwrap();
assert_eq!(host.shard_payload(), shard);
for &len in lens {
pts += 1_000_000;
let src = pattern(len);
host.submit_frame(&src, pts, 0).unwrap();
let f = client
.poll_frame()
.unwrap_or_else(|e| panic!("shard {shard}: frame must be DELIVERED ({e})"));
assert_eq!(
f.data, src,
"shard {shard}: {len} B frame must be byte-identical"
);
assert!(f.complete);
delivered += 1;
}
}
assert_eq!(delivered, 6, "every submitted frame must be delivered");
// The setter is host-side machinery: a client session must refuse it, and an
// invalid size (odd / oversized) must be rejected without touching the live config.
assert!(client.set_shard_payload(1408).is_err());
assert!(
host.set_shard_payload(1407).is_err(),
"odd must be rejected"
);
assert!(
host.set_shard_payload(crate::config::max_shard_payload() + 2)
.is_err(),
"oversized must be rejected"
);
assert_eq!(host.shard_payload(), 1216, "failed swaps must not stick");
}
}
+5
View File
@@ -192,6 +192,11 @@ mod wasapi_mic;
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
#[path = "audio/wiring_plan.rs"]
pub(crate) mod wiring_plan;
// Pure capture-loop policy, split out for the same reason `wiring_plan` is: it encodes field
// behaviour, so its tests must run on every platform's CI, not only Windows.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
#[path = "audio/capture_policy.rs"]
pub(crate) mod capture_policy;
mod mic_jitter;
mod mic_pump;
@@ -0,0 +1,260 @@
//! Desktop-audio capture POLICY — the parts of [`wasapi_cap`](super::wasapi_cap) that are pure
//! decisions rather than WASAPI plumbing, split out for the same reason
//! [`wiring_plan`](super::wiring_plan) is: so they compile and their unit tests RUN on every
//! platform. Both of these encode field-report behaviour, and regressing either must fail CI on
//! Linux too, not only on a Windows box.
//!
//! * [`FightDamper`] — how hard to fight another program for the default playback device.
//! * [`CaptureStats`] — the audio plane's vitals, so a log can tell a quiet host from a broken
//! endpoint from one we are damaging ourselves.
use std::time::{Duration, Instant};
/// Default-playback re-assertions inside [`FIGHT_WINDOW`] before we stop fighting.
pub(crate) const FIGHT_LIMIT: u32 = 4;
pub(crate) const FIGHT_WINDOW: Duration = Duration::from_secs(20);
/// How long to leave the default alone once another program has proven it will take it back.
pub(crate) const FIGHT_BACKOFF: Duration = Duration::from_secs(60);
/// Damping for the default-playback tug-of-war (WP2.4).
///
/// The 2026-08-03 field log recorded seven full re-assert cycles in sixteen seconds — something on
/// that box re-set the default playback to CABLE Input every ~4 s and we snapped it back every
/// time, each round a capture teardown plus a wiring pass with `IPolicyConfig` writes. Winning that
/// argument is not possible and every round was an audible dropout, so: re-assert a few times
/// (transient churn does settle), then concede for a minute and say so once.
///
/// Time is passed IN rather than read here, which keeps the policy pure and testable.
pub(crate) struct FightDamper {
/// Re-assertions in the current window, and when the window opened.
count: u32,
window_started: Instant,
/// Set while we are deliberately not fighting.
paused_until: Option<Instant>,
/// One warning per fight burst, and one per concession.
warned_fighting: bool,
warned_giving_up: bool,
now: Instant,
}
impl FightDamper {
pub(crate) fn new(now: Instant) -> FightDamper {
FightDamper {
count: 0,
window_started: now,
paused_until: None,
warned_fighting: false,
warned_giving_up: false,
now,
}
}
/// A dud default-device change was observed at `now`.
pub(crate) fn observed_at(&mut self, now: Instant) {
self.now = now;
if now.duration_since(self.window_started) >= FIGHT_WINDOW {
self.window_started = now;
self.count = 0;
self.warned_fighting = false;
}
if self.paused_until.is_some_and(|t| now >= t) {
self.paused_until = None;
self.warned_giving_up = false;
self.count = 0;
self.window_started = now;
}
}
/// Should we put the default back? False while paused, or once this window's budget is spent.
pub(crate) fn should_reassert(&mut self) -> bool {
if self.paused_until.is_some() {
return false;
}
if self.count >= FIGHT_LIMIT {
self.paused_until = Some(self.now + FIGHT_BACKOFF);
return false;
}
self.count += 1;
true
}
/// Warn on the FIRST re-assert of a burst only (the rest are noise).
pub(crate) fn warn_now(&mut self) -> bool {
!std::mem::replace(&mut self.warned_fighting, true)
}
/// Warn once when we concede.
pub(crate) fn warn_giving_up(&mut self) -> bool {
self.paused_until.is_some() && !std::mem::replace(&mut self.warned_giving_up, true)
}
/// Currently conceding (test/diagnostic accessor).
pub(crate) fn is_paused(&self) -> bool {
self.paused_until.is_some()
}
}
/// How often the capture loop reports its vitals (WP0.2).
pub(crate) const STATS_EVERY: Duration = Duration::from_secs(30);
/// One reporting window's worth of capture vitals.
///
/// The point is to make three states that used to look identical in a log tell themselves apart: a
/// genuinely quiet host (`peak` ~0, no drops), a working stream (`peak` > 0), and a stream we are
/// damaging ourselves (`dropped_chunks` > 0). The 2026-08-03 field log — 3,600 lines, filed over an
/// audio-quality complaint — could distinguish none of them, because the audio plane logged nothing
/// at all between "capturing" and the session ending.
#[derive(Default)]
pub(crate) struct CaptureStats {
pub(crate) frames: u64,
/// Interleaved SAMPLES seen — the RMS denominator. Deliberately separate from `frames`:
/// dividing the sum of squares by the frame count instead inflates RMS by sqrt(channels),
/// which made a sine report an RMS equal to its own peak.
pub(crate) samples: u64,
/// Loudest |sample| in the window — tells a silent endpoint from a working one.
pub(crate) peak: f32,
/// Sum of squares, for the window's RMS: a level far below peak means a badly attenuated
/// endpoint (a parked device sitting at 20 % volume costs ~14 dB before Opus ever sees it).
pub(crate) sumsq: f64,
/// Chunks the encode thread was too slow to take. Silent data loss, previously uncounted:
/// the encoder simply concatenates across the hole, so it is a click AND a permanent shift of
/// everything after it.
pub(crate) dropped_chunks: u64,
}
impl CaptureStats {
pub(crate) fn observe(&mut self, samples: &[f32], channels: u32) {
self.frames += (samples.len() / channels.max(1) as usize) as u64;
self.samples += samples.len() as u64;
for &s in samples {
let a = s.abs();
if a > self.peak {
self.peak = a;
}
self.sumsq += (s as f64) * (s as f64);
}
}
/// `(peak dBFS, rms dBFS, delivered %)` for this window. Silence reports -120 dB rather than
/// -inf so the log line stays parseable.
pub(crate) fn summary(&self, elapsed: Duration, sample_rate: u32) -> (f64, f64, f64) {
let rms = (self.sumsq / (self.samples as f64).max(1.0)).sqrt();
let db = |v: f64| if v > 0.0 { 20.0 * v.log10() } else { -120.0 };
// Expected frames for the window — a shortfall means the endpoint is not delivering at
// real time (a stalling virtual device), which a peak/RMS alone cannot show.
let expected = elapsed.as_secs_f64() * sample_rate as f64;
(
db(self.peak as f64),
db(rms),
(self.frames as f64 / expected.max(1.0)) * 100.0,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Replays the 2026-08-03 field shape: a dud default change every ~2 s, forever. We must put
/// the default back a few times, then concede — and warn exactly once for each.
#[test]
fn fight_damper_concedes_instead_of_looping_forever() {
let t0 = Instant::now();
let mut d = FightDamper::new(t0);
let mut reasserts = 0;
let (mut warns_fighting, mut warns_giving_up) = (0, 0);
for i in 0..8 {
d.observed_at(t0 + Duration::from_millis(i * 2_000));
if d.should_reassert() {
reasserts += 1;
if d.warn_now() {
warns_fighting += 1;
}
} else if d.warn_giving_up() {
warns_giving_up += 1;
}
}
assert_eq!(
reasserts, FIGHT_LIMIT,
"must stop after the window's budget"
);
assert_eq!(warns_fighting, 1, "one warning per burst, not one per flip");
assert_eq!(warns_giving_up, 1, "concede exactly once");
}
/// Occasional, genuinely transient churn must ALWAYS be corrected — the damper must not
/// accumulate across widely-spaced events and quietly stop doing its job.
#[test]
fn fight_damper_always_fixes_isolated_changes() {
let t0 = Instant::now();
let mut d = FightDamper::new(t0);
let mut reasserts = 0;
for i in 1..=10 {
d.observed_at(t0 + FIGHT_WINDOW * i);
if d.should_reassert() {
reasserts += 1;
}
}
assert_eq!(reasserts, 10, "isolated changes must always be corrected");
}
/// After the backoff expires the damper re-arms, so a program that goes quiet and comes back
/// later is fought again rather than being conceded to for the rest of the session.
#[test]
fn fight_damper_rearms_after_the_backoff() {
let t0 = Instant::now();
let mut d = FightDamper::new(t0);
for i in 0..FIGHT_LIMIT + 2 {
d.observed_at(t0 + Duration::from_millis(i as u64 * 500));
d.should_reassert();
}
assert!(d.is_paused(), "should have conceded");
d.observed_at(t0 + FIGHT_BACKOFF + FIGHT_WINDOW * 2);
assert!(d.should_reassert(), "must re-arm once the backoff expires");
}
/// Peak/RMS must separate the states a log could not previously tell apart.
#[test]
fn capture_stats_separate_silence_from_signal() {
let mut quiet = CaptureStats::default();
quiet.observe(&[0.0; 480], 2);
let (peak, rms, _) = quiet.summary(Duration::from_secs(1), 48_000);
assert_eq!(peak, -120.0, "digital silence reports the floor, not -inf");
assert_eq!(rms, -120.0);
let mut loud = CaptureStats::default();
let tone: Vec<f32> = (0..480).map(|i| (i as f32 * 0.13).sin() * 0.5).collect();
loud.observe(&tone, 2);
assert_eq!(
loud.frames, 240,
"480 interleaved stereo samples = 240 frames"
);
let (peak, rms, _) = loud.summary(Duration::from_secs(1), 48_000);
assert!(
peak > -8.0 && peak <= 0.0,
"peak {peak} dBFS should track a 0.5 tone"
);
// A sine's RMS is its amplitude / sqrt(2) — about 3 dB below peak. Getting this equal to
// peak is exactly what a frames-vs-samples mix-up in the denominator looks like, so the
// margin is asserted rather than just the ordering.
assert!(
rms < peak - 2.0,
"RMS {rms} vs peak {peak}: a sine must sit ~3 dB below its peak"
);
}
/// The delivered-percentage is what shows an endpoint that has stopped feeding us in real
/// time — invisible in peak/RMS, and the shape a stalling virtual device makes.
#[test]
fn capture_stats_report_a_delivery_shortfall() {
let mut full = CaptureStats::default();
full.observe(&vec![0.1f32; 48_000 * 2], 2); // exactly 1 s of stereo
let (_, _, pct) = full.summary(Duration::from_secs(1), 48_000);
assert!((pct - 100.0).abs() < 1.0, "expected ~100 %, got {pct}");
let mut half = CaptureStats::default();
half.observe(&vec![0.1f32; 48_000], 2); // 0.5 s of stereo in a 1 s window
let (_, _, pct) = half.summary(Duration::from_secs(1), 48_000);
assert!((pct - 50.0).abs() < 1.0, "expected ~50 %, got {pct}");
}
}
+60 -4
View File
@@ -674,6 +674,8 @@ fn pw_thread(
})
.register();
// Which source the negotiated format below actually describes — see the note there.
let sink_mode = sink_name.is_some();
let props = match &sink_name {
// Stream-sink mode: this stream IS the sink (media.class + Direction::Input). Apps
// play into it, PipeWire mixes them, process() receives the mix. Mirrors the
@@ -710,8 +712,25 @@ fn pw_thread(
let stream = pw::stream::StreamBox::new(&core, "punktfunk-audio", props)
.context("pw audio Stream")?;
// The capture callback's state: the hand-off channel plus this plane's vitals. Before
// this it was the bare `tx`, and the desktop-audio plane logged NOTHING between "capture
// started" and the session ending — no level, no cadence, and in particular no sign of
// the silent drop below. That is exactly what made the 2026-08-03 Windows field report
// un-triageable, and the Linux half kept it after the Windows half was fixed.
struct CapUd {
tx: std::sync::mpsc::SyncSender<Vec<f32>>,
channels: u32,
stats: crate::audio::capture_policy::CaptureStats,
last_stats: std::time::Instant,
}
let ud = CapUd {
tx,
channels,
stats: Default::default(),
last_stats: std::time::Instant::now(),
};
let _listener = stream
.add_local_listener_with_user_data(tx)
.add_local_listener_with_user_data(ud)
.state_changed({
let mainloop = mainloop.clone();
move |_s, _ud, old, new| {
@@ -723,22 +742,32 @@ fn pw_thread(
}
}
})
.param_changed(|_stream, _tx, id, param| {
.param_changed(move |_stream, _tx, id, param| {
let Some(param) = param else { return };
if id != pw::spa::param::ParamType::Format.as_raw() {
return;
}
let mut info = AudioInfoRaw::default();
if info.parse(param).is_ok() {
// `stream_sink` says WHICH source this format describes, and that changes how
// much it is worth. In stream-sink mode the host owns the sink, so this IS the
// format apps render into and the desktop mix cannot have been narrowed before
// we saw it. In LEGACY monitor mode we are capturing someone else's sink
// through PipeWire's resampler: a 16 kHz Bluetooth headset upstream would
// still be reported here as a clean 48 kHz, exactly the way WASAPI's
// autoconvert hid the same thing on Windows (the 2026-08-03 report). 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.
tracing::info!(
format = ?info.format(),
rate = info.rate(),
channels = info.channels(),
stream_sink = sink_mode,
"audio format negotiated"
);
}
})
.process(|stream, tx| {
.process(|stream, ud| {
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let Some(mut buffer) = stream.dequeue_buffer() else {
return;
@@ -774,7 +803,34 @@ fn pw_thread(
];
samples.push(f32::from_le_bytes(b));
}
let _ = tx.try_send(samples); // drop if the encoder is behind
ud.stats.observe(&samples, ud.channels);
// Non-blocking and lossy, as before — but COUNTED. A full channel means the
// encode thread is not keeping up, and because the encoder simply
// concatenates across the hole every dropped chunk is a click AND a
// permanent shift of everything after it.
if ud.tx.try_send(samples).is_err() {
ud.stats.dropped_chunks += 1;
}
if ud.last_stats.elapsed() >= crate::audio::capture_policy::STATS_EVERY {
let (peak_db, rms_db, delivered_pct) =
ud.stats.summary(ud.last_stats.elapsed(), SAMPLE_RATE);
if ud.stats.dropped_chunks > 0 {
tracing::warn!(
dropped_chunks = ud.stats.dropped_chunks,
"the audio encode thread could not keep up — captured audio was \
DROPPED; the stream will click and everything after it shifts"
);
}
tracing::info!(
peak_db = format!("{peak_db:.1}"),
rms_db = format!("{rms_db:.1}"),
delivered_pct = format!("{delivered_pct:.0}"),
dropped_chunks = ud.stats.dropped_chunks,
"desktop audio capture"
);
ud.stats = Default::default();
ud.last_stats = std::time::Instant::now();
}
}));
if outcome.is_err() {
tracing::error!("panic in pipewire audio callback — chunk dropped");
@@ -14,8 +14,11 @@
//! * default **PLAYBACK** → the plan's loopback endpoint, applied ONLY while a desktop-audio capture
//! is open (`set_playback` — the mic pump must never park the playback default while the host is
//! idle). By default that endpoint is the SILENT sink (Steam Streaming Microphone render side) so
//! audio plays on the client only; `PUNKTFUNK_HOST_AUDIO` prefers real hardware instead (audible on
//! both ends). **Never** the Steam Streaming Speakers, whose loopback is silent — validated live;
//! audio plays on the client only; `audio.output_mode = host_and_client` (formerly
//! `PUNKTFUNK_HOST_AUDIO`) prefers real hardware instead (audible on both ends). Since 2026-08 a
//! silent sink must also be able to CARRY the mix — one that narrows it (a voice-carrier endpoint
//! mixing mono or at 24 kHz) loses to real hardware; see [`super::wiring_plan`]. **Never** the
//! Steam Streaming Speakers, whose loopback is silent — validated live;
//! * default **RECORDING** → the mic target's capture endpoint (VB-Cable "CABLE Output") so host apps
//! record the client's mic by default.
//!
@@ -33,18 +36,44 @@
//!
//! Setting a default endpoint uses the undocumented `IPolicyConfig` COM interface (the only way to set
//! a default device programmatically — neither the `windows` nor `wasapi` crate exposes it; it is the
//! same call `mmsys.cpl` makes). Opt out with `PUNKTFUNK_KEEP_DEFAULT` to leave the user's chosen
//! defaults untouched (the plan is still computed — the mic must still pick a target).
//! same call `mmsys.cpl` makes). The `audio.output_mode = follow_default` setting (formerly
//! `PUNKTFUNK_KEEP_DEFAULT`) leaves the user's chosen defaults untouched the plan is still
//! computed, since the mic must still pick a target.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)]
use super::wiring_plan::{self, plan, Endpoint, Wiring};
use super::wiring_plan::{self, plan, plan_with_formats, Endpoint, MixFormat, Wiring};
use anyhow::{anyhow, bail, Result};
use std::ffi::c_void;
use std::sync::Mutex;
use wasapi::Direction;
/// A render endpoint's engine mix format, or `None` if it cannot be asked right now.
///
/// This is the number the 2026-08-03 field report needed and no log had: the capture side requests
/// 48 kHz f32 with `autoconvert`, so WASAPI converts silently from whatever the endpoint really
/// runs — and a voice-carrier endpoint (Steam's Streaming Microphone) narrowing the desktop mix to
/// mono or 24 kHz was invisible. Reading it costs one `IAudioClient` activation per endpoint, done
/// only during a wiring pass.
///
/// Deliberately total: EVERY failure maps to `None` ("assume it is fine"), because the wiring plan
/// treats an unknown format as non-narrowing. A box where activation fails therefore plans exactly
/// as it did before formats existed, instead of mis-demoting a perfectly good endpoint.
fn mix_format_of(ep: &Endpoint) -> Option<MixFormat> {
let fmt = open_endpoint(ep)
.ok()?
.get_iaudioclient()
.ok()?
.get_mixformat()
.ok()?;
Some(MixFormat {
rate_hz: fmt.get_samplespersec(),
channels: fmt.get_nchannels(),
bits: fmt.get_bitspersample(),
})
}
/// `(friendly_name, endpoint_id)` for every ACTIVE endpoint in direction `dir`.
fn list_endpoints(dir: Direction) -> Vec<Endpoint> {
let mut out = Vec::new();
@@ -69,10 +98,22 @@ fn list_endpoints(dir: Direction) -> Vec<Endpoint> {
out
}
/// `PUNKTFUNK_HOST_AUDIO`: the operator wants the stream audible on the host too — the loopback
/// plan prefers real hardware over the silent sink (the pre-client-only-default behavior).
/// The operator wants the stream audible on the host too — the loopback plan prefers real
/// hardware over the silent sink (the pre-client-only-default behavior).
///
/// Now driven by the first-class `audio.output_mode` setting
/// ([`AudioOutputMode`](pf_host_config::AudioOutputMode)), which still honours the older
/// `PUNKTFUNK_HOST_AUDIO` spelling.
pub(crate) fn host_audio_requested() -> bool {
std::env::var_os("PUNKTFUNK_HOST_AUDIO").is_some()
pf_host_config::config()
.audio_output_mode
.prefers_host_hardware()
}
/// The operator's default playback/recording devices must not be touched at all — the
/// `follow_default` mode, formerly `PUNKTFUNK_KEEP_DEFAULT`.
pub(crate) fn keep_default_devices() -> bool {
pf_host_config::config().audio_output_mode.keeps_default()
}
/// One wiring pass plus the inputs the desktop-audio capture loop's failure handling needs:
@@ -118,7 +159,27 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
let want = std::env::var("PUNKTFUNK_MIC_DEVICE")
.ok()
.map(|s| s.to_lowercase());
let wiring = plan(&renders, &captures, want.as_deref(), host_audio_requested());
// Mix formats are read only when we are actually going to park the playback default (i.e. a
// desktop-audio capture is opening). The mic pump wires on every open while the host is idle
// and does not care which loopback endpoint wins, so it must not pay an IAudioClient
// activation per render endpoint on every pass.
let probe: &dyn Fn(&Endpoint) -> Option<MixFormat> = if set_playback {
&mix_format_of
} else {
&wiring_plan::no_formats
};
let wiring = plan_with_formats(
&renders,
&captures,
want.as_deref(),
host_audio_requested(),
probe,
// The loopback is opened at the session's negotiated channel count, but the wiring pass
// runs before (and outside) any session. Stereo is the floor every session uses and the
// only count a *narrowing* verdict can be made against without guessing: an endpoint that
// cannot carry stereo cannot carry 5.1 either.
2,
);
let done = |wiring: Wiring| WiredPlan {
wiring,
fingerprint,
@@ -142,6 +203,18 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
renders = ?renders.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(),
"audio wiring plan"
);
// The quality warning the 2026-08-03 report had no way to produce. Says WHICH endpoint,
// WHY it is narrow, and the two things the operator can actually do about it.
if let (Some(why), Some((name, _))) = (&wiring.loopback_narrowing, &wiring.loopback_render)
{
tracing::warn!(
device = %name,
"the desktop-audio loopback endpoint {why} — streamed audio will sound worse \
than it does on the host. Attach or select a 48 kHz stereo output device, or \
set audio.output_mode = host_and_client (PUNKTFUNK_HOST_AUDIO=1) to prefer \
real hardware"
);
}
if wiring.mic_render.is_some() && wiring.loopback_unsatisfiable() {
// Inventory + per-endpoint reasons + ONLY the remedies not already taken — the old
// static advice here suggested installing the Steam pair to a field box that had it
@@ -153,10 +226,11 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
}
}
if std::env::var_os("PUNKTFUNK_KEEP_DEFAULT").is_some() {
if keep_default_devices() {
if changed {
tracing::info!(
"PUNKTFUNK_KEEP_DEFAULT set — leaving the audio default devices untouched"
mode = %pf_host_config::config().audio_output_mode.as_str(),
"audio output mode is follow_default — leaving the audio default devices untouched"
);
}
return done(wiring);
@@ -317,6 +391,25 @@ fn park_default_playback(name: &str, id: &str, changed: bool, mic_id: Option<&st
}
}
/// Put the default playback device back on the endpoint we are already capturing, WITHOUT a
/// wiring pass (WP2.4).
///
/// The capture loop uses this when something else takes the default mid-stream: in Assert mode the
/// capture is bound to the planned endpoint explicitly, so the only thing a hijacked default
/// changes is where *apps* render — one `IPolicyConfig` write fixes that, where the old path tore
/// the capture down and re-ran the whole wiring pass. Deliberately does not touch the [`PARKED`]
/// memo: the endpoint is the one we already parked, so the operator's original default is
/// unchanged and still owed back at stream end.
pub(crate) fn reassert_default_playback(id: &str) -> bool {
match set_default_endpoint(id) {
Ok(()) => true,
Err(e) => {
tracing::debug!(error = %format!("{e:#}"), "failed to re-assert the default playback device");
false
}
}
}
/// Put the operator's default playback device back after streaming — the inverse of
/// [`park_default_playback`]. No-op if we never parked it, and a default the operator changed
/// themselves mid-stream is left alone (their choice wins). Must run on a COM-initialized thread
@@ -27,6 +27,7 @@
//! succeed). On thread exit (capturer dropped at stream end) the parked default playback
//! device is restored.
use super::capture_policy::{CaptureStats, FightDamper, FIGHT_BACKOFF, STATS_EVERY};
use super::{audio_control, wiring_plan, AudioCapturer, SAMPLE_RATE};
use anyhow::{anyhow, Context, Result};
use std::collections::VecDeque;
@@ -359,7 +360,7 @@ fn capture_once(
) -> Result<Next> {
// Interleaved f32: channels * 4 bytes per frame.
let block_align = channels as usize * 4;
let keep_default = std::env::var_os("PUNKTFUNK_KEEP_DEFAULT").is_some();
let keep_default = audio_control::keep_default_devices();
// Assert-mode without KEEP_DEFAULT is the only shape that parks the playback default.
let assert_plan = mode == TargetMode::Assert && !keep_default;
let mut plan = audio_control::wire_now_full(assert_plan);
@@ -454,12 +455,25 @@ fn capture_once(
channels as usize,
Some(mask),
);
let (default_period, _min_period) =
audio_client.get_device_period().context("device period")?;
// WP0.1 — the endpoint's ACTUAL engine mix format, read BEFORE we initialize. Everything the
// old log printed ("48 kHz f32 channels=2") was our REQUEST; with `autoconvert` WASAPI
// silently converts from whatever the endpoint really runs, so a voice-carrier endpoint
// narrowing the desktop mix to mono or 24 kHz was invisible in a 3,600-line field log. This
// line is what makes an audio-quality report triageable without a round trip.
let engine = audio_client.get_mixformat().ok();
// NB the plan's WP4.5 ("open the loopback at the MINIMUM device period, worth ~510 ms") is
// deliberately NOT done here, because its premise is wrong: in shared mode
// `IAudioClient::Initialize` cannot change the engine period at all — `hnsBufferDuration` sizes
// the buffer, and the callback still fires at the engine's fixed default period. Lowering it
// needs `IAudioClient3::InitializeSharedAudioStream`, which the `wasapi` crate does not wrap.
// Passing `min_period` here would therefore be a no-op at best and a new Initialize failure
// path at worst, on a device this tree cannot compile for, let alone test. Left as real work.
let (default_period, min_period) = audio_client.get_device_period().context("device period")?;
let stream_mode = StreamMode::EventsShared {
autoconvert: true,
buffer_duration_hns: default_period,
};
let used_period = default_period;
audio_client
.initialize_client(&desired, &Direction::Capture, &stream_mode)
.context("initialize loopback client")?;
@@ -476,7 +490,17 @@ fn capture_once(
tracing::info!(device = %dev_name,
follow = matches!(mode, TargetMode::Follow) || keep_default,
last_resort,
// The endpoint's own format — NOT the one we asked for.
engine_hz = engine.as_ref().map(|f| f.get_samplespersec()),
engine_ch = engine.as_ref().map(|f| f.get_nchannels()),
engine_bits = engine.as_ref().map(|f| f.get_bitspersample()),
buffer_ms = used_period as f32 / 10_000.0,
min_buffer_ms = min_period as f32 / 10_000.0,
"audio loopback capturing");
if let Some(why) = &wiring.loopback_narrowing {
tracing::warn!(device = %dev_name,
"capturing an endpoint that {why} — the stream cannot sound better than this source");
}
// Watchdog seed: the default as it stands right after our open. In Assert mode the plan just
// parked the default on our endpoint — if it did NOT stick (IPolicyConfig denied) converge
@@ -514,6 +538,15 @@ fn capture_once(
let opened_at = Instant::now();
let mut saw_packets = false;
let mut silence_noted = false;
// WP0.2 — the audio plane's own vitals, logged periodically. Before this, a host log said
// nothing whatsoever about audio between "capturing" and the session ending: no level, no
// cadence, and in particular no sign of the SILENT, uncounted drop below, where a stalled
// encode thread loses chunks and the encoder simply concatenates across the hole (a click,
// and a permanent A/V offset, with nothing in any log).
let mut stats = CaptureStats::default();
let mut last_stats = Instant::now();
// WP2.4 — damping for the default-playback tug-of-war.
let mut fight = FightDamper::new(Instant::now());
loop {
if stop.load(Ordering::Relaxed) {
audio_client.stop_stream().ok();
@@ -556,7 +589,34 @@ fn capture_once(
for c in raw.chunks_exact(4) {
samples.push(f32::from_le_bytes([c[0], c[1], c[2], c[3]]));
}
let _ = tx.try_send(samples); // non-blocking, lossy — same discipline as PipeWire
stats.observe(&samples, channels);
// Non-blocking, lossy — same discipline as PipeWire. Now COUNTED: a full channel
// means the encode thread is not keeping up, and every dropped chunk is a click plus
// a permanent shift of everything after it.
if tx.try_send(samples).is_err() {
stats.dropped_chunks += 1;
}
}
if last_stats.elapsed() >= STATS_EVERY {
let (peak_db, rms_db, delivered_pct) = stats.summary(last_stats.elapsed(), SAMPLE_RATE);
if stats.dropped_chunks > 0 {
tracing::warn!(
device = %dev_name,
dropped_chunks = stats.dropped_chunks,
"the audio encode thread could not keep up — captured audio was DROPPED; the \
stream will click and everything after it shifts"
);
}
tracing::info!(
device = %dev_name,
peak_db = format!("{peak_db:.1}"),
rms_db = format!("{rms_db:.1}"),
delivered_pct = format!("{delivered_pct:.0}"),
dropped_chunks = stats.dropped_chunks,
"desktop audio capture"
);
last_stats = Instant::now();
stats = CaptureStats::default();
}
// Watchdog: react when the default render device CHANGES from what we last observed —
@@ -568,29 +628,68 @@ fn capture_once(
if seen_default.as_deref() != Some(nid.as_str()) {
seen_default = Some(nid.clone());
if nid != dev_id {
audio_client.stop_stream().ok();
// NB the stream is stopped per-branch below, NOT here: the WP2.4 Dud
// path deliberately keeps capturing, and stopping first would have made
// the "no teardown" fix silently useless.
if keep_default {
audio_client.stop_stream().ok();
tracing::info!(
"default render device changed (PUNKTFUNK_KEEP_DEFAULT) — \
following it"
);
return Ok(Next::Reopen(TargetMode::Follow));
}
return Ok(match judge_default(&en, wiring, &nid) {
match judge_default(&en, wiring, &nid) {
DefaultKind::Capturable(name) => {
audio_client.stop_stream().ok();
tracing::info!(device = %name,
"operator changed the output device mid-stream — following \
it (audio now also plays on the host)");
Next::Reopen(TargetMode::Follow)
return Ok(Next::Reopen(TargetMode::Follow));
}
// WP2.4 — a DUD default does not affect what we are capturing:
// Assert mode binds the capture to the plan's endpoint EXPLICITLY,
// not to whatever the default happens to be. Only where *apps*
// render has moved. So put the default back and KEEP THE STREAM —
// the old full reopen tore the capture down for nothing, and the
// 2026-08-03 field log shows what that cost: something re-set the
// default to CABLE Input every ~4 s and each round trip was a
// teardown, a re-plan with IPolicyConfig writes, and an audible
// dropout — seven of them in sixteen seconds, one ending in a 2 s
// error backoff.
DefaultKind::Dud(name) => {
tracing::warn!(device = %name,
"default playback moved to an endpoint whose loopback cannot \
work re-asserting the audio wiring plan");
Next::Reopen(TargetMode::Assert)
if !assert_plan {
// Follow/KEEP_DEFAULT shapes still need the old behaviour:
// there the capture IS bound to the default.
audio_client.stop_stream().ok();
return Ok(Next::Reopen(TargetMode::Assert));
}
fight.observed_at(Instant::now());
if fight.should_reassert() {
audio_control::reassert_default_playback(&dev_id);
// Believe our own write: the next watchdog tick sees the
// default back on our endpoint and stays quiet.
seen_default = Some(dev_id.clone());
if fight.warn_now() {
tracing::warn!(device = %name, planned = %dev_name,
"something keeps moving the default playback to an \
endpoint whose loopback cannot work putting it \
back (the capture is unaffected)");
}
} else if fight.warn_giving_up() {
tracing::warn!(device = %name, planned = %dev_name,
backoff_s = FIGHT_BACKOFF.as_secs(),
"another program is repeatedly taking the default \
playback device backing off rather than fighting it. \
Desktop audio keeps streaming from the planned endpoint, \
but apps rendering to the other device will not be heard");
}
}
DefaultKind::Unknown => Next::Reopen(TargetMode::Assert),
});
DefaultKind::Unknown => {
audio_client.stop_stream().ok();
return Ok(Next::Reopen(TargetMode::Assert));
}
}
}
}
}
+268 -6
View File
@@ -43,6 +43,59 @@
/// A `(friendly_name, endpoint_id)` pair as enumerated from WASAPI.
pub(crate) type Endpoint = (String, String);
/// A render endpoint's ENGINE MIX FORMAT, as `IAudioClient::GetMixFormat` reports it.
///
/// This is the number the 2026-08-03 field report needed and the log did not have. The capture
/// side opens with `autoconvert: true` and asks for 48 kHz f32 in the wire layout, so WASAPI
/// silently converts whatever the endpoint really runs — and the "48 kHz f32 channels=2" we
/// logged was our REQUEST, not the source. An endpoint that mixes at 24 kHz mono therefore
/// produced a 48 kHz stereo stream that had already been through a 24 kHz mono bottleneck, with
/// nothing in any log to say so.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct MixFormat {
pub rate_hz: u32,
pub channels: u16,
pub bits: u16,
}
impl MixFormat {
/// Why this endpoint would NARROW a `want`-channel desktop mix, or `None` if it carries it
/// intact. Bit depth is deliberately not a criterion: 16-bit is ~96 dB of headroom, far below
/// Opus's own noise floor, whereas a lost channel or halved bandwidth is plainly audible.
pub(crate) fn narrowing(&self, want: u8) -> Option<String> {
if self.rate_hz < 48_000 && self.channels < want as u16 {
return Some(format!(
"mixes at {} Hz and only {} channel(s)",
self.rate_hz, self.channels
));
}
if self.rate_hz < 48_000 {
return Some(format!(
"mixes at {} Hz, so the stream is band-limited to ~{} kHz before Opus sees it",
self.rate_hz,
self.rate_hz / 2000
));
}
if self.channels < want as u16 {
return Some(format!(
"mixes {} channel(s), so a {want}-channel desktop mix is downmixed and re-expanded",
self.channels
));
}
None
}
}
/// Looks up a render endpoint's mix format by endpoint id. `None` = unknown (enumeration failed,
/// or the caller has no way to ask) — treated as "assume it is fine", so a probe failure can
/// never make the plan worse than it was before formats existed.
pub(crate) type FormatProbe<'a> = &'a dyn Fn(&Endpoint) -> Option<MixFormat>;
/// A [`FormatProbe`] that knows nothing — the pre-WP2.1 behaviour.
pub(crate) fn no_formats(_: &Endpoint) -> Option<MixFormat> {
None
}
/// The coherent endpoint assignment for one wiring pass. Computed fresh on every mic/capture
/// (re)open — Windows endpoints churn (boot-time registration, hotplug, driver installs), so a
/// once-per-process plan goes stale.
@@ -60,6 +113,11 @@ pub(crate) struct Wiring {
/// the mic reservation. The capture side treats it as a stopgap: it warns when the silence
/// materializes and re-plans on any endpoint-set change instead of riding it out.
pub loopback_last_resort: bool,
/// Set when the chosen loopback endpoint's mix format NARROWS the desktop mix (see
/// [`MixFormat::narrowing`]) and the plan took it anyway because nothing better existed. Carries
/// the human-readable reason for the capture side to log — a quality risk the operator can act
/// on (attach a real output, or set the output mode to prefer hardware), not a failure.
pub loopback_narrowing: Option<String>,
}
impl Wiring {
@@ -137,6 +195,32 @@ pub(crate) fn plan(
captures: &[Endpoint],
mic_want: Option<&str>,
host_audio: bool,
) -> Wiring {
plan_with_formats(renders, captures, mic_want, host_audio, &no_formats, 2)
}
/// [`plan`] with knowledge of each render endpoint's engine mix format, and the channel count the
/// session wants to carry.
///
/// **The 2026-08-03 field report is this function's reason to exist.** The default client-only
/// preference takes the "silent sink" — 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.
///
/// So a silent sink now has to EARN its preference: if its mix format narrows the mix (see
/// [`MixFormat::narrowing`]) it drops below real hardware. It is still taken when nothing better
/// exists — narrow audio beats no audio — but flagged in [`Wiring::loopback_narrowing`] so the
/// capture side can say why. An unknown format (probe failed) counts as fine, so this can never
/// make the plan worse than it was before formats existed.
pub(crate) fn plan_with_formats(
renders: &[Endpoint],
captures: &[Endpoint],
mic_want: Option<&str>,
host_audio: bool,
format_of: FormatProbe,
want_channels: u8,
) -> Wiring {
let find_render = |needle: &str| {
renders
@@ -172,10 +256,18 @@ pub(crate) fn plan(
not_mic(id) && !excluded_from_loopback(&ln) && !virtualish(&ln)
})
};
let silent = || {
renders
.iter()
.find(|(n, id)| not_mic(id) && silent_sink(&n.to_lowercase()))
// A silent sink splits in two: one that carries the mix intact, and one that narrows it. The
// first keeps the historical preference; the second falls BELOW real hardware.
let narrowing_of = |ep: &Endpoint| format_of(ep).and_then(|f| f.narrowing(want_channels));
let silent_intact = || {
renders.iter().find(|ep| {
not_mic(&ep.1) && silent_sink(&ep.0.to_lowercase()) && narrowing_of(ep).is_none()
})
};
let silent_narrow = || {
renders.iter().find(|ep| {
not_mic(&ep.1) && silent_sink(&ep.0.to_lowercase()) && narrowing_of(ep).is_some()
})
};
// LAST RESORT — the Steam Streaming Speakers, and ONLY them. Their loopback is known-silent
// (validated live): a QUALITY risk, flagged so the capture side can warn when the silence
@@ -192,10 +284,13 @@ pub(crate) fn plan(
.iter()
.find(|(n, id)| not_mic(id) && n.to_lowercase().contains("steam streaming speakers"))
};
// A narrowing silent sink sits below real hardware in BOTH modes: preferring silence on the
// host is a routing choice, but it must not silently cost audio quality when a clean endpoint
// is right there.
let preferred = if host_audio {
real_hw().or_else(silent)
real_hw().or_else(silent_intact).or_else(silent_narrow)
} else {
silent().or_else(real_hw)
silent_intact().or_else(real_hw).or_else(silent_narrow)
};
let (loopback_render, loopback_last_resort) = match preferred {
Some(ep) => (Some(ep.clone()), false),
@@ -204,12 +299,16 @@ pub(crate) fn plan(
None => (None, false),
},
};
// Report narrowing for whatever we actually chose — including real hardware, which can also
// be a 24 kHz mono endpoint (a headset's hands-free profile is exactly that).
let loopback_narrowing = loopback_render.as_ref().and_then(narrowing_of);
Wiring {
mic_render,
mic_capture,
loopback_render,
loopback_last_resort,
loopback_narrowing,
}
}
@@ -550,6 +649,169 @@ mod tests {
}
}
// ---- format-aware loopback selection (WP2.1) -----------------------------------------
fn fmt(rate_hz: u32, channels: u16) -> MixFormat {
MixFormat {
rate_hz,
channels,
bits: 32,
}
}
/// Probe helper: give endpoints whose (lowercased) name contains a needle that format,
/// everything else unknown. Owns its table so call sites can pass a literal inline.
fn probe(table: Vec<(&'static str, MixFormat)>) -> impl Fn(&Endpoint) -> Option<MixFormat> {
move |ep: &Endpoint| {
let name = ep.0.to_lowercase();
table
.iter()
.find_map(|(needle, f)| name.contains(needle).then_some(*f))
}
}
/// THE 2026-08-03 field case, with formats. The reporter's exact endpoint inventory: the plan
/// took the Steam Streaming Microphone on all 31 opens while a clean AMD HD Audio endpoint sat
/// idle. Once we can see that the silent sink narrows the mix, real hardware must win.
#[test]
fn narrowing_silent_sink_loses_to_real_hardware() {
let renders = [
ep("CABLE In 16ch (VB-Audio Virtual Cable)"),
ep("Altavoces (Steam Streaming Speakers)"),
ep("Altavoces (Steam Streaming Microphone)"),
ep("CABLE Input (VB-Audio Virtual Cable)"),
ep("1 - Odyssey G60SD (AMD High Definition Audio Device)"),
];
let captures = [
ep("CABLE Output (VB-Audio Virtual Cable)"),
ep("Microphone (Steam Streaming Microphone)"),
];
// A voice-carrier endpoint: 24 kHz mono.
let p = probe(vec![
("steam streaming microphone", fmt(24_000, 1)),
("odyssey", fmt(48_000, 2)),
]);
let w = plan_with_formats(&renders, &captures, None, false, &p, 2);
assert_eq!(
w.loopback_render.as_ref().unwrap().0,
"1 - Odyssey G60SD (AMD High Definition Audio Device)",
"a narrowing silent sink must not beat clean real hardware"
);
assert!(
w.loopback_narrowing.is_none(),
"the chosen endpoint is intact"
);
// The mic assignment is untouched by any of this.
assert_eq!(
w.mic_render.unwrap().0,
"CABLE Input (VB-Audio Virtual Cable)"
);
}
/// …but a silent sink that carries the mix intact keeps its historical preference: the
/// client-only routing default is not being abandoned, only made conditional on quality.
#[test]
fn intact_silent_sink_still_wins() {
let renders = [
ep("Speakers (Realtek HD Audio)"),
ep("CABLE Input (VB-Audio Virtual Cable)"),
ep("Speakers (Steam Streaming Microphone)"),
];
let p = probe(vec![
("steam streaming microphone", fmt(48_000, 2)),
("realtek", fmt(48_000, 2)),
]);
let w = plan_with_formats(&renders, &[], None, false, &p, 2);
assert_eq!(
w.loopback_render.unwrap().0,
"Speakers (Steam Streaming Microphone)"
);
}
/// Narrow audio still beats NO audio: with nothing else available the narrowing sink is taken
/// and flagged, not refused.
#[test]
fn narrowing_sink_is_taken_when_it_is_all_there_is() {
let renders = [
ep("CABLE Input (VB-Audio Virtual Cable)"),
ep("Speakers (Steam Streaming Microphone)"),
];
let p = probe(vec![("steam streaming microphone", fmt(16_000, 1))]);
let w = plan_with_formats(&renders, &[], None, false, &p, 2);
assert_eq!(
w.loopback_render.as_ref().unwrap().0,
"Speakers (Steam Streaming Microphone)"
);
let why = w.loopback_narrowing.expect("must be flagged");
assert!(why.contains("16000"), "{why}");
}
/// Real hardware can narrow too — a headset in its hands-free profile is 16 kHz mono — and
/// must be flagged just the same. The flag is about the CHOSEN endpoint, not about which tier
/// it came from.
#[test]
fn narrowing_is_reported_for_real_hardware_too() {
let renders = [ep("Headset (Hands-Free AG Audio)")];
let p = probe(vec![("headset", fmt(16_000, 1))]);
let w = plan_with_formats(&renders, &[], None, false, &p, 2);
assert_eq!(
w.loopback_render.as_ref().unwrap().0,
"Headset (Hands-Free AG Audio)"
);
assert!(w.loopback_narrowing.is_some());
}
/// An unknown format must never make the plan WORSE than it was before formats existed: a
/// probe that answers nothing has to reproduce `plan` exactly.
#[test]
fn unknown_formats_reproduce_the_formatless_plan() {
let renders = [
ep("Speakers (Apple Audio Device)"),
ep("CABLE Input (VB-Audio Virtual Cable)"),
ep("Speakers (Steam Streaming Speakers)"),
ep("Speakers (Steam Streaming Microphone)"),
];
let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")];
for host_audio in [false, true] {
let a = plan(&renders, &captures, None, host_audio);
let b = plan_with_formats(&renders, &captures, None, host_audio, &no_formats, 2);
assert_eq!(a, b, "host_audio={host_audio}");
assert!(a.loopback_narrowing.is_none());
}
}
/// `host_audio` still prefers real hardware, and a narrowing silent sink stays last in that
/// mode too.
#[test]
fn host_audio_ordering_survives_formats() {
let renders = [
ep("Speakers (Realtek HD Audio)"),
ep("Speakers (Steam Streaming Microphone)"),
];
let p = probe(vec![
("steam streaming microphone", fmt(24_000, 1)),
("realtek", fmt(48_000, 2)),
]);
let w = plan_with_formats(&renders, &[], None, true, &p, 2);
assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)");
}
/// The narrowing test is channel-count aware: an endpoint that is fine for stereo narrows a
/// 5.1 session.
#[test]
fn narrowing_depends_on_the_session_channel_count() {
let stereo_only = fmt(48_000, 2);
assert_eq!(stereo_only.narrowing(2), None);
assert!(stereo_only.narrowing(6).is_some());
// Rate is judged independently of channels.
assert!(fmt(44_100, 8).narrowing(2).is_some());
// And an endpoint wider than the session is never "narrowing".
assert_eq!(fmt(48_000, 8).narrowing(2), None);
// Both wrong: the message must name both problems.
let both = fmt(16_000, 1).narrowing(6).unwrap();
assert!(both.contains("16000") && both.contains("channel"), "{both}");
}
/// Operator override beats the candidate order.
#[test]
fn env_override_wins() {
+41 -4
View File
@@ -26,9 +26,7 @@
#![deny(clippy::undocumented_unsafe_blocks)]
use anyhow::{anyhow, Context, Result};
use punktfunk_core::config::{
mtu1500_shard_payload_for, CompositorPref, FecConfig, FecScheme, GamepadPref, Role,
};
use punktfunk_core::config::{CompositorPref, FecConfig, FecScheme, GamepadPref, Role};
use punktfunk_core::input::{InputEvent, InputKind};
use punktfunk_core::packet::{FLAG_PIC, FLAG_PROBE, FLAG_SOF};
use punktfunk_core::quic::{
@@ -72,6 +70,9 @@ use input::{input_thread, ClientInput};
/// The Hello→Welcome→Start negotiation (plan §W1); `serve_session` calls `handshake::negotiate`
/// after the pairing gate.
mod handshake;
/// MTU resilience for the video data plane: `PUNKTFUNK_WIRE_MTU` override, the per-session
/// path-MTU watch on the control connection, and the per-peer learned shard-payload clamp.
mod wire_mtu;
/// The mid-stream control task (plan §W1); `serve_session` spawns `control::run` after the
/// handshake to multiplex renegotiation / speed-test control messages onto the data-plane channels.
@@ -1090,6 +1091,30 @@ async fn serve_session(
// just never fires then.
let (cursor_shape_tx, cursor_shape_rx) =
tokio::sync::mpsc::unbounded_channel::<punktfunk_core::quic::CursorShape>();
// Mid-session shard renegotiation (design/shard-payload-reneg.md Phase 2): the wire-MTU
// watcher decides (constrained-path shrink / ack-gated jumbo grow), the control task
// writes the `ShardPayloadChanged` and routes the acks back, and the data-plane loop
// applies `Session::set_shard_payload` between AUs (drained next to `bitrate_rx`).
// Channels are wired unconditionally (they just never fire); the DRIVER exists only for
// a client that advertised `Hello::max_shard_payload` on a non-chunk-aligned session —
// PyroWave clients parse chunk-aligned AUs in windows of the `Welcome` value pinned at
// session start (read once over the C ABI), so those sessions keep the leg-1
// next-session clamp instead of a mid-stream re-key.
let (shard_change_tx, shard_change_rx) = tokio::sync::mpsc::unbounded_channel::<u16>();
let (shard_ack_tx, shard_ack_rx) = tokio::sync::mpsc::unbounded_channel::<u16>();
let (shard_apply_tx, shard_apply_rx) = std::sync::mpsc::channel::<usize>();
let shard_reneg = (hello.max_shard_payload > 0 && codec != crate::encode::Codec::PyroWave)
.then_some(wire_mtu::ShardReneg {
client_ceiling: hello.max_shard_payload,
change_tx: shard_change_tx,
ack_rx: shard_ack_rx,
apply_tx: shard_apply_tx,
});
// The session is real: watch this connection's MTU discovery settle and turn it into a
// path verdict (WARN + learned clamp for the next session on a constrained path; clears
// a stale clamp on a healthy one) — and, with the driver above, heal or grow THIS
// session mid-stream. Bounded ~10 s task unless a jumbo grow leaves it as revert guard.
wire_mtu::spawn_watch(conn.clone(), welcome.shard_payload as usize, shard_reneg);
// Negotiated cursor forwarding: the HOST_CAP_CURSOR bit the Welcome advertised, read back
// rather than recomputed (`handshake::cursor_forward` computed it once, with the encoder
// blend-capability gate — re-running it here could drift, and would re-probe).
@@ -1145,6 +1170,8 @@ async fn serve_session(
probe_result_rx,
reconfig_result_rx,
retarget_rx,
shard_change_rx,
shard_ack_tx,
cursor_shape_rx,
cursor_client_draws,
clip_enabled,
@@ -1307,9 +1334,18 @@ async fn serve_session(
let stop = stop.clone();
let cap = audio_cap.clone();
let channels = welcome.audio_channels;
// Read the granted bit back off the Welcome (the cursor plane's precedent), so the wire
// the client was promised and the wire we actually send cannot disagree — then re-derive
// the SAME budget rung from it, so the encode tier and the redundancy decision are one
// choice made once rather than two settings that can drift apart.
let budget = handshake::audio_budget(
welcome.host_caps & punktfunk_core::quic::HOST_CAP_AUDIO_RED != 0,
welcome.bitrate_kbps,
channels,
);
std::thread::Builder::new()
.name("punktfunk1-audio".into())
.spawn(move || audio_thread(conn, stop, cap, channels))
.spawn(move || audio_thread(conn, stop, cap, channels, budget))
.map_err(|e| tracing::warn!(error = %e, "audio thread spawn failed — session continues without audio"))
.ok()
} else {
@@ -1578,6 +1614,7 @@ async fn serve_session(
keyframe: keyframe_rx,
rfi: rfi_rx,
bitrate_rx,
shard_rx: shard_apply_rx,
compositor,
gamescope_route,
bitrate_kbps,
+69 -17
View File
@@ -1,8 +1,13 @@
//! The native audio plane (plan §W1 — carved out of the [`super`] module): desktop capture → Opus
//! (48 kHz, 5 ms, CBR the same tuning as the GameStream path) → `AUDIO_MAGIC` QUIC datagrams, at
//! the negotiated channel count. The encoder ([`NativeAudioEnc`]) and the capture/encode/send loop
//! ([`audio_thread`]) are gated to linux/windows (libopus + a real capturer); other targets get the
//! stub, so a dev build streams video-only rather than failing to compile.
//! (48 kHz, 5 ms, constrained VBR at the configured [`AudioTier`](punktfunk_core::audio::AudioTier))
//! → `AUDIO_MAGIC` QUIC datagrams — or `AUDIO_RED_MAGIC` when the session negotiated redundancy —
//! at the negotiated channel count. The encoder ([`NativeAudioEnc`]) and the capture/encode/send
//! loop ([`audio_thread`]) are gated to linux/windows (libopus + a real capturer); other targets
//! get the stub, so a dev build streams video-only rather than failing to compile.
//!
//! Two things here deliberately DIVERGE from the GameStream plane, which used to share this
//! tuning: hard CBR (its audio FEC needs fixed-size packets; this plane has no FEC, so CBR was a
//! pure quality tax) and the fixed 128 kbps stereo bitrate. See [`NativeAudioEnc::new`].
use super::*;
@@ -17,20 +22,36 @@ enum NativeAudioEnc {
#[cfg(any(target_os = "linux", target_os = "windows"))]
impl NativeAudioEnc {
/// Build the encoder for `channels` (2/6/8), hard-CBR + RESTRICTED_LOWDELAY like the
/// GameStream path; bitrate from the shared layout table (stereo keeps the validated 128 kbps).
fn new(channels: u8) -> Result<NativeAudioEnc, opus::Error> {
/// Build the encoder for `channels` (2/6/8) at `tier`, RESTRICTED_LOWDELAY like the GameStream
/// path but — unlike it — in CONSTRAINED VBR.
///
/// **Why not hard CBR (WP1.2).** The layout table's comment justifies `set_vbr(false)` with
/// "constant packet size, which GameStream's audio FEC relies on" — true of the GameStream
/// plane, and irrelevant here: the native `punktfunk/1` audio plane has no FEC at all (see
/// `punktfunk_core::audio::AudioGapTracker`, which exists precisely because a lost packet has
/// nothing to rebuild it from). So this path was paying a pure quality tax for a constraint
/// that does not apply to it. Constrained VBR keeps the same average bitrate and the same
/// bounded packet size, and spends the bits where the signal needs them.
///
/// The GameStream encoder (`crate::gamestream::audio`) is deliberately NOT changed: its FEC
/// really does need fixed-size packets.
fn new(
channels: u8,
tier: punktfunk_core::audio::AudioTier,
) -> Result<NativeAudioEnc, opus::Error> {
let l = punktfunk_core::audio::layout_for(channels, false);
let bitrate = l.bitrate_for(tier);
if channels == 2 {
let mut e = opus::Encoder::new(
crate::audio::SAMPLE_RATE,
opus::Channels::Stereo,
opus::Application::LowDelay,
)?;
e.set_bitrate(opus::Bitrate::Bits(128_000)).ok();
e.set_vbr(false).ok();
e.set_bitrate(opus::Bitrate::Bits(bitrate)).ok();
e.set_vbr(true).ok();
e.set_vbr_constraint(true).ok();
Ok(NativeAudioEnc::Stereo(e))
} else {
let l = punktfunk_core::audio::layout_for(channels, false);
let mut e = opus::MSEncoder::new(
crate::audio::SAMPLE_RATE,
l.streams,
@@ -38,8 +59,9 @@ impl NativeAudioEnc {
l.mapping,
opus::Application::LowDelay,
)?;
e.set_bitrate(opus::Bitrate::Bits(l.bitrate)).ok();
e.set_vbr(false).ok();
e.set_bitrate(opus::Bitrate::Bits(bitrate)).ok();
e.set_vbr(true).ok();
e.set_vbr_constraint(true).ok();
Ok(NativeAudioEnc::Surround(e))
}
}
@@ -52,8 +74,8 @@ impl NativeAudioEnc {
}
}
/// The audio thread: desktop capture → Opus (48 kHz, 5 ms, CBR — same tuning as the GameStream
/// path) → `AUDIO_MAGIC` datagrams, at the negotiated `channels` (2 stereo / 6 = 5.1 / 8 = 7.1,
/// The audio thread: desktop capture → Opus (48 kHz, 5 ms, constrained VBR at the configured
/// tier) → `AUDIO_MAGIC` (or `AUDIO_RED_MAGIC`) datagrams, at the negotiated `channels` (2 stereo / 6 = 5.1 / 8 = 7.1,
/// canonical wire order FL FR FC LFE RL RR SL SR). QUIC already encrypts; no extra layer. The
/// capturer comes from (and returns to) the persistent slot — see [`AudioCapSlot`].
#[cfg(any(target_os = "linux", target_os = "windows"))]
@@ -62,11 +84,16 @@ pub(super) fn audio_thread(
stop: Arc<AtomicBool>,
audio_cap: AudioCapSlot,
channels: u8,
budget: punktfunk_core::audio::AudioBudget,
) {
use crate::audio::SAMPLE_RATE;
const FRAME_MS: usize = 5;
const SAMPLES_PER_FRAME: usize = SAMPLE_RATE as usize * FRAME_MS / 1000; // 240
let want = punktfunk_core::audio::normalize_channels(channels);
// Tier and redundancy are ONE decision, budgeted against the session's video bitrate — see
// `handshake::audio_budget`. An unparseable `audio.quality` was already warned about there
// and fell back to the default, so nothing here can silently downgrade someone's audio.
let (tier, redundancy) = (budget.tier, budget.redundancy);
// Reuse the cached capturer ONLY when its channel count matches this session's; a stereo
// capturer left by a prior session must not feed a 5.1/7.1 session (the encoder + the client's
@@ -92,7 +119,7 @@ pub(super) fn audio_thread(
}
}
};
let mut enc = match NativeAudioEnc::new(want) {
let mut enc = match NativeAudioEnc::new(want, tier) {
Ok(e) => e,
Err(e) => {
tracing::warn!(error = %e, "opus encoder init failed — session continues without audio");
@@ -120,9 +147,16 @@ pub(super) fn audio_thread(
// A stuck Opus encoder would fail on every 5 ms frame (~200/s); power-of-two throttle the
// warn so it can't flood stderr + the log ring while still surfacing that it's failing.
let mut opus_encode_errs: u64 = 0;
// WP3.1 — the previous frame's Opus bytes, for the redundant `0xD2` plane. Cleared whenever
// continuity breaks (a capture reopen), so we never advertise a predecessor the client's
// sequence numbering does not agree with.
let mut prev_frame: Vec<u8> = Vec::new();
if capturer.is_some() {
tracing::info!(
channels = want,
tier = tier.as_str(),
kbps = budget.kbps,
redundancy,
"punktfunk/1 audio streaming (Opus 48 kHz, 5 ms datagrams)"
);
}
@@ -138,6 +172,10 @@ pub(super) fn audio_thread(
capturer = Some(c);
last_failed = None;
acc.clear(); // drop the partial frame straddling the gap
// The next frame has no valid predecessor across the gap: sending the
// pre-gap frame as "the previous one" would hand the client audio from
// before the discontinuity to splice in.
prev_frame.clear();
}
Err(e) => {
tracing::debug!(error = %format!("{e:#}"), "audio reopen failed — will retry");
@@ -162,11 +200,24 @@ pub(super) fn audio_thread(
let pts_ns = now_ns();
match enc.encode_float(&frame, &mut opus_buf) {
Ok(n) => {
let d =
punktfunk_core::quic::encode_audio_datagram(seq, pts_ns, &opus_buf[..n]);
let opus = &opus_buf[..n];
let d = if redundancy {
punktfunk_core::quic::encode_audio_red_datagram(
seq,
pts_ns,
opus,
&prev_frame,
)
} else {
punktfunk_core::quic::encode_audio_datagram(seq, pts_ns, opus)
};
if conn.send_datagram(d.into()).is_err() {
break 'session; // connection gone
}
if redundancy {
prev_frame.clear();
prev_frame.extend_from_slice(opus);
}
seq = seq.wrapping_add(1);
}
Err(e) => {
@@ -199,6 +250,7 @@ pub(super) fn audio_thread(
_stop: Arc<AtomicBool>,
_audio_cap: AudioCapSlot,
_channels: u8,
_budget: punktfunk_core::audio::AudioBudget,
) {
tracing::warn!("punktfunk/1 audio requires Linux or Windows — session continues without it");
}
@@ -43,6 +43,11 @@ pub(super) async fn run(
// Host-initiated bitrate re-target (a rebuild re-resolved an Automatic rate): forwarded to
// the client as a `BitrateChanged` so its controller's climb base tracks the real encoder.
mut retarget_rx: tokio::sync::mpsc::UnboundedReceiver<u32>,
// Mid-session shard renegotiation (design/shard-payload-reneg.md): the wire-MTU watcher
// asks for a `ShardPayloadChanged` here (this task is the control stream's sole writer),
// and the client's `ShardPayloadAck`s flow back on `shard_ack_tx` — the grow gate.
mut shard_change_rx: tokio::sync::mpsc::UnboundedReceiver<u16>,
shard_ack_tx: tokio::sync::mpsc::UnboundedSender<u16>,
mut cursor_shape_rx: tokio::sync::mpsc::UnboundedReceiver<punktfunk_core::quic::CursorShape>,
cursor_client_draws: Arc<AtomicBool>,
clip_enabled: Arc<AtomicBool>,
@@ -56,6 +61,9 @@ pub(super) async fn run(
// Set once `clip_offer_rx` closes (coordinator gone / inert handle) so its `select!` branch
// stops firing on a perpetually-ready `None`.
let mut clip_offer_closed = false;
// Same discipline for the wire-MTU watcher's channel — its bounded lifetime ends mid-session
// on every healthy path.
let mut shard_change_closed = false;
let mut active = initial_mode;
// Host-side switch rate limit (a backstop against a hostile/broken client spamming
// Reconfigure into pipeline-rebuild churn — the drain-to-newest in the data plane already
@@ -214,6 +222,16 @@ pub(super) async fn run(
if bitrate_tx.send(resolved).is_err() {
break; // data plane gone
}
} else if let Ok(ack) = punktfunk_core::quic::ShardPayloadAck::decode(&msg) {
// Mid-session shard renegotiation: the client applied (or granted) a
// geometry change. Forward to the wire-MTU watcher — for a grow this IS
// the gate that lets the packetizer go above the old size. A dropped
// send just means the watcher already ended (shrink acks are telemetry).
tracing::info!(
shard_payload = ack.shard_payload,
"client acked shard-payload change"
);
let _ = shard_ack_tx.send(ack.shard_payload);
} else if let Ok(req) = ProbeRequest::decode(&msg) {
tracing::info!(
target_kbps = req.target_kbps,
@@ -317,6 +335,19 @@ pub(super) async fn run(
break;
}
}
n = shard_change_rx.recv(), if !shard_change_closed => {
// Mid-session shard renegotiation: the wire-MTU watcher decided (shrink on a
// constrained-path verdict / ack-gated jumbo grow). Only ever fires toward a
// client that advertised `Hello::max_shard_payload` — the watcher owns that
// gate. `None` = the watcher's bounded lifetime ended (normal, NOT a session
// end): disable this branch, exactly the `clip_offer_closed` pattern — a
// closed mpsc yields `None` perpetually and would busy-spin the select.
let Some(n) = n else { shard_change_closed = true; continue };
let msg = punktfunk_core::quic::ShardPayloadChanged { shard_payload: n };
if io::write_msg(&mut ctrl_send, &msg.encode()).await.is_err() {
break;
}
}
shape = cursor_shape_rx.recv() => {
// Cursor-forward bridge (M2): the encode loop diffed a new pointer bitmap.
// Rare (shape changes are human-paced); ≤ ~58 KiB fits the u16 frame by
+80 -1
View File
@@ -24,6 +24,63 @@ use super::*;
/// paints on a Mutter virtual stream), and only a can't-blend backend falls back to the
/// compositor EMBED. THE single predicate: the Welcome's `HOST_CAP_CURSOR` bit is computed
/// from it, and the session wiring reads that bit back.
/// THE single audio-plane decision for a session: the encode tier AND whether the redundant
/// `0xD2` plane is sent. The Welcome's `HOST_CAP_AUDIO_RED` bit is computed from it, and
/// `serve_session` reads that bit back to configure the audio thread — so the wire the client is
/// promised and the wire we send cannot disagree.
///
/// Capable-and-agreed for redundancy: the client must have advertised `CLIENT_CAP_AUDIO_RED`, so a
/// session with an older client keeps the plain `0xC9` wire byte-for-byte.
///
/// **Both halves are then BUDGETED against the session's video bitrate**
/// ([`plan_audio_budget`](punktfunk_core::audio::plan_audio_budget)). Tier `High` and redundancy
/// were introduced separately, each costed as "~1 % of the video budget", and they multiply:
/// 256 kbps stereo sent twice is 512 kbps — ~10 % of a 5 Mbps session. Audio rides QUIC datagrams,
/// outside the ABR loop, so ABR can neither see that nor reclaim it. The budget is what stops a
/// constrained link silently handing a tenth of its bandwidth to audio.
///
/// The operator's `audio.quality` / `audio.redundancy` settings are the REQUEST; the budget may
/// lower them, never raise them.
///
/// NB the plan's "only while the link is actually losing packets" gate is deliberately not here:
/// turning redundancy on and off mid-session changes the wire tag, and the client's decoder would
/// have to re-derive which plane it is on from every datagram. Deciding once, at handshake, against
/// a bitrate we already know is both cheaper and more predictable.
/// `wants_redundancy` is the caller's answer to "is `0xD2` even on the table" — at handshake that
/// is the client's cap AND the operator's setting; afterwards it is the GRANTED
/// `HOST_CAP_AUDIO_RED` bit, so the audio thread re-derives the same rung of the same ladder.
pub(super) fn audio_budget(
wants_redundancy: bool,
video_kbps: u32,
channels: u8,
) -> punktfunk_core::audio::AudioBudget {
let configured = pf_host_config::config().audio_quality.as_deref();
let requested = match configured {
None => punktfunk_core::audio::AudioTier::default(),
Some(s) => punktfunk_core::audio::AudioTier::parse(s).unwrap_or_else(|| {
// Once per process: this runs per session, and an operator with a typo in host.env
// does not need it on every connect. Never silently downgrade someone's audio.
static WARNED: std::sync::Once = std::sync::Once::new();
WARNED.call_once(|| {
tracing::warn!(
value = %s,
"audio.quality (PUNKTFUNK_AUDIO_QUALITY) is not one of low/standard/high — \
using the default"
);
});
punktfunk_core::audio::AudioTier::default()
}),
};
punktfunk_core::audio::plan_audio_budget(video_kbps, channels, requested, wants_redundancy)
}
/// The operator's answer to "may this session use redundancy at all", before the budget is
/// consulted: the client must be able to decode it and the operator must not have forced it off.
pub(super) fn redundancy_offered(client_caps: u8) -> bool {
client_caps & punktfunk_core::quic::CLIENT_CAP_AUDIO_RED != 0
&& pf_host_config::config().audio_redundancy.unwrap_or(true)
}
pub(super) fn cursor_forward(
client_caps: u8,
compositor: Option<crate::vdisplay::Compositor>,
@@ -491,7 +548,12 @@ pub(super) async fn negotiate(
// per-datagram loss on Wi-Fi — the "100 Mbps badly fails on the phone" root cause.
// Negotiated, so the client follows. Jumbo (≈8900) is a future negotiated bump (needs
// MAX_DATAGRAM_BYTES raised + end-to-end 9000 MTU).
shard_payload: mtu1500_shard_payload_for(peer.ip()) as u16,
// Resolution order (wire_mtu.rs): `PUNKTFUNK_WIRE_MTU` operator override, then a path
// budget learned from a prior session whose QUIC MTU discovery settled below the
// video-datagram ceiling (the "VPN on the host blackholes every video packet" field
// shape — small flows pass, the stream is an endless black screen), then this family
// default. Healthy paths take the default branch and are byte-identical to before.
shard_payload: wire_mtu::negotiated_shard_payload(peer.ip()) as u16,
encrypt: true,
key,
salt,
@@ -564,6 +626,20 @@ pub(super) async fn negotiate(
punktfunk_core::quic::HOST_CAP_PEN
} else {
0
}
// Redundant desktop-audio plane (0xD2): the client asked, the operator has not forced
// it off, AND it fits the session's audio budget. Capable-and-agreed like the cursor
// bit — a client that did not ask keeps the plain 0xC9 wire byte-for-byte.
| if audio_budget(
redundancy_offered(hello.client_caps),
bitrate_kbps,
audio_channels,
)
.redundancy
{
punktfunk_core::quic::HOST_CAP_AUDIO_RED
} else {
0
},
// The negotiated session AEAD (resolved above) + its 32-byte key toward a ChaCha
// client; toward everyone else cipher 0 keeps the Welcome byte-identical to the
@@ -658,6 +734,9 @@ pub(super) async fn negotiate(
let start =
Start::decode(&io::read_msg(recv).await?).map_err(|e| anyhow!("Start decode: {e:?}"))?;
bringup.mark("start");
// The wire-MTU watch (`wire_mtu::spawn_watch`) is spawned by `serve_session` after the
// control-task channels exist — it now also DRIVES the mid-session shard renegotiation
// (design/shard-payload-reneg.md), which needs the control stream's writer.
Ok::<_, anyhow::Error>((
hello,
welcome,
@@ -762,6 +762,9 @@ fn send_loop(
slice_wire: bool,
burst_cap: Option<usize>,
fec_target: Arc<AtomicU8>,
// Mid-session shard-payload re-keys from the wire-MTU watcher (validated + ack-gated
// there) — applied between AUs only (design/shard-payload-reneg.md Phase 1).
shard_rx: std::sync::mpsc::Receiver<usize>,
stats: SendStats,
// `Some` = the client advertised VIDEO_CAP_HOST_TIMING: emit one 0xCF datagram per AU right
// after its last packet left the socket (capture→sent, the whole host pipeline incl. pacing).
@@ -818,6 +821,25 @@ fn send_loop(
}
// Adaptive FEC: pick up any new recovery target the control task set from client LossReports.
apply_fec_target(&mut session, &fec_target);
// Mid-session shard renegotiation: apply a re-key from the wire-MTU watcher — between
// AUs only, NEVER with a streamed AU open (its shard-aligned tiling derives from the
// size it began with; same gate as the probe burst above). Drain to the newest; the
// protocol side (client advertisement, ack-gated grow) was enforced by the watcher.
if streamed.is_none() {
let mut want_shard = None;
while let Ok(s) = shard_rx.try_recv() {
want_shard = Some(s);
}
if let Some(s) = want_shard {
match session.set_shard_payload(s) {
Ok(()) => tracing::info!(shard_payload = s, "wire shard payload re-keyed"),
// Can't fire for a watcher-driven value (it validates the same bounds) —
// belt-and-suspenders for a future driver.
Err(e) => tracing::warn!(shard_payload = s, error = ?e,
"shard re-key refused by session validation"),
}
}
}
// Short timeout so we keep re-checking `stop` + probes when no frames are flowing.
match frame_rx.recv_timeout(std::time::Duration::from_millis(50)) {
Ok(send_msg) => {
@@ -1171,6 +1193,11 @@ pub(super) struct SessionContext {
/// Accepted mid-stream bitrate changes (adaptive bitrate, already clamped) — the encoder
/// alone is rebuilt in place at the new rate; capture + virtual output are untouched.
pub(super) bitrate_rx: std::sync::mpsc::Receiver<u32>,
/// Mid-session shard-payload changes from the wire-MTU watcher (already validated +
/// protocol-gated there; a grow arrives only after the client's ack). Applied between
/// AUs via [`Session::set_shard_payload`] — the packetizer re-keys, capture/encoder/
/// virtual output are untouched (design/shard-payload-reneg.md Phase 1).
pub(super) shard_rx: std::sync::mpsc::Receiver<usize>,
/// The resolved compositor backend (moot on Windows — `vdisplay::open` ignores it there).
pub(super) compositor: crate::vdisplay::Compositor,
/// This session's resolved gamescope sub-mode, or `None` for every other backend. Carried here
@@ -1385,6 +1412,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
keyframe,
rfi,
bitrate_rx,
shard_rx,
compositor,
gamescope_route,
mut bitrate_kbps,
@@ -1771,6 +1799,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
slice_wire,
burst_cap,
fec_target,
shard_rx,
send_stats,
timing_conn,
phase_send,
@@ -0,0 +1,308 @@
//! MTU resilience for the video data plane (the "connects fine, black screen forever" field
//! shape).
//!
//! Video datagrams are sealed at a per-session `shard_payload` sized for a clean 1500-byte MTU
//! (1472-byte UDP payloads). A host whose route to the client runs through a smaller-MTU hop —
//! a VPN/overlay adapter (Tailscale/WARP/ZeroTier default to 1280) claiming the LAN route, or a
//! lowered NIC MTU — delivers every SMALL flow (QUIC control, hole punch, input, audio) while
//! 100 % of video datagrams die by fragmentation or local `WSAEMSGSIZE`: the client sits on a
//! black screen reporting `loss_ppm=0` (it can't see gaps in packets it never saw any of) and
//! the host streams into the void with every gauge green. Neither side observes the failure
//! directly — but the control connection CAN: its MTU discovery probes up to exactly the sealed
//! video-datagram size ([`video_datagram_udp_ceiling`], set in `quic/endpoint.rs`), so its
//! settled MTU is a verdict on the path.
//!
//! Three legs, none of which changes a session on a healthy path:
//! - **`PUNKTFUNK_WIRE_MTU=<bytes>`** — operator override; the shard payload is derived from
//! the given on-wire IP MTU. Wire-compatible with every deployed client:
//! `Welcome::shard_payload` is already negotiated per session (the v4/v6 split ships two
//! values today) and clients follow the negotiated value.
//! - **Watch** — a per-session task samples the control connection's discovered MTU once the
//! search has had time to finish. A connection still alive that settled BELOW the ceiling is
//! proof the path can't carry full-size video: log an actionable WARN and record the measured
//! budget for the peer.
//! - **Heal** — the next handshake from that peer clamps `shard_payload` to the recorded
//! budget, so a reconnect fixes the stream. A later session that reaches the ceiling erases
//! the record (the learn/heal loop is self-correcting in both directions).
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::{Mutex, OnceLock};
use punktfunk_core::config::{
jumbo_shard_payload_for, jumbo_wire_mtu, mtu1500_shard_payload_for, sealed_datagram_bytes,
shard_payload_for_udp_budget, shard_payload_for_wire_mtu, video_datagram_udp_ceiling,
};
/// Everything the MID-SESSION renegotiation driver needs (design/shard-payload-reneg.md
/// Phase 2) — `None` at [`spawn_watch`] makes the watcher observe-and-learn only (leg-1
/// behavior). Constructed ONLY when the client's `Hello::max_shard_payload` advertised
/// per-frame geometry AND the session's wire is not chunk-aligned: a PyroWave client parses
/// chunk-aligned AUs in windows of the `Welcome` value pinned at session start (Apple
/// `Stage2Pipeline` / `pf-client-core` video.rs read it once over the C ABI), so re-keying
/// such a session mid-stream would corrupt its parse — those sessions keep the leg-1
/// next-session clamp instead.
pub(super) struct ShardReneg {
/// The client's advertised receive ceiling (bytes of shard; > 0 by construction).
pub client_ceiling: u16,
/// → control task (the control stream's sole writer): send `ShardPayloadChanged{n}`.
pub change_tx: tokio::sync::mpsc::UnboundedSender<u16>,
/// ← control task: the client's `ShardPayloadAck`s (the grow gate).
pub ack_rx: tokio::sync::mpsc::UnboundedReceiver<u16>,
/// → data plane: apply [`Session::set_shard_payload`] between AUs
/// (drained next to `bitrate_rx` in the encode loop).
pub apply_tx: std::sync::mpsc::Sender<usize>,
}
/// Measured UDP-payload budget per peer IP, learned from live control connections whose MTU
/// discovery settled below the video-datagram ceiling. In-memory only: a host restart
/// re-learns in one session, and entries self-correct (a later ceiling-hit erases, a lower
/// re-measure overwrites).
fn learned() -> &'static Mutex<HashMap<IpAddr, u16>> {
static LEARNED: OnceLock<Mutex<HashMap<IpAddr, u16>>> = OnceLock::new();
LEARNED.get_or_init(|| Mutex::new(HashMap::new()))
}
/// The shard payload for a new session to `peer`: `PUNKTFUNK_WIRE_MTU` override, else the
/// peer's learned path budget, else the family default (today's exact behavior). Logs whenever
/// the result differs from the default.
pub(super) fn negotiated_shard_payload(peer: IpAddr) -> usize {
let env = match std::env::var("PUNKTFUNK_WIRE_MTU") {
Ok(v) => match v.trim().parse::<usize>() {
Ok(mtu) => Some(mtu),
Err(_) => {
tracing::warn!(value = %v, "PUNKTFUNK_WIRE_MTU is not a number — ignoring it");
None
}
},
Err(_) => None,
};
let learned_budget = learned().lock().unwrap().get(&peer).copied();
resolve(env, learned_budget, peer)
}
/// Pure resolution (env override > learned budget > family default) — the tested core of
/// [`negotiated_shard_payload`].
fn resolve(env_wire_mtu: Option<usize>, learned_udp_budget: Option<u16>, peer: IpAddr) -> usize {
let default = mtu1500_shard_payload_for(peer);
if let Some(mtu) = env_wire_mtu {
let p = shard_payload_for_wire_mtu(mtu, peer);
if p != default {
tracing::info!(
wire_mtu = mtu,
shard_payload = p,
default,
"wire MTU: shard payload set from PUNKTFUNK_WIRE_MTU"
);
}
return p;
}
if let Some(budget) = learned_udp_budget {
let p = shard_payload_for_udp_budget(budget as usize, peer);
if p != default {
tracing::info!(
peer = %peer,
udp_budget = budget,
shard_payload = p,
default,
"wire MTU: shard payload clamped to this peer's measured path MTU (learned \
from a prior session's QUIC MTU discovery) video datagrams now fit the \
constrained hop"
);
return p;
}
}
default
}
/// Sample the control connection's discovered MTU after the search has settled and turn it
/// into a verdict — and, with a [`ShardReneg`] driver, act on it MID-SESSION
/// (design/shard-payload-reneg.md Phase 2): a below-ceiling verdict shrinks the live wire at
/// the ~310 s mark (session 1 heals instead of staying black), and a settled-at-jumbo
/// verdict grows it, ack-gated, when the operator opted in. Spawned once per negotiated
/// session; without a grow the task ends after the final sample (bounded ~10 s lifetime,
/// holding only a cheap `Connection` handle) — after a grow it stays as the revert guard
/// until the connection closes.
pub(super) fn spawn_watch(
conn: quinn::Connection,
session_shard_payload: usize,
reneg: Option<ShardReneg>,
) {
tokio::spawn(async move {
let peer = conn.remote_address().ip();
let ceiling = video_datagram_udp_ceiling() as u16;
// Discovery finishes in a handful of RTTs on a LAN (well under the first sample) but
// needs a loss timeout per failed probe on a constrained path — the second sample
// covers that with margin. Max, because discovery only ever raises `current_mtu`
// (the post-grow revert guard below re-reads it live, where blackhole detection CAN
// lower it again).
let mut settled = 0u16;
for wait_s in [3u64, 7] {
tokio::time::sleep(std::time::Duration::from_secs(wait_s)).await;
settled = settled.max(conn.stats().path.current_mtu);
if settled >= ceiling {
break;
}
}
// The wire this session is CURRENTLY sealed at — moves on a mid-session shrink/grow.
let mut current = session_shard_payload;
let mut reneg = reneg;
if settled >= ceiling {
// The path carries full-size video datagrams — erase any stale learned clamp so
// the next session returns to the default wire.
if learned().lock().unwrap().remove(&peer).is_some() {
tracing::info!(peer = %peer,
"wire MTU: path re-measured at full size — learned clamp cleared");
}
} else {
// A closed connection stops discovering, so a session that ended before the final
// sample proves nothing (a healthy high-RTT path could still be mid-search): learn
// only from a connection that stayed alive through the whole window.
if conn.close_reason().is_some() {
return;
}
learned().lock().unwrap().insert(peer, settled);
if sealed_datagram_bytes(current) <= settled as usize {
// This session was already clamped small enough — the path is still constrained
// (keep the record fresh) but video fits, so no alarm.
tracing::info!(peer = %peer, discovered_udp_mtu = settled,
"wire MTU: constrained path re-measured; this session's video is sized to fit");
} else {
tracing::warn!(
peer = %peer,
discovered_udp_mtu = settled,
needed_udp_mtu = ceiling,
"wire MTU: this path CANNOT carry full-size video datagrams — the control \
plane works but every video packet is oversized for a hop, which streams as \
an endless black screen with zero reported loss. Typical cause: a VPN/overlay \
adapter (Tailscale / Cloudflare WARP / ZeroTier) claiming the LAN route, or a \
lowered NIC MTU compare `ping <client> -f -l 1450` vs `-l 1200` and check \
`netsh interface ipv4 show subinterfaces` (Windows) / `ip link` (Linux). The \
measured budget is recorded: the NEXT session from this client sizes video to \
fit automatically. To pin it for all sessions set PUNKTFUNK_WIRE_MTU."
);
// Phase 2 down-leg: heal THIS session at the verdict mark. Shrink is sent
// and applied immediately — per-frame pinning on the client makes ordering
// irrelevant and smaller always fits; the ack is telemetry. The learned
// record above still makes session 2 START right.
if let Some(r) = reneg.as_ref() {
let target = shard_payload_for_udp_budget(settled as usize, peer);
if target < current
&& r.change_tx.send(target as u16).is_ok()
&& r.apply_tx.send(target).is_ok()
{
tracing::info!(
peer = %peer,
shard_payload = target,
was = current,
"wire MTU: video re-keyed mid-session to fit the constrained path \
the stream heals now instead of on the next connect"
);
current = target;
}
}
}
}
// Phase 2 up-leg: jumbo grow — operator opt-in (PUNKTFUNK_JUMBO / PUNKTFUNK_WIRE_MTU
// > 1500, which also raised the endpoint's probe ceiling so `settled` can even reach
// here), client-advertised headroom, and a settled-at-jumbo proof. The grow is
// ACK-GATED: not one sealed datagram above the old size leaves before the client's
// ack, even though its buffers are statically sized — the rule must not erode.
let (Some(mtu), Some(r)) = (jumbo_wire_mtu(), reneg.as_mut()) else {
return;
};
let target = jumbo_shard_payload_for(mtu, peer).min(r.client_ceiling as usize);
let target = target - target % 2;
if target <= current || (settled as usize) < sealed_datagram_bytes(target) {
return;
}
if r.change_tx.send(target as u16).is_err() {
return;
}
let acked = tokio::time::timeout(std::time::Duration::from_secs(5), async {
while let Some(v) = r.ack_rx.recv().await {
if v as usize == target {
return true;
}
}
false
})
.await
.unwrap_or(false);
if !acked {
tracing::warn!(peer = %peer, shard_payload = target,
"wire MTU: jumbo grow not acked — staying at the current wire");
return;
}
if r.apply_tx.send(target).is_err() {
return;
}
tracing::info!(
peer = %peer,
shard_payload = target,
was = current,
wire_mtu = mtu,
"wire MTU: jumbo grow acked and applied — packets-per-frame cut ~6×"
);
current = target;
// Revert guard: a mis-proven jumbo hop must self-correct instead of blackholing.
// quinn's PMTU blackhole detection lowers `current_mtu` when the big packets start
// vanishing; sample it and shrink back through the same path the down-leg uses.
loop {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
if conn.close_reason().is_some() {
return;
}
let mtu_now = conn.stats().path.current_mtu;
if (mtu_now as usize) < sealed_datagram_bytes(current) {
let back = shard_payload_for_udp_budget(mtu_now as usize, peer);
tracing::warn!(peer = %peer, discovered_udp_mtu = mtu_now,
shard_payload = back, was = current,
"wire MTU: jumbo path stopped fitting — reverting the wire to match");
if r.change_tx.send(back as u16).is_err() || r.apply_tx.send(back).is_err() {
return;
}
current = back;
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
const V4: IpAddr = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 2));
const V6: IpAddr = IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1));
#[test]
fn default_when_nothing_known() {
assert_eq!(resolve(None, None, V4), mtu1500_shard_payload_for(V4));
assert_eq!(resolve(None, None, V6), mtu1500_shard_payload_for(V6));
}
#[test]
fn env_override_beats_learned() {
// 1280 wire 28 IP/UDP 64 header/crypto = 1188.
assert_eq!(resolve(Some(1280), Some(1472), V4), 1188);
}
#[test]
fn learned_budget_clamps() {
// A WARP-shaped path: 1280-byte UDP budget → 1280 64 = 1216.
assert_eq!(resolve(None, Some(1280), V4), 1216);
}
#[test]
fn learned_at_or_above_ceiling_is_the_default_wire() {
assert_eq!(resolve(None, Some(1472), V4), mtu1500_shard_payload_for(V4));
assert_eq!(resolve(None, Some(2000), V4), mtu1500_shard_payload_for(V4));
}
#[test]
fn env_full_mtu_is_the_default_wire_both_families() {
assert_eq!(resolve(Some(1500), None, V4), mtu1500_shard_payload_for(V4));
assert_eq!(resolve(Some(1500), None, V6), mtu1500_shard_payload_for(V6));
}
}
+10 -8
View File
@@ -35,7 +35,7 @@ track per machine; switching is a one-line change.
| **Windows client** (MSIX) | `…/generic/punktfunk-client-windows/canary/punktfunk-client-windows_x64.msix` | `…/latest/…` + the release page |
| **Windows host** (installer) | `…/generic/punktfunk-host-windows/canary/punktfunk-host-setup.exe` | `…/latest/…` + the release page |
| **Windows host** (winget) | — *(stable only)* | `winget install unom.PunktfunkHost` / `winget upgrade unom.PunktfunkHost`, after `winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest` |
| **Android** | Play **Internal testing** + sideload `…/generic/punktfunk-android/canary/punktfunk-android.apk` | Play **closed (alpha)** track + the release page |
| **Android** | Play **Internal testing** (invite-only) + sideload `…/generic/punktfunk-android/canary/punktfunk-android.apk` | **[Google Play](https://play.google.com/store/apps/details?id=io.unom.punktfunk)** (production) + the release page |
| **Apple** (mac/iOS/tvOS) | **TestFlight** | TestFlight + a notarized `.dmg` on the release page |
The apt distribution and the rpm group are just path segments in the URL — switching tracks is a
@@ -120,14 +120,16 @@ major bump, or a patch), just tag it — the canary base re-derives from whateve
Pre-release tags work too: `v0.2.0-rc1` builds a real release (the `-rc1` suffix is dropped where a
strictly-numeric version is required — MSIX, the App Store marketing version).
### App-store promotion (manual, after the tag)
### App-store publication (after the tag)
CI uploads stable to **testing** tracks only — it never auto-publishes to the public stores:
- **Apple** — the build lands in **TestFlight**. Promote to the App Store from App Store Connect
(submit for review). The notarized `.dmg` on the release page is the direct-download path.
- **Android** — the build lands in Play's **closed (alpha)** track. Promote alpha → production in
the Play Console when ready.
- **Android** — a `vX.Y.Z` tag publishes straight to Google Play **production** at 100%, with no
further click. Canary `main` builds go to Play **Internal testing**. To ramp a release gradually
instead of shipping it to everyone at once — or to halt or roll one back — use the Play Console,
or `android-promote.yml`, which moves a versionCode already on Play between tracks without
rebuilding.
- **Apple** — still manual. The build lands in **TestFlight**; promote it to the App Store from App
Store Connect (submit for review). The notarized `.dmg` on the release page is the
direct-download path.
## Why two tracks (the version-shadow trap)
+45 -43
View File
@@ -15,13 +15,14 @@ The Linux, Windows, Mac, iPhone/iPad and Android apps group settings the same wa
**Display**, **Input**, **Audio**, **Controllers** — under *Preferences* on Linux and *Settings*
elsewhere. The Apple TV app shows one scrolling list instead, and so does any client's settings
screen reached with a controller. A controller-driven launch (Steam Deck Gaming Mode) opens the
client's **console home**, whose settings screen is one steppable list; the Decky plugin's Settings
tab covers the same store in the same groups and the same order, as a left rail of categories the
way SteamOS's own Settings looks. The console home is part of the
client — it is not the host's
[web console](/docs/web-console).
client's **console home**, whose settings screen is one steppable list of sections — **Stream**,
**Video**, **Presentation**, **Audio**, **Controller**, **Touchscreen**, **Interface**,
**Profiles**. On a Steam Deck that list *is* the settings surface: the
[Decky plugin](/docs/steam-deck) is a launcher and keeps no settings of its own, and its **Open
Punktfunk** button puts the console home one tap from the Quick Access Menu. The console home is
part of the client — it is not the host's [web console](/docs/web-console).
Linux stores them in `~/.config/punktfunk/client-gtk-settings.json`, the same file the Decky plugin
Linux stores them in `~/.config/punktfunk/client-gtk-settings.json`, the same file the console home
writes, so a change in either shows up in the other. Windows uses
`%APPDATA%\punktfunk\client-windows-settings.json`; the Apple and Android apps use their own stores.
@@ -45,9 +46,9 @@ and your client scales what it gets — see
**Match window** — *default: off.* The stream mode follows your window instead, and each resize
renegotiates the host's display and encoder, so a windowed session stays pixel-exact. Fullscreen
degenerates to the display's native mode. Offered by the Linux, Windows, Mac, iPhone/iPad, console
home and Decky screens (on Decky it sits in the Resolution picker, and Gaming-Mode streams are
always fullscreen, so it lands on native); not by Android.
degenerates to the display's native mode. Offered by the Linux, Windows, Mac, iPhone/iPad and
console-home screens (in the console home it is an option inside the Resolution picker, and a
Gaming-Mode stream is always fullscreen, so there it lands on native); not by Android.
**Refresh rate** — *default: Native*, the refresh of the display your window is on. The Apple app
stores an explicit rate (60 Hz by default): iPhone and iPad offer the rates the device can display,
@@ -68,11 +69,11 @@ capacity probe stay off for the whole session.
multiplied by this, and your device resamples the result to its window. Above 1× supersamples for
sharpness, at more bandwidth *and* more decode work; below 1× is lighter on both the host and the
link. The stops run 0.5× to 4×. The result is floored to an even size and capped per axis at
4096 px for H.264, 8192 px otherwise. Offered everywhere except the console home's list.
4096 px for H.264, 8192 px otherwise. Offered everywhere.
**Video codec** — *default: Automatic.* A soft preference: the host emits your choice when it can
also produce it, otherwise the best codec you both speak, in the order HEVC → AV1 → H.264.
**PyroWave** is never auto-picked — pick it explicitly on Linux, Windows, the console home, Decky, or
**PyroWave** is never auto-picked — pick it explicitly on Linux, Windows, the console home, or
an Apple device whose decode probe passes; anywhere else it isn't offered, and asking for it lands on
that same order. See [PyroWave](/docs/pyrowave). The Android and Apple apps hide AV1 unless the
device has a hardware AV1 decoder; Android never offers PyroWave.
@@ -86,13 +87,13 @@ Full detail: [HDR](/docs/hdr).
needs HEVC or PyroWave, the host's own 4:4:4 policy left on, a capture path that delivers full
chroma, and a GPU that can encode it; if any gate fails the host says 4:2:0 before your decoder is
built. The Apple, Linux and Windows apps all advertise it (Apple additionally requires its hardware
decode probe to pass). The console home and Decky offer the toggle; Android doesn't.
decode probe to pass). The console home offers the toggle; Android doesn't.
**Prioritize** — *default: Lowest latency.* What the client optimizes for when a decoded frame is
ready. **Lowest latency** shows every frame the moment the display can take it, so a network hiccup
becomes an occasional repeated or skipped frame. **Smoothness** holds a small buffer that evens
those hiccups out, at that buffer's worth of added delay. Linux and Windows apps, the console home
and Decky; the Apple and Android apps have carried the same setting for a while, and it is stored
those hiccups out, at that buffer's worth of added delay. Linux and Windows apps and the console
home; the Apple and Android apps have carried the same setting for a while, and it is stored
under the same name, so a [profile](/docs/profiles-and-links) means the same thing on every device.
**Smoothness buffer** — *default: Automatic (two frames).* Only shown under **Smoothness**. How
@@ -106,7 +107,7 @@ the instant it's ready instead of waiting for the screen's next refresh: the low
can give you, at the cost of visible tearing on fast motion. It is **best-effort** — not every
driver or compositor offers a tearing mode, and where none is available the stream stays tear-free.
The Detailed [stats overlay](/docs/stats) names the mode actually in use, so you can tell "off"
from "off but unavailable". Linux and Windows apps, the console home and Decky.
from "off but unavailable". Linux and Windows apps and the console home.
**Follow variable refresh rate** — *default: on.* On a VRR / FreeSync / G-Sync screen, let the panel
refresh in step with the stream rather than on a fixed cadence — which removes the wait between a
@@ -115,8 +116,8 @@ windowed one is at the compositor's mercy) and is harmless on a fixed-refresh sc
graphics driver that offers the modern queue-free display mode; on an older driver it does nothing
unless you also set `PUNKTFUNK_VRR_FIFO=1` (see [configuration](/docs/configuration)), because the
older way of following a panel costs noticeable latency on a fixed-refresh screen. The stats overlay
reports `vrr yes` once it has *measured* that the panel really is following. Linux and Windows apps,
the console home and Decky.
reports `vrr yes` once it has *measured* that the panel really is following. Linux and Windows apps
and the console home.
**Host compositor** — *default: Automatic.* Which backend a **Linux** host uses to drive the virtual
output. Advisory: a host without that backend quietly auto-detects instead.
@@ -130,7 +131,7 @@ claims a sink advertising exactly that many channels, so applications produce re
**Windows** host loopback-captures your current output endpoint and lets Windows convert it — so 5.1
from a stereo endpoint is an upmix, not new channels. Offered everywhere.
**Microphone** — *default: off on Linux, Windows, Android, the console home and Decky; on in the
**Microphone** — *default: off on Linux, Windows, Android and the console home; on in the
Apple app.* Sends this device's microphone to the host's virtual mic. On Linux and Windows the
row is spelled *Stream microphone*, and **Ctrl+Alt+Shift+V** mutes it mid-stream without ending
anything — see [Muting your microphone](/docs/input#muting-your-microphone).
@@ -142,18 +143,18 @@ from an echo-cancelled PipeWire source when your desktop provides one, on **Wind
for the Communications stream category so the endpoint's processing engages, and on **Apple** and
**Android** the platform's voice-processing mode. Turn it off if your microphone already runs its
own processing, or if the canceller makes your voice sound thin. The row sits under the microphone
toggle and greys out while the microphone is off. Offered by the Linux, Windows, Apple, Android,
console-home and Decky clients. What it can and can't fix is in
toggle and greys out while the microphone is off. Offered by the Linux, Windows, Apple, Android and
console-home clients. What it can and can't fix is in
[Why do I hear myself](/docs/echo).
**Speaker** and **Microphone** device pickers — *default: System default.* Which endpoint stream
audio plays out of, and which input feeds the uplink. Only the Linux app (PipeWire nodes), the
**Mac** app (which also has a microphone *channel* picker) and **Decky** have these — iPhone, iPad,
Apple TV, Android and the console home have none, and the Windows app has none and ignores a stored
speaker choice. On Linux, a device that has since disappeared keeps a "(not detected)" entry rather
than silently snapping back to the default; the Mac shows it as "Unavailable device" and Decky as
"(not connected)". Decky reads the endpoint list from the client's session binary, so a client
older than the two-binary split leaves these pickers on Automatic.
audio plays out of, and which input feeds the uplink. Only the Linux app (PipeWire nodes) and the
**Mac** app (which also has a microphone *channel* picker) have these — iPhone, iPad, Apple TV,
Android and the console home have none, and the Windows app has none and ignores a stored speaker
choice. On Linux, a device that has since disappeared keeps a "(not detected)" entry rather than
silently snapping back to the default; the Mac shows it as "Unavailable device". A Steam Deck in
Gaming Mode therefore has no endpoint picker at all: the session uses whatever the Desktop-Mode app
last stored, and the system default otherwise.
## Input
@@ -182,7 +183,7 @@ client greys them out to say so.
**Gamepad type** (*Controller type* on Apple, Android and the console home) — *default: Automatic*,
which matches each physical controller. The pickers offer Xbox 360, Xbox One, DualSense and
DualShock 4 everywhere, plus Steam Deck on Linux, Android, the console home and Decky. Your client
DualShock 4 everywhere, plus Steam Deck on Linux, Android and the console home. Your client
declares a type per pad as it connects — Automatic declares what that controller really is, an
explicit choice declares your choice — and the host builds each virtual pad from that. A type the
host has no backend for degrades to an Xbox 360 pad rather than failing: Xbox One on a Windows host,
@@ -193,8 +194,8 @@ which forwards *every* connected controller, each as its own player, on Linux, W
console home. Pinning one restricts the session to that controller alone — single-player. The Android
app has no such picker.
**Capture system shortcuts** — *default: on.* Offered by the Linux and Windows apps, the console home
and Decky; Windows spells the row out as *Capture system shortcuts (Alt+Tab, Win, …)*. On a Deck it
**Capture system shortcuts** — *default: on.* Offered by the Linux and Windows apps and the console
home; Windows spells the row out as *Capture system shortcuts (Alt+Tab, Win, …)*. On a Deck it
matters only for a keyboard you attached yourself, for the reason the paragraph below gives: Gaming
Mode is gamescope, which has nothing to hold back. On, Alt+Tab and the Windows key
(Super on Linux) reach the host while the stream has input captured. Off, they act on this machine
@@ -215,21 +216,22 @@ the wlroots compositors all do, and X11 sessions grab the keyboard directly. Und
Wake-on-LAN and waits for it to boot — only for a host whose MAC address this client has already
learned. Turn it off for hosts you reach over a VPN, where "offline" usually means "not reachable by
broadcast" and the wake only adds a delay. The Linux, Windows, Apple and Android apps have this
toggle, as do the console home and Decky — and note that the Decky plugin sends a wake of its own
before a stream starts whatever this setting says, so on a Deck it governs the client's connect
rather than the launch. The console home also offers wake as an explicit action on an offline host.
See
toggle, as does the console home — and on a Steam Deck it governs the
[Decky plugin's](/docs/steam-deck) launches too, because the plugin starts every stream through the
client, which reads this setting like any other connect. The console home also offers wake as an
explicit action on an offline host, whatever the toggle says. See
[Wake-on-LAN](/docs/wake-on-lan).
**Show game library** — *default: off on Linux and Windows; on in the Apple and Android apps.* Browse
a paired host's games and launch one directly; the Windows app still labels it experimental. The
console home and Decky have the toggle too — on Decky it governs the *client's* screens, since the
plugin's own library browser works either way. See [Game library](/docs/game-library).
console home has the toggle too, and it governs the desktop clients that share the store — the
console's own **Library** button is offered on any paired host either way. See
[Game library](/docs/game-library).
**Start streams in fullscreen** — *default: on.* On Linux and Windows, F11 or Alt+Enter leaves
fullscreen live. On a Mac the setting is **Fullscreen while streaming**, and the window comes back
when you return to the host list. The console home and Decky carry the row for the desktop client
that shares the store — a Gaming-Mode launch is fullscreen whatever it says. iPhone, iPad, Apple TV
when you return to the host list. The console home carries the row for the desktop client that
shares the store — a Gaming-Mode launch is fullscreen whatever it says. iPhone, iPad, Apple TV
and Android have no equivalent.
## Overlay
@@ -237,9 +239,9 @@ and Android have no equivalent.
**Statistics overlay** — *default: Normal.* Four tiers — Off, Compact, Normal, Detailed — each a
superset of the one before. This setting only picks the tier a session *starts* at — you can cycle
them live in-stream, with a shortcut that differs by platform. The Apple app additionally lets you
choose which corner the overlay sits in (Top Left, Top Right, Bottom Left, Bottom Right). The Decky
plugin has the tier picker too, in its Settings section. The shortcuts, and every number in the
overlay, are in
choose which corner the overlay sits in (Top Left, Top Right, Bottom Left, Bottom Right). The
console home has the tier picker too, as **Statistics overlay** under **Interface**. The shortcuts,
and every number in the overlay, are in
[Understanding the stats overlay](/docs/stats).
## Settings that are facts about your device
@@ -251,8 +253,8 @@ stay global and **cannot be put in a settings profile**:
vendor-ordered and falls back on its own; change it only when debugging, and note that
`PUNKTFUNK_DECODER` overrides it
([Configuration](/docs/configuration#client-side-native-clients)). The decoder picker is on Linux,
Windows, in the console home and in Decky; the GPU picker on Windows, and on Linux and Decky only
when the machine has more than one adapter — which a Deck doesn't, so the row isn't there. The
Windows and in the console home; the GPU picker on Windows, and on Linux only when the machine has
more than one adapter — the console home has none, and a Deck has a single adapter anyway. The
Apple and Android apps have neither.
- **Speaker** and **Microphone** device pickers — this device's audio endpoints.
- **Forwarded controller** — which physical pad is in your hands. The *type* the host creates is a
+4 -5
View File
@@ -100,11 +100,10 @@ capture state, and the switch that turns this off is *DualSense / DualShock pass
Settings. Over **Bluetooth** the pad still works as an ordinary gamepad, but adaptive triggers and
the lightbar need the USB connection.
The app is on Google Play as a **test track** (closed testing for stable, internal testing for
canary) — request a tester invite on our [**Discord**](https://discord.gg/kaPNvzMuGU) and we'll add
you, or sideload the public APK instead (see
[Install a Client](/docs/install-client#android)). Then open the app, pick your host,
[pair](/docs/pairing) once, and stream.
The app is on **[Google Play](https://play.google.com/store/apps/details?id=io.unom.punktfunk)** as a
public listing — no invite — or you can sideload the public APK instead (see
[Install a Client](/docs/install-client#android)); canary builds ride a separate, invite-only Play
Internal testing track. Then open the app, pick your host, [pair](/docs/pairing) once, and stream.
## Windows desktop client
+5 -3
View File
@@ -77,7 +77,8 @@ The setting is read when a session starts, so if you change it while streaming,
macOS can also flip it mid-session: **Stream ▸ Share Clipboard** (⌃⌥⇧C), which becomes **Stop
Sharing Clipboard** once the host has acknowledged it.
iOS, iPadOS, tvOS and the Steam Deck Decky plugin have no clipboard switch — see
iOS, iPadOS, tvOS and a Steam Deck in Gaming Mode have no clipboard switch — neither the Decky
panel nor the client's console home has a host edit sheet — see
[what each client does](#which-hosts-and-clients-support-it) below.
## Nothing crosses until something pastes
@@ -134,8 +135,9 @@ when a host application pastes.
The **Linux client has the switch but no working clipboard bridge**: it enables the plane and then
has no code to read or write the desktop's own clipboard, so nothing is announced and nothing is
pasted. Turning it on there is harmless but has no effect today. The Decky plugin on the Steam Deck
has no switch at all.
pasted. Turning it on there is harmless but has no effect today. On a Steam Deck in Gaming Mode
there is no switch at all — the Decky panel doesn't edit hosts — and since a Deck streams with that
same Linux client, a switch there would have nothing to move anyway.
When you copy **on the Windows client**, images cross only if the copying application publishes the
registered `PNG` clipboard format. Many Windows apps publish only a bitmap, and those copies aren't
+4 -5
View File
@@ -132,11 +132,10 @@ and runs what it already knows about the title, so a client can never hand the h
- **Android** — the library lives only in the controller-optimized home, which a TV always uses and a
phone or tablet switches to when a controller is connected. Press **Y** on a saved host, or open its
options and choose **Library**.
- **Steam Deck (Decky)** — the plugin's per-host **Games** picker lists the library and lets you
**Pin** titles; a pinned game becomes a one-tap row under **Pinned Games** in the Quick Access Menu.
The picker itself doesn't launch anything — either tap a pinned row, or use **Open library on
screen** to browse the host's games full-screen on the Deck and launch from there. See
[Steam Deck](/docs/steam-deck).
- **Steam Deck (Decky)** — the panel is a launcher and browses nothing itself: tap **Open
Punktfunk**, which opens the client's console home, and a paired host's **Library** button is
right there — full-screen covers, gamepad-navigable, and a press starts the stream with the title
launching. See [Steam Deck](/docs/steam-deck).
- **Moonlight** — when the host runs with `--gamestream`, your library appears in Moonlight's app
list beside `Desktop`, with covers served by the host. A title keeps the same app id across host
restarts, so Moonlight's cached tiles stay correct. Titles with no launch recipe are left out.
+3 -2
View File
@@ -49,8 +49,9 @@ your settings. If the stream isn't sending a microphone at all (**Stream microph
[client settings](/docs/client-settings#audio)) the shortcut does nothing and no badge appears,
rather than pretending to mute something.
This is on the **Linux and Windows** clients. The Apple, Android and Decky clients have no mute
shortcut yet; turn **Stream microphone** off in their settings instead.
This is on the **Linux and Windows** clients — including a Steam Deck stream, which is the Linux
client, so an attached keyboard gets the chord. The Apple and Android clients have no mute shortcut
yet; turn **Stream microphone** off in their settings instead.
Alt-Tabbing away releases input on its own and takes it back when you return. A release you asked
for with the chord stays released until you opt back in. Either way, keys and buttons you were
+16 -14
View File
@@ -25,7 +25,7 @@ Already installed? Skip to [Keeping a client up to date](#keeping-a-client-up-to
| **Windows** | [Signed MSIX](#windows) from the package registry |
| **macOS** | [Notarized `.dmg`](#macos) from the releases page |
| **iPhone / iPad / Apple TV** | [TestFlight beta](#ios-ipados-apple-tv) |
| **Android / Android TV** | [Beta — a Play test track, or sideload the APK](#android) |
| **Android / Android TV** | [Google Play](#android), or sideload the APK |
| **LG webOS TV** | [Community client](#lg-webos-tv-community) (sideloaded `.ipk`) |
| Anything else (browser, old phone, TV) | [Moonlight](/docs/moonlight) |
@@ -79,9 +79,11 @@ list: [Clients → the `punktfunk` CLI](/docs/clients#scripting-the-punktfunk-cl
## Steam Deck
Most Deck users want **Gaming Mode**: install the **[Decky plugin](/docs/steam-deck)** and a
**Punktfunk** panel lands in the Quick Access Menu, so you can discover hosts, pair with a PIN, and
stream **without dropping to the desktop**. Follow the **[Steam Deck (Decky) guide](/docs/steam-deck)**
— it walks through Decky Loader, the plugin, and the one-time client install.
**Punktfunk** panel lands in the Quick Access Menu, so you can find a host, get let in (a PIN, or a
request the host's operator approves), and stream **without dropping to the desktop**. Everything
else — settings, the game library, adding a host by address — is one tap away in the client's own
gamepad UI. Follow the **[Steam Deck (Decky) guide](/docs/steam-deck)** — it walks through Decky
Loader, the plugin, and the one-time client install.
> The plugin doesn't decode video itself — it drives whichever `punktfunk-client` is installed on
> the Deck. The Flatpak below is the tested default; a native package or a sysext works too. If your
@@ -162,19 +164,15 @@ Open the app, and your hosts appear automatically under *On this network*.
## Android
The Android client (phone + Android TV) is on Google Play as a **test track** — **closed testing**
for stable releases, **internal testing** for canary builds. To join, request a tester invite on our
[**Discord**](https://discord.gg/kaPNvzMuGU) and we'll add your Google account:
**[Request access on Discord →](https://discord.gg/kaPNvzMuGU)**
Once you're added, install it from Google Play, then open the app and pick your host:
The Android client (phone + Android TV — one package, the TV layout is the same app in leanback
mode) is published on **Google Play**. It's a public listing: no invite, no tester list.
**[Get Punktfunk on Google Play →](https://play.google.com/store/apps/details?id=io.unom.punktfunk)**
_(only resolves once your account is on the tester list)_
**Prefer not to wait for an invite?** The signed APK is published publicly on every build, so you can
sideload it instead — no account, no invite:
Install it, open the app, and pick your host.
**Prefer not to go through Play?** The signed APK is published publicly on every build, so you can
sideload it instead — no Play account needed:
```text
https://git.unom.io/api/packages/unom/generic/punktfunk-android/latest/punktfunk-android.apk
@@ -184,6 +182,10 @@ Swap `latest` for `canary` to track `main`. Release APKs are also attached to ea
[release](https://git.unom.io/unom/punktfunk/releases). Android asks you to allow installs from your
browser or file manager the first time.
**Canary on Play** is a separate **Internal testing** track, and that one *is* invite-only — ask on
[Discord](https://discord.gg/kaPNvzMuGU) and we'll add your Google account. The `canary` APK above
needs no invite.
## LG webOS TV (community)
> **Community project.** [`pf-webos`](https://github.com/dyptan-io/pf-webos) is built and maintained
+2 -2
View File
@@ -61,8 +61,8 @@ Then, on the client:
- **[Native clients](/docs/clients) (Apple, Linux, Windows, Android):** select the host (or use
*Pair with PIN…* from its menu) and enter the PIN the host displays.
- **[Steam Deck](/docs/steam-deck) (the Decky plugin):** open Punktfunk from the Quick Access menu
and pick the host — an unpaired one's button reads **Pair & Stream**. Enter the PIN on the
4-digit pad it opens.
and pick the host — an unpaired one opens a sheet offering **Request access** (no PIN: somebody
approves the Deck at the host) or **Use a PIN instead**, which opens the 4-digit pad.
- **[Moonlight](/docs/moonlight):** choose **Pair**; Moonlight shows a 4-digit PIN, and you type
that PIN into the console's **Moonlight (GameStream) pairing** card and press **Submit PIN**.
(This direction is the reverse of the native flow, and arming doesn't apply to it.)
+3 -2
View File
@@ -11,8 +11,9 @@ Both live in the client apps — the Apple app, the Linux GTK client, the Window
Android app. Neither exists in the host's [web console](/docs/web-console).
The controller-driven surfaces are a half-exception: Apple TV, the Android app's console mode and
the Steam Deck console the Decky plugin launches all *use* the profile a host is bound to, but none
of them can create or edit one. Do that on a desktop or a phone first.
the Steam Deck console the Decky plugin launches all *use* the profile a host is bound to and can
pin one as its own card, but none of them can create or edit one. Do that on a desktop or a phone
first. The Decky panel itself only *shows* those pins, nested under their host as one-tap cards.
## What a profile is
+86 -51
View File
@@ -7,10 +7,12 @@ The **Decky plugin** adds a **Punktfunk** panel to the Steam Deck's Quick Access
button), so you can find a host, pair, and start streaming **without leaving Gaming Mode**. It's the
couch-friendly front end for the Steam Deck — built from real Steam UI, gamepad-navigable end to end.
Under the hood the plugin doesn't decode video itself: it discovers hosts, runs the PIN pairing, and
**launches the regular [Linux client](/docs/clients#linux-desktop-client-gtk4)** (usually the
`io.unom.Punktfunk` Flatpak) the way gamescope needs so it fullscreens correctly. So the Deck has two
ways to stream, and they share one client + one paired identity:
The plugin is a **launcher**, not a second client. It doesn't decode video, browse your library or
hold settings of its own — it starts the regular
[Linux client](/docs/clients#linux-desktop-client-gtk4) (usually the `io.unom.Punktfunk` Flatpak)
the way gamescope needs so it fullscreens correctly. Everything the panel doesn't do is one tap
away in that client's own gamepad UI. So the Deck has two ways to stream, and they share one
client + one paired identity:
- **Gaming Mode** → the **Decky plugin** (this page).
- **Desktop Mode** → run the [Flatpak](/docs/install-client#steam-deck) directly, like any Linux app.
@@ -30,11 +32,13 @@ You need three things on the Deck:
(Full options: [Install a Client → Steam Deck](/docs/install-client#steam-deck).) If you have
no Flatpak but a native `punktfunk-client` — a sysext, a distro package, a nix profile, your own
build — the plugin launches that instead; with both installed the Flatpak wins, unless
`PF_DECKY_CLIENT=native` (or `flatpak`) is set in the plugin backend's environment. But
**pairing, Wake-on-LAN and the host game library still go through the Flatpak**, so install it
on the Deck even then. Both kinds share `~/.config/punktfunk`, so your identity, known hosts
and settings are the same either way.
build — the plugin uses that instead; with both installed the Flatpak wins, unless
`PF_DECKY_CLIENT=native` (or `flatpak`) is set in the plugin backend's environment. Both kinds
share `~/.config/punktfunk`, so your identity, known hosts and settings are the same either way.
**The client must be v0.22.0 or newer.** The panel drives everything through the client's
headless `punktfunk` command, which shipped in that release. An older client says so in the
panel, with the update button that fixes it right there.
3. **A Punktfunk host** running on your LAN — see [Install the Host](/docs/install). The Deck finds
it automatically over mDNS, so nothing to configure here.
@@ -64,40 +68,68 @@ The **Punktfunk** panel appears in the Quick Access Menu right away — no Deck
## Use it
Open the **Punktfunk** panel from the Quick Access Menu, or **Open Punktfunk** for the full-screen
page (host list + stream settings).
Open the **Punktfunk** panel from the Quick Access Menu. It has one list — the hosts you can
stream — plus a door into the client's own gamepad UI for everything else.
- **Discover** — hosts on your network appear automatically (mDNS). Tap **Refresh** to rescan. A
lock icon means the host requires [pairing](/docs/pairing).
- **Add a host by hand** — if mDNS can't reach it (another subnet, a VPN), tap **+** on the Hosts
tab and enter its address; the port defaults to **9777**. Saved hosts can be renamed, re-pointed
at a new address, or forgotten from the same row.
- **Sleeping host?** Streaming sends a [Wake-on-LAN](/docs/wake-on-lan) packet first, and when one
actually went out the Deck waits far longer than usual for the host to answer, so a stream
survives a resume from sleep. Nothing to enable — it's a no-op until the plugin has learned that
host's MAC address, and the packet only lands if the host machine is armed to wake in its
BIOS and its network card.
- **Pair** — for a locked host, [arm pairing on the host](/docs/pairing) (its console or web
console shows a 4-digit PIN), then enter that PIN on the Deck's keypad. Pairing persists, so the
next connection is silent.
- **Stream** — pick a host and the stream launches fullscreen in Gaming Mode. The plugin drives a
- **Hosts** — hosts on your network appear automatically (mDNS), alongside the ones you've already
saved. A saved host is also probed directly, so a box reached over a VPN or Tailscale shows as
online even though it never advertises. Tap **Refresh** to rescan. The list sorts online hosts
first, then whichever you streamed most recently. A lock icon means the host still has to let
this Deck in.
- **Let a host in** — tapping a locked host opens a small sheet with two ways through:
- **Request access** — no PIN at all. See [Request access](#request-access) below.
- **Use a PIN instead** — [arm pairing on the host](/docs/pairing) (its console or web console
shows a 4-digit PIN), then enter it on the Deck's keypad.
Either way the host is remembered, so the next connection is silent.
- **Stream** — tap a host and the stream launches fullscreen in Gaming Mode. The plugin drives a
hidden Steam shortcut behind the scenes so gamescope focuses and fullscreens it.
- **Library entry** — a visible, branded **Punktfunk** app also appears in your Steam library.
Launching it opens the client's console home (host picker, pairing, settings), gamepad-navigable
— it does not resume a stream. If it ever disappears, the Quick Access Menu panel has a button to
put it back.
- **Games** — tap **Games** on a host row to browse that host's [library](/docs/game-library), and
**Pin** the ones you play. Pinned games show up on the full page *and* in the Quick Access Menu
as one-tap streams that launch straight into the game.
- **Settings** — resolution, refresh rate, **render scale**, bitrate, **video codec**, gamepad type,
**host compositor**, and mic, written to the client the plugin launches. Leave **Resolution** /
**Refresh** on *Native* to get the Deck's own mode, **Render scale** at 1× unless you want to
trade bandwidth for sharpness (>1×) or sharpness for bandwidth (<1×), and **Video codec** /
**Host compositor** on *Automatic* — that suits almost every host, so change them only when
you're troubleshooting. With **Gamepad type** on *Automatic* the Deck's built-in controller is
forwarded as a **Steam Deck** pad (paddles, both trackpads, gyro) — that needs Steam Input set to
**Off** for Punktfunk (game page → ⚙ → Controller Settings), else Steam keeps those controls and
only sticks + buttons reach the host.
- **Sleeping host?** Streaming sends a [Wake-on-LAN](/docs/wake-on-lan) packet and waits for the
host to actually come back before dialling, so a stream survives a resume from sleep. Nothing to
enable — it's a no-op until the client has learned that host's MAC address, and the packet only
lands if the host machine is armed to wake in its BIOS and its network card.
- **Pinned cards** a host with pinned [settings profiles](/docs/client-settings) shows them
nested underneath it as `▸ <Profile name>`. Tapping one streams that host with that profile
applied — your "4K on the TV" and "battery saver" presets, one tap each. Pins are made in the
Punktfunk app (or any other client) and shared across all of them; the panel shows them, it
doesn't create them.
- **Open Punktfunk** — opens the client's console home: the host picker, adding a host by address,
pairing, browsing a host's [game library](/docs/game-library), and the **full settings screen**.
This is where resolution, bitrate, codec, audio, controllers and the stats overlay live.
- **Library entry** — a visible, branded **Punktfunk** app also appears in your Steam library, and
launching it opens that same console home — it does not resume a stream. If it ever disappears,
the Quick Access Menu panel has a button to put it back.
> **Where did the plugin's settings tab go?** Into the app, at **Open Punktfunk → Settings** — the
> same rows over the same settings, gamepad-navigable, and one tap from the same panel. The plugin
> used to carry its own copy of that screen, which meant two places to change one setting and a
> copy that fell behind. There is now one.
With **Controller type** on *Automatic* the Deck's built-in controller is forwarded as a **Steam
Deck** pad (paddles, both trackpads, gyro) — that needs Steam Input set to **Off** for Punktfunk
(game page → ⚙ → Controller Settings), else Steam keeps those controls and only sticks + buttons
reach the host.
### Request access
**Request access lets you in without typing a PIN**: instead of the host showing you a code, you
ask, and whoever is at the host approves the Deck in its [web console](/docs/web-console) or on
screen.
Tap the host → **Request access**. The Deck says *"Approve this Deck in <host>'s console — the
stream starts by itself"*, and the stream opens and waits. The moment somebody approves it, the
picture comes up — no going back to the panel, nothing else to tap. If nobody approves within
about three minutes, it gives up like any failed connection and you can try again or use a PIN.
It's the better option when you're not the person sitting at the host, or when reading a PIN off
another screen is awkward. Two things to know:
- The host must be **advertising on your network** for this to be offered. A host you added by
address (a VPN box, another subnet) has no advertised identity for the Deck to pin, so the sheet
offers the PIN path only and says so. That's a safety rule, not a limitation to work around:
pinning the advertised identity is what stops something else answering in the host's place while
the Deck waits.
- Once approved, the host shows as **paired** and every later stream connects silently.
> **Steam Input off is a trade-off, not a free win.** The plugin installs a Steam Input layout
> called **Punktfunk** and points its shortcuts at it, and that layout's whole job is making the
@@ -115,9 +147,9 @@ input, so it is safe to hit by accident.
The plugin **checks for updates itself** — no Decky store needed. It covers **both** the plugin *and*
the streaming client (they version independently), so when either has a newer build the panel shows an
**Update** button (in the Quick Access Menu and on the full page). Tap it: the client updates in
place, and if the plugin itself changed it downloads, verifies, replaces itself, and reloads — all
without leaving Gaming Mode.
**Update** button at the top of the panel. Tap it: the client updates in place, and if the plugin
itself changed it downloads, verifies, replaces itself, and reloads — all without leaving Gaming
Mode.
One exception: if your client isn't one the plugin can install for you (a sysext, a nix profile, a
source build), the panel shows you the update **command** instead of a button — tap-to-install would
@@ -139,13 +171,16 @@ The plugin check follows the [channel](/docs/channels) you installed from: a plu
| Symptom | Fix |
|---|---|
| The stream never starts, **Pair** reports `flatpak-not-found`, or **Games** says the client isn't installed | Install the client Flatpak in Desktop Mode (see [Before you start](#before-you-start)). |
| No hosts listed | Make sure the host is running and on the **same LAN**; the Deck needs `avahi` (shipped on SteamOS). Tap **Refresh**. |
| Pairing fails / "not armed" | The PIN is shown only after you **arm pairing on the host**. Arm it, then enter the PIN within the window. |
| Stream launches but doesn't focus | Start it from the panel (not by launching the Flatpak by hand) so Steam/gamescope focuses it. |
| The stream wedges — black, or won't close | Open the full page → **About** tab → **Force-stop**, then start it again. |
| The **Punktfunk** library entry disappeared | Quick Access Menu → **Recreate library shortcut**; it puts the entry back in place. |
| You want a clean slate | **About** tab → **Reset Punktfunk** — clears saved hosts, stream settings and pinned games on this Deck, and keeps your paired identity. |
| The panel says **"Update the Punktfunk client"** | The installed client predates v0.22.0 and has no `punktfunk` command to drive. Tap the update button in the same panel, or update it in Desktop Mode. |
| The stream never starts, or the panel can't reach the client | Install the client Flatpak in Desktop Mode (see [Before you start](#before-you-start)). |
| No hosts listed | Make sure the host is running and on the **same LAN**. Tap **Refresh**. For a host mDNS can't reach, add it by address in **Open Punktfunk → Add host**. |
| Pairing fails / "not armed" | The PIN is shown only after you **arm pairing on the host**. Arm it, then enter the PIN within the window — or use **Request access** instead, which needs no PIN. |
| **Request access** isn't offered | The host isn't advertising on this network, so there's no identity to pin. Use the PIN path. |
| A request-access stream sits there | That's it waiting — somebody has to approve the Deck on the host. It gives up after about three minutes. |
| Stream launches but doesn't focus | Start it from the panel (not by launching the client by hand) so Steam/gamescope focuses it. |
| The stream wedges — black, or won't close | Panel → **About****Force-stop**, then start it again. |
| The **Punktfunk** library entry disappeared | Panel → **Recreate library shortcut**; it puts the entry back in place. |
| You want a clean slate | **Open Punktfunk → Settings** for stream settings, or `punktfunk reset` in Desktop Mode to forget every saved host. Your paired identity is kept either way. |
Nothing here matching? The problem is probably on the host side — start at
[Troubleshooting](/docs/troubleshooting), which is organised by symptom (host not found, pairing
+11 -8
View File
@@ -354,7 +354,7 @@ macOS, iOS/iPadOS and tvOS. Android is one app, with Android TV being the same a
| iPhone · iPad | ✅ | ✅ | ✅ ⁴ | ✅ | ✅ | ❌ ³ |
| Apple TV | ⚠️ ⁵ | ✅ | ✅ ⁴ | ✅ | ✅ | ❌ ³ |
| Android · Android TV | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ ³ |
| Decky (Steam Deck) | ⁶ | ❌ | ⁷ | ❌ | ✅ | ✅ ⁸ |
| Decky (Steam Deck) | ⚠️ ⁶ | ❌ | ⚠️ ⁷ | ❌ | ✅ | ✅ ⁸ |
| `punktfunk` CLI | ✅ | ✅ ⁹ | ✅ | ✅ | ✅ | ❌ |
| Moonlight | ❌ ¹⁰ | ❌ ¹⁰ | ✅ ¹¹ | ❓ ¹² | ❓ ¹² | ❓ ¹² |
@@ -375,10 +375,12 @@ macOS, iOS/iPadOS and tvOS. Android is one app, with Android TV being the same a
exists, so a fresh Apple TV has none. Of the settings a profile can carry, tvOS also drops the
ones the platform has no input for: inverted scroll, modifier layout, variable refresh rate,
mouse mode and touch mode.
6. The plugin writes flat values into the shared client settings; it has no profile surface. The
client it launches still honours whatever profile that settings file names.
7. Including pinned one-tap "Stream *game*" rows in the Quick Access Menu, which follow a host
across IP changes. Not subject to the desktop opt-in.
6. The panel *shows* the profiles a host has pinned, as nested one-tap cards, and streams with
them; it has no profile surface of its own. Pins are made in a client's own UI — including the
console home **Open Punktfunk** opens — and are shared, so every client shows the same cards.
Creating and editing a profile stays a desktop-app job.
7. Not in the panel: **Open Punktfunk** opens the client's console home, and a paired host's
library is one button from there.
8. Both the plugin itself and, where the install kind allows it, the client it launches.
9. The CLI parses and follows links; it does not register the URL scheme — the graphical apps do.
10. [Profiles and links](/docs/profiles-and-links) are Punktfunk-app concepts and do not exist on
@@ -400,7 +402,8 @@ macOS, iOS/iPadOS and tvOS. Android is one app, with Android TV being the same a
1. Multiple controllers, each on its own stable slot, arriving and leaving independently. The pad
**type** the host emulates is picked per pad; the pickers are not identical across apps — Linux,
Android and Decky offer six presets including Steam Deck, Windows and Apple offer five.
Android and the console home offer six presets including Steam Deck, Windows and Apple offer
five.
2. DualSense and DualShock 4 touchpad and motion are forwarded, and the host's adaptive-trigger and
lightbar effects are replayed on a real DualSense. On the desktop clients any controller SDL
exposes a gyro on forwards motion — a Switch Pro or the Steam Deck's own pad included — and the
@@ -513,8 +516,8 @@ capability.
| **GameStream / Moonlight plane** | Works, and whether it is on depends on how you installed. Every Linux package (deb, RPM, Arch, the Bazzite sysext) and the SteamOS installer ship the unit as `serve --gamestream`, so GameStream is **on** there; NixOS defaults it on too. The Windows installer's checkbox is unticked, so it is **off** unless you asked for it, and a bare `punktfunk-host serve` is off. It pairs over plain HTTP with weaker legacy encryption — trusted LAN only, and worth turning off if you don't use Moonlight (see [Security](/docs/security#gamestream--moonlight-compatibility-is-the-weak-crypto-path)). It is a compatibility surface, so Punktfunk-only features (profiles, links, clipboard, microphone) are not on it. |
| **Linux and Windows desktop clients** | Packaged and current. They are one codebase: the same session binary streams for both, and for the Decky plugin and the `punktfunk` CLI. |
| **Apple client** (macOS · iOS · iPadOS · tvOS) | One universal build, distributed as a **TestFlight beta**; the Mac also has a notarized DMG. Feature-complete apart from the platform gaps named above (no microphone on tvOS, clipboard on macOS only). |
| **Android client** (phone · TV) | Distributed on Play's **closed (alpha)** track for releases, Internal testing for canary, plus a sideloadable APK. The same app in leanback mode is the TV client. |
| **Decky plugin** (Steam Deck) | Ships through install-from-URL rather than the Decky store, and keeps itself and the client it launches up to date. It launches the Linux client rather than streaming itself, and has no settings surface of its own beyond the flat values it writes into the shared client settings. |
| **Android client** (phone · TV) | Published on **Google Play** as a public listing for releases, with an invite-only Internal testing track for canary, plus a sideloadable APK. The same app in leanback mode is the TV client. |
| **Decky plugin** (Steam Deck) | Ships through install-from-URL rather than the Decky store, and keeps itself and the client it launches up to date. It is a launcher, not a second client: it starts the Linux client rather than streaming itself, and holds no settings, no library and no host editor of its own — its **Open Punktfunk** button hands all of that to the client's console home. |
| **Web console** | The full management surface — dashboard and sessions, pairing, library, displays, plugins and the plugin store, logs, stats, settings, and host updates. It cannot yet run a speed test or set a bitrate; the client apps can. |
| **Plugins** | Three first-party ones (ROM Manager, Playnite, VirtualHere) plus the SDK, installed from the console. See [Plugins](/docs/plugins). |
| **`pf-webos`** (LG TV) | A community client in a separate repository. Nothing here can establish its state; ask that project. |
+67
View File
@@ -302,6 +302,73 @@ told your client so. [When the client and the host
disagree](/docs/client-settings#when-the-client-and-the-host-disagree) lists what it does with each
one.
## Streamed audio sounds worse than the host does
The host does not capture "the sound card" — it captures a **render endpoint**, and by default it
picks one that is *silent on the host* so the audio plays on your client only. On a PC with Steam
installed that silent endpoint is Steam's **Streaming Microphone**, which exists to carry remote
*voice*. If Windows has it configured as a narrow device — mono, or below 48 kHz — then the whole
desktop mix is squeezed through that before it is ever encoded, and no amount of bitrate will bring
it back.
Since 0.25 the host checks for this: it reads each candidate endpoint's real format, prefers a real
output device over a narrow virtual one, and says so in the log —
```
WARN the desktop-audio loopback endpoint mixes at 24000 Hz, so the stream is band-limited …
INFO audio loopback capturing device="…" engine_hz=48000 engine_ch=2 engine_bits=32
```
That `engine_*` line is the endpoint's **own** format, so it tells you directly whether the source
was ever full quality. To choose the routing yourself, set in `host.env`:
```ini
# client_only — default; audio plays on the client only (a silent endpoint)
# host_and_client — capture a real output device; audio plays on BOTH ends
# follow_default — capture whatever YOUR default playback device is, and never change it
PUNKTFUNK_AUDIO_OUTPUT_MODE=host_and_client
```
`host_and_client` is also the quickest way to A/B the problem: if the stream sounds right that way
and wrong on the default, the endpoint was the cause.
Two related knobs:
```ini
PUNKTFUNK_AUDIO_QUALITY=high # low | standard | high (default high — stereo 256 kbps)
PUNKTFUNK_AUDIO_REDUNDANCY=1 # force the loss-resilient audio plane on (default: automatic)
```
Both are a **request**, not a guarantee: the host budgets audio against the session's video
bitrate and steps it down on a narrow link, because audio is not managed by adaptive bitrate — so
whatever it takes is taken off the top. On a roomy link you get 256 kbps plus loss redundancy; as
the link narrows the host drops redundancy first, then the tier, and never goes below ~96 kbps. The
session log line says what it settled on:
```
INFO punktfunk/1 audio streaming … tier=high kbps=512 redundancy=true
```
`standard` reproduces the pre-0.25 encoder exactly if you want to A/B it.
## Audio lags behind the picture
The client buffers a little audio to absorb network jitter. Since 0.25 that buffer **corrects
itself**: if it drifts deeper — a Wi-Fi burst, a stall, or just the two devices' clocks running at
fractionally different speeds — it trims itself back a few milliseconds at a time, inaudibly.
Before, it could only grow, so a single hiccup left audio permanently behind the video and the only
cure was reconnecting.
If audio is still noticeably late:
- **Reconnect once.** It confirms whether the delay was accumulated (gone after a reconnect) or
constant (something else).
- **Check for underruns** rather than guessing. The client logs its buffer depth periodically; a
rising `underruns` count means the buffer is being starved, which is a network or CPU problem, not
a buffering one.
- **Wired or 5 GHz Wi-Fi.** Arrival jitter is what the buffer exists to absorb; less jitter lets it
run shallower.
## Windows: the host or the web console won't start
The **`PunktfunkHost` service** runs both halves of the Windows host: the streaming host itself and
+3 -2
View File
@@ -300,8 +300,9 @@ stop testing — that removes the app and its data with it.
### Android / Android TV
Uninstall the app from Google Play or from Settings → Apps. The Android client is still an invited
test track, so if you also want your account taken off the tester list, say so on
Uninstall the app from Google Play or from Settings → Apps. That's the whole job — it's a public
Play listing, so there's no tester list to leave. If you were on the invite-only **canary**
(Internal testing) track and want off that too, say so on
[Discord](https://discord.gg/kaPNvzMuGU).
### Steam Deck — Decky plugin
+12 -7
View File
@@ -74,9 +74,10 @@ saved host's own menu, and only appears when that host is offline *and* an addre
| Android · Android TV | **Wake host** — waits, showing the "Waking…" screen | **Wake-on-LAN MAC** in **Edit host** |
| Punktfunk Console (controller shell) | on an offline host with a known address, the confirm button reads **Wake & Connect** — it waits, then connects | not offered |
Punktfunk Console has no auto-wake setting of its own, and offers **Wake & Connect** whatever the
desktop app's setting says. In the Apple apps the same button appears when you drive them with a
controller, but there it does follow the auto-wake setting.
Punktfunk Console carries the row too — **Wake hosts automatically**, in the same settings list the
desktop apps write — but its **Wake & Connect** button is an explicit action and appears whatever
that row says. In the Apple apps the same button appears when you drive them with a controller, but
there it does follow the auto-wake setting.
The Apple apps also publish a **Wake Host** action to Shortcuts, so an automation can wake a host
without opening the app. On iPhone and iPad it has a ready-made phrase: *"Wake ⟨host⟩ with
@@ -88,10 +89,14 @@ host list, and shows an explanation with a link to system settings if you declin
### On the Steam Deck
The [Decky plugin](/docs/steam-deck) has no wake button and no wake setting. It sends a wake through
the Flatpak client just before **every** stream launch, and it is a no-op until that client has
learned the host's address. When a packet really did go out, the plugin also stretches the stream's
connect budget to 75 seconds, so the connection survives the host resuming from sleep.
The [Decky plugin](/docs/steam-deck) has no wake button and no wake setting of its own. It starts
every stream through the client, so the wake is the client's, on exactly the terms above: a packet
the moment the host doesn't answer, re-sent every 6 seconds while the client watches for it once a
second, and the dial only when it really is back. It follows **Wake hosts automatically** in the
client's own settings — **Open Punktfunk → Settings** from the same panel — and is a no-op until the
client has learned that host's MAC address. (The plugin used to fire a packet itself and stretch the
connect budget to 75 seconds to cover the resume; a wait that watches for the host beats a fixed
budget, so that is gone.)
### From the command line
+112 -1
View File
@@ -312,6 +312,13 @@
// `PunktfunkStatus` code).
#define PUNKTFUNK_CLIP_ERROR 6
// The protocol's audio frame, in milliseconds — every host datagram carries exactly one
// ([`crate::quic::encode_audio_datagram`]), so it is also the smallest useful shed unit.
#define PUNKTFUNK_AUDIO_FRAME_MS 5
// Sample rate of every audio plane in the protocol.
#define PUNKTFUNK_AUDIO_SAMPLE_RATE_HZ 48000
#if defined(PUNKTFUNK_FEATURE_QUIC)
// The uniform no-TTL-host staleness bound: a legacy host refreshes state every 500 ms, so two
// missed refreshes = quiet host → silence. Replaces the per-platform zoo (1.6 s / 60 s / 1.5 s /
@@ -333,6 +340,12 @@
#define INBOUND_REQ_FLAG 2147483648
#endif
// Floor for a negotiated `shard_payload` (even, well under every real path). A path whose UDP
// budget lands below this can't carry the QUIC control plane either (QUIC's own minimum is a
// 1200-byte UDP payload), so shrinking video shards further buys nothing — the clamp helpers
// bottom out here instead of producing degenerate confetti-sized shards.
#define MIN_SHARD_PAYLOAD 512
// 16-byte AEAD authentication tag appended by either session cipher.
#define TAG_LEN 16
@@ -475,7 +488,17 @@
// Largest UDP datagram the core will send or accept. `Config::validate` bounds
// `shard_payload` so `HEADER_LEN + shard_payload + CRYPTO_OVERHEAD ≤ MAX_DATAGRAM_BYTES`.
#define MAX_DATAGRAM_BYTES 2048
//
// Sized for **jumbo frames** (design/shard-payload-reneg.md W0.2): a 9000-MTU LAN carries
// ~8908-byte shards (sealed 8972-byte UDP payloads), and every receive path — the transport
// `RECV_BUF`, the session's `recvmmsg` ring — is sized from this constant, so a deployed
// client can accept a jumbo geometry the moment its host negotiates one. The ring cost is
// 128 × ~9 KiB ≈ 1.1 MiB per **client** session (lazily allocated on first poll; hosts never
// allocate it) — measured against the ~256 KiB it was at 2048, an acceptable static price
// for never having to resize buffers on a mid-session grow. Senders still derive their
// shard payload from the path MTU (`config::mtu1500_shard_payload*`, the wire-MTU clamps);
// this is the acceptance ceiling, not a transmit size.
#define MAX_DATAGRAM_BYTES 9216
// The slice-flush floor: a sentinel block below this many data shards costs disproportionate
// per-block FEC parity (`ceil(k × pct/100)` ≥ 1 whatever `k`), so slice boundaries only flush
@@ -627,6 +650,19 @@
#define CLIENT_CAP_PHASE_LOCK 2
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// `Hello.client_caps` bit: this client can decode the redundant desktop-audio plane
// ([`AUDIO_RED_MAGIC`](super::datagram::AUDIO_RED_MAGIC), `0xD2`), where every datagram also
// carries a copy of the previous frame so a single lost packet is reconstructed instead of
// papered over with packet-loss concealment.
//
// Active only when the host answers with [`HOST_CAP_AUDIO_RED`] (capable-and-agreed, the
// cursor/clipboard precedent). Toward an older host, or a host that declines because the link is
// clean, the client keeps receiving the plain `0xC9` plane — so a client may always set this bit.
// `0x04` — `0x01`/`0x02` are cursor / phase-lock.
#define CLIENT_CAP_AUDIO_RED 4
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`Welcome::host_caps`] bit: the host CAN forward the cursor out-of-band (it captures cursor
// metadata separately from the frame — the Linux portal `SPA_META_Cursor` path; NOT gamescope,
@@ -652,6 +688,20 @@
#define HOST_CAP_PEN 16
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`Welcome::host_caps`] bit: the host is sending the REDUNDANT desktop-audio plane
// ([`AUDIO_RED_MAGIC`](super::datagram::AUDIO_RED_MAGIC), `0xD2`) instead of plain `0xC9` — each
// datagram carries its own frame plus a copy of the previous one.
//
// Set only when the client asked via [`CLIENT_CAP_AUDIO_RED`]. It is a statement about the WIRE,
// not a negotiation the client can decline: with the bit set the client must decode `0xD2`, and
// without it `0xC9`. The host may also drop back to `0xC9` mid-session (the redundancy is
// loss-gated — a clean LAN shouldn't pay for it), which is why clients decode BOTH tags
// unconditionally and treat this bit as "expect redundancy", not "only redundancy".
// `0x20` — `0x10` is [`HOST_CAP_PEN`], `0x08` is [`HOST_CAP_CURSOR`].
#define HOST_CAP_AUDIO_RED 32
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
@@ -776,6 +826,16 @@
#define MSG_RFI_REQUEST 7
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`ShardPayloadChanged`].
#define MSG_SHARD_PAYLOAD_CHANGED 8
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`ShardPayloadAck`].
#define MSG_SHARD_PAYLOAD_ACK 9
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`ProbeRequest`].
#define MSG_PROBE_REQUEST 32
@@ -967,6 +1027,41 @@
#define HIDOUT_MAGIC 205
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Redundant audio datagram, host → client: the [`AUDIO_MAGIC`] plane plus a copy of the PREVIOUS
// frame, so a single lost datagram is *reconstructed* rather than concealed.
//
// `[0xD2][u32 seq LE][u64 pts_ns LE][u16 primary_len LE][primary opus][previous opus]`
//
// **Why this and not Opus in-band FEC.** LBRR is a SILK-layer feature: the desktop-audio encoder
// runs `RESTRICTED_LOWDELAY` (CELT-only) at 5 ms frames, which is below SILK's 10 ms minimum, so
// `set_inband_fec(true)` on that encoder is a no-op. Nothing in libopus can protect this plane —
// the redundancy has to be at the application layer. (The mic uplink is a different encoder, VoIP
// mode at 10 ms, and *does* use real in-band FEC.)
//
// **Why it costs no latency.** The copy rides the SUCCESSOR of the frame it protects, and the
// client is already holding 1590 ms of de-jitter buffer — far more than the 5 ms the successor
// takes to arrive. So the recovery happens inside slack that already exists.
//
// The previous frame's sequence is implicitly `seq - 1`; a host with nothing to duplicate yet
// (the first frame of a session, or straight after a capture reopen) simply sends an empty tail,
// which decodes to `None`.
//
// Sent ONLY when the client advertised [`CLIENT_CAP_AUDIO_RED`](super::caps::CLIENT_CAP_AUDIO_RED)
// and the host answered [`HOST_CAP_AUDIO_RED`](super::caps::HOST_CAP_AUDIO_RED) — the
// capable-and-agreed handshake the cursor and 4:4:4 planes already use. Every other session keeps
// the plain [`AUDIO_MAGIC`] wire byte-for-byte.
//
// NB `0xD1` is deliberately skipped: the DualSense pad-audio program has reserved it for the
// per-pad audio plane.
#define PUNKTFUNK_AUDIO_RED_MAGIC 210
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Fixed header length of an [`AUDIO_RED_MAGIC`] datagram (tag + seq + pts + primary length).
#define PUNKTFUNK_AUDIO_RED_HEADER (((1 + 4) + 8) + 2)
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Wire length of a v1 (legacy, level) rumble datagram.
#define RUMBLE_V1_LEN 7
@@ -1397,6 +1492,14 @@ typedef uint8_t PunktfunkInputKind;
typedef struct ColorInfo ColorInfo;
#endif
// Tuning for [`JitterPolicy`], in MILLISECONDS.
//
// Denominating the depth in time rather than in device quanta is the point. Every client used to
// compute its target as `3 × quantum`, which is a sane 15 ms at a 5 ms quantum and a silent 64 ms
// at a 20 ms one — the same source line meaning two very different latencies depending on what
// else happened to be using the audio graph that day.
typedef struct JitterTuning JitterTuning;
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Opaque handle to a live `punktfunk/1` connection (QUIC control plane + UDP data plane, all
// pumped on internal threads).
@@ -1788,6 +1891,14 @@ typedef struct {
// The multipliers a picker offers. `1.0` (Native) is the default; the rest are the round stops
// users reason about. Shared so every client's list stays identical.
#define PRESETS { 0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0, }