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
enricobuehler 2c03290a5e Merge pull request 'chore(release): bump workspace version to 0.24.0' (#29) from worktree-release-0240 into main
audit / cargo-audit (push) Successful in 50s
apple / swift (push) Successful in 1m19s
audit / bun-audit (plugin-kit) (push) Failing after 24s
audit / bun-audit (sdk) (push) Failing after 21s
audit / bun-audit (web) (push) Failing after 36s
audit / docs-site-audit (push) Successful in 33s
audit / pnpm-audit (push) Successful in 24s
apple / screenshots (push) Successful in 5m40s
audit / license-gate (push) Successful in 5m48s
android-screenshots / screenshots (push) Successful in 1m33s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m1s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m0s
decky / build-publish (push) Successful in 36s
linux-client-screenshots / screenshots (push) Successful in 1m42s
android / android (push) Successful in 9m17s
sbom / sbom (push) Successful in 28s
arch / build-publish (push) Successful in 13m16s
web-screenshots / screenshots (push) Successful in 4m26s
flatpak / build-publish (push) Successful in 8m8s
release / apple (push) Successful in 10m44s
ci / rust (push) Successful in 6m48s
ci / web (push) Successful in 1m33s
ci / docs-site (push) Successful in 1m36s
ci / rust-arm64 (push) Successful in 1m49s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m56s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m45s
windows-host / package (push) Successful in 11m53s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Successful in 24s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
deb / build-publish (push) Successful in 6m25s
deb / build-publish-client-arm64 (push) Successful in 1m19s
docker / deploy-docs (push) Successful in 16s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / builders-arm64cross (push) Successful in 8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 25s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m22s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m58s
deb / build-publish-host (push) Successful in 4m11s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m10s
Reviewed-on: #29
2026-08-03 19:43:24 +00:00
enricobuehler b6a370a0fd Merge remote-tracking branch 'origin/main' into worktree-release-0240
ci / web (pull_request) Successful in 1m8s
apple / swift (pull_request) Successful in 1m21s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m31s
ci / docs-site (pull_request) Successful in 1m57s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m14s
android / android (pull_request) Successful in 4m35s
ci / rust (pull_request) Successful in 6m0s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 2m56s
2026-08-03 21:40:38 +02:00
enricobuehler 7db83445b2 Merge pull request 'fix(host/input): rumble comes back when a controller does' (#25) from worktree-haptics-m1-rumble-seq into main
apple / swift (push) Successful in 1m18s
ci / web (push) Successful in 1m31s
ci / rust-arm64 (push) Successful in 2m9s
ci / docs-site (push) Successful in 1m43s
deb / build-publish-client-arm64 (push) Successful in 1m47s
android / android (push) Successful in 5m55s
deb / build-publish-host (push) Successful in 4m49s
apple / screenshots (push) Successful in 5m54s
arch / build-publish (push) Successful in 8m33s
deb / build-publish (push) Successful in 4m53s
ci / rust (push) Successful in 6m22s
windows-host / package (push) Successful in 12m32s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 28s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
docker / deploy-docs (push) Successful in 30s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 16s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 33s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 27s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / builders-arm64cross (push) Successful in 14s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 37s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 35s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 18m33s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 19m43s
Reviewed-on: #25
2026-08-03 19:17:49 +00:00
enricobuehler 5582a6ea51 Merge branch 'main' into worktree-haptics-m1-rumble-seq
apple / swift (pull_request) Successful in 1m17s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m51s
ci / docs-site (pull_request) Successful in 2m5s
ci / rust-arm64 (pull_request) Successful in 2m22s
android / android (pull_request) Successful in 3m25s
ci / rust (pull_request) Successful in 8m4s
2026-08-03 19:17:01 +00:00
enricobuehler f7b85ec1fd Merge pull request 'fix(host/pads): an unplugged controller actually disappears' (#26) from worktree-haptics-m2-pad-slots into main
android / android (push) Canceled after 1m16s
apple / swift (push) Canceled after 1m18s
apple / screenshots (push) Canceled after 0s
ci / docs-site (push) Successful in 1m21s
arch / build-publish (push) Canceled after 1m33s
ci / rust (push) Canceled after 1m33s
ci / web (push) Canceled after 1m36s
ci / rust-arm64 (push) Canceled after 1m42s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 1m35s
deb / build-publish-client-arm64 (push) Canceled after 48s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 24s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 2s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 19s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 18s
windows-host / package (push) Canceled after 1m48s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
Reviewed-on: #26
2026-08-03 19:16:24 +00:00
enricobuehler 327301e012 docs(release): the 0.24.0 notes cover the two controller fixes
ci / web (pull_request) Successful in 1m23s
ci / docs-site (pull_request) Successful in 1m28s
android / android (pull_request) Successful in 5m0s
apple / swift (pull_request) Successful in 1m20s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 6m19s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m20s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m1s
ci / rust (pull_request) Successful in 12m53s
PRs #25 and #26 are going into this release, and neither was in the notes.

Both are user-visible and easy to have lived with without knowing why:
force-feedback stopping for good after a controller reconnect (roughly half of
reconnects, every platform), and an unplugged pad staying visible to the game
for the rest of the session (every time, if it was your only controller).

The whatsnew line for the rumble fix is Play listing copy and that file has a
500-character ceiling, so "A decoder hiccup no longer snowballs into a burst of
broken frames" loses "snowballs into" for "causes" — same meaning, and the new
line is kept short. 498 of 500 used.
2026-08-03 19:52:05 +02:00
enricobuehler ab4cd06e86 Merge remote-tracking branch 'origin/main' into worktree-haptics-m2-pad-slots
ci / docs-site (pull_request) Successful in 3m0s
apple / swift (pull_request) Successful in 1m19s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 4m42s
ci / web (pull_request) Successful in 1m39s
ci / rust-arm64 (pull_request) Failing after 11m17s
ci / rust (pull_request) Successful in 7m36s
2026-08-03 19:47:34 +02:00
enricobuehler 3eab1e41df Merge remote-tracking branch 'origin/main' into worktree-haptics-m1-rumble-seq
ci / web (pull_request) Successful in 1m42s
ci / docs-site (pull_request) Successful in 1m40s
ci / rust-arm64 (pull_request) Successful in 3m22s
apple / swift (pull_request) Successful in 1m23s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 4m8s
ci / rust (pull_request) Successful in 8m11s
2026-08-03 19:47:32 +02:00
enricobuehlerandClaude Opus 5 62573d2781 docs(release): the 0.24.0 notes cover the ABR sweep
ci / web (pull_request) Successful in 1m11s
android / android (pull_request) Canceled after 0s
apple / swift (pull_request) Canceled after 0s
ci / rust (pull_request) Failing after 3m8s
apple / screenshots (pull_request) Canceled after 0s
ci / rust-arm64 (pull_request) Canceled after 1m56s
ci / docs-site (pull_request) Canceled after 32s
windows / build (aarch64-pc-windows-msvc) (pull_request) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (pull_request) Canceled after 0s
PR #28 merged after the bump commit was written, so the notes described a
release that no longer matched the tree. Merged origin/main and added what it
brings: 45 commits since v0.23.0 now, not 39.

Four user-facing entries, because eleven defects in one path is not one bullet
and the pinning is the headline the field reports have been describing for
months ("my bitrate is stuck at 20"):

- the 20 Mbps pin itself, with the measured escape (150 Mbps in ~16 s against
  ~17 minutes) — the number is the point, since the old behaviour was not "slow
  to climb" but "never arrives"
- the five single-window lessons the controller treated as permanent
- throughput counted with FEC parity, which rose with the loss it was meant to
  detect
- the silent host re-target, which made a client's first climb a request to go
  DOWN

The Under the hood section gets the whole sweep in one bullet rather than
scattering it, and PUNKTFUNK_ABR_MAX_MBPS moves from the probe bullet into it
(it now binds at construction, not only on probe-learned ceilings, so it no
longer belongs to the probe).

Play notes gain an ABR line and now run 459/500 chars; the gate's real logic was
re-run against the file, including the byte-identical check. Voice check over
everything above "Under the hood" is clean of internal vocabulary.

Re-verified after the merge: cargo metadata --locked resolves, cargo fmt --all
--check clean, doc lazy-continuation scanner 0 hits. #28 touched no manifest, so
the version bump and the versions-only lock diff are untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 19:47:01 +02:00
enricobuehler d383fa6103 Merge remote-tracking branch 'origin/main' into worktree-release-0240 2026-08-03 19:44:53 +02:00
enricobuehlerandClaude Opus 5 93608980ae chore(release): bump workspace version to 0.24.0
ci / web (pull_request) Successful in 1m5s
android / android (pull_request) Canceled after 1m19s
apple / swift (pull_request) Canceled after 0s
apple / screenshots (pull_request) Canceled after 0s
ci / rust (pull_request) Canceled after 2m50s
ci / rust-arm64 (pull_request) Canceled after 2m0s
ci / docs-site (pull_request) Canceled after 1m29s
windows / build (aarch64-pc-windows-msvc) (pull_request) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (pull_request) Canceled after 0s
A minor bump: 39 commits since v0.23.0 across 121 files. Mostly a fix-up of
0.23.0 — the slice wire's reassembler sized every sentinel-opened AU at
max_frame_bytes and lost 9 of 12 in-flight frames on any link that reorders,
which is the freeze field reports were seeing on Android and the session client
— plus the desktop presenter rebuild (intent model, V-Sync/VRR as real settings,
the driver's queue-free vblank mode where it exists), the Decky settings tab
growing from nine rows to the whole store, a "Forward controllers" off switch
for passthrough couches, and plugin output finally reaching the console's log
page. The canary base is already 0.24 — scripts/ci/pf-version.sh derives it as
one minor ahead of the latest stable tag — so this is the version canary has
been publishing against all along.

No wire, ABI or driver-protocol change: wire protocol 2, C ABI 14, virtual-display
driver protocol 6 and the Windows virtual-gamepad channel 3 are all identical to
0.23.0. No new capability bits either — VIDEO_CAP_MULTI_SLICE took the video-caps
byte's last free bit in 0.23.0 and nothing here needed the next one. The only
generated-header change since the tag is documentation (probe elapsed_ms
semantics), already committed and verified by ci.yml's staleness gate on main.

Lock touched for the 32 workspace members only, via `cargo update --workspace`:
diff against origin/main is versions-only, 32 insertions and 32 deletions (the
33rd 0.23.0 line in the lock is the third-party `wasapi` crate, which sits at
0.23.0 itself — same trap as the last cut). `cargo metadata --locked` resolves;
`cargo fmt --all --check` clean in both the main and the packaging/windows/drivers
workspaces.

api/openapi.json is deliberately left at 0.23.0: it tracks API edits and lags a
release, as in every prior cut.

Notes at docs/releases/v0.24.0.md, per docs/releases/README.md — authored with the
bump so CI's ensure_release seeds the release body at tag creation. Play's "What's
new" at docs/releases/whatsnew/v0.24.0.txt (409/500 chars), which android.yml now
gates as a hard failure at step 1; the gate's own logic was run locally against
this file, including the byte-identical-to-another-release check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 19:39:00 +02:00
enricobuehler 1feeff3ca6 Merge pull request 'fix(abr): eleven defects from a sweep of the Automatic-bitrate path' (#28) from fix/abr-sweep into main
apple / swift (push) Successful in 1m19s
ci / web (push) Successful in 1m23s
ci / docs-site (push) Successful in 1m23s
ci / rust-arm64 (push) Successful in 2m23s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 19s
deb / build-publish-client-arm64 (push) Successful in 1m23s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 16s
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 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 24s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 23s
android / android (push) Successful in 5m58s
deb / build-publish (push) Successful in 5m52s
docker / builders-arm64cross (push) Successful in 13s
deb / build-publish-host (push) Successful in 5m5s
docker / deploy-docs (push) Successful in 36s
apple / screenshots (push) Successful in 6m14s
arch / build-publish (push) Successful in 8m31s
ci / rust (push) Successful in 8m36s
flatpak / build-publish (push) Successful in 7m27s
windows-host / package (push) Successful in 13m53s
windows-host / winget-source (push) Skipped
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m31s
windows-host / canary-manifest (push) Successful in 35s
release / apple (push) Successful in 11m43s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m42s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 4m12s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m7s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m12s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m2s
Reviewed-on: #28
2026-08-03 17:33:28 +00:00
enricobuehler 1ae8b4d4ca fix(client/abr): let the ceiling follow a host-initiated re-target
apple / swift (pull_request) Successful in 1m15s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 4m8s
ci / web (pull_request) Successful in 2m1s
ci / rust-arm64 (pull_request) Successful in 2m25s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m14s
ci / docs-site (pull_request) Successful in 1m14s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m44s
ci / rust (pull_request) Successful in 9m9s
Interaction between two fixes in this series. The host now tells the client when
a rebuild re-resolves an Automatic rate, and that rate can legitimately sit ABOVE
the client's climb ceiling — the ceiling is the negotiated start rate until the
capacity probe raises it, while the host's re-resolve answers "what do these
pixels actually need" (a 1080p session mirroring a 4K panel resolves ~3× higher).

Left alone, the client would learn the new rate, notice it was above a stale
ceiling, and step the host straight back down off the rate it had just chosen
for itself. So an ack raises the ceiling to meet it. `set_ceiling` only ever
raises and still clamps to PUNKTFUNK_ABR_MAX_MBPS, which is the one limit that
should bind here. No effect on ordinary acks: a climb is never requested above
the effective ceiling to begin with.
2026-08-03 18:53:02 +02:00
enricobuehler 33ecd8e1a5 fix(client/abr): a granted climb disproves the learned cap
Completing the cap-escape fix. Backing the re-probe clock off to 12 s got the
client asking again quickly, but each ask only LIFTED the cap by +12.5 % — so
even a host that had fully recovered still granted the session its real ceiling
one small step at a time, ~4 minutes from the 20 Mbps default to a 300 Mbps
link. The crawl was never the point; re-learning was.

A request granted IN FULL at or above the cap is the host's own word that the
limit is gone. Drop the cap outright at that point instead of nudging it. A
standing limit is unaffected — it answers the same re-probe with another short
ack, which re-latches it and doubles its clock, exactly as before.

Adds the end-to-end regression the sweep was really about: a session pinned at
20 Mbps by a transient cadence refusal, under a probe-measured 300 Mbps ceiling,
now reaches 150 Mbps in 22 windows (~16 s) where it used to need ~17 minutes.
2026-08-03 18:53:02 +02:00
enricobuehler 48565c4e9e fix(host/abr): stop pinning Automatic sessions, and tell the client when the rate moves
Two host-side halves of the same sweep.

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

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

2 host tests added.
2026-08-03 18:53:02 +02:00
enricobuehler e9a7373c76 fix(client/abr): measure delivered throughput in media bytes, not wire bytes
The controller's two throughput-driven gates both compare "what the pipeline
carried" against the ENCODER's target: the utilization gate asks whether a clean
window actually tested that target (a calm menu proves nothing), and the
never-decaying proven mark bounds how far every later climb may step.

Both were fed `bytes_received`, which counts every accepted datagram — headers,
FEC parity, probe filler, audio. So the figure rose with the redundancy the host
adds in ANSWER to loss: at 25 % FEC the gate passed with the encoder emitting
~55 % of target, and the proven mark inherited the same inflation permanently.
The signal was weakest exactly on the lossy links it exists for.

Count data-shard payload separately at the reassembler's routing decision — the
same place, and for the same reason, the probe counters are already stamped —
and feed the ABR that. First time both gates are dimensionally honest: a media
rate compared against a media target.
2026-08-03 18:53:02 +02:00
enricobuehler f7a8c2013d fix(client/abr): the controller stops learning the wrong lessons from one window
Six defects found by a sweep of the Automatic-bitrate path, all of them the same
shape: a single window, or a single refusal, taught the controller something it
then treated as permanent.

- Rolling baselines (OWD, client decode, host encode) armed off ONE sample. The
  baseline is a rolling minimum, so one window IS the floor — and `on_ack`
  deliberately clears the encode baseline after every decrease we ourselves
  asked for, re-opening that hole each time. A calm re-seed window followed by
  ordinary motion read as 4 ms of "congestion", backed off, cleared again, and
  ratcheted toward the floor on a link that was never the problem. All three now
  need BASELINE_MIN_WINDOWS of evidence before they may fire, via one shared
  `score_baseline` (the three copies had already drifted apart).

- A mode switch rebased only the encode baseline. Decode and OWD are just as
  mode-scoped: 4K120 decodes slower and puts bigger frames on the wire than
  1080p60, so the old floor was one the new mode cleared on its first window —
  ~30 s of every window scoring bad, i.e. a backoff every other window. A switch
  UP in mode cratered the rate instead of raising it. `proven_kbps` goes with
  them; throughput the old mode's decoder digested is not evidence about this one.

- `proven_kbps` — never decayed, and permanent authority over how far every
  later climb may step — was raised by any window without a decode rise,
  including ones scored SEVERE. The windows that overstate delivered throughput
  are exactly the damaged ones: a stall's backlog draining at once, a flush's
  queue, the FEC surge answering a loss burst. Now only clean windows raise it.

- A learned cap escaped at +12.5 % per ~60 s. The host cannot distinguish a
  durable encoder ceiling from a climb refused while it is transiently behind
  cadence, and the latter routinely latches during slow start at the 20 Mbps
  default — from which crossing the gap to a probe-measured ceiling took upwards
  of twenty minutes. Re-probe after 12 s instead, doubling the interval each time
  the lift is immediately re-learned: a transient is out in one interval, a real
  ceiling settles into a slow poll.

- The decode cap latched AT the rate that choked, authorizing a climb straight
  back into the failure, and a bare jump-to-live flush could teach a "decoder
  knee" from what was a network event. It now latches just under the choke rate
  (inside the ±1/8 band the evidence already required) and only credits a flush
  where the decode signal is absent and cannot speak for itself.

- PUNKTFUNK_ABR_MAX_MBPS bound only probe-learned ceilings, not the negotiated
  start rate — so the one knob an Automatic session gives the operator did
  nothing when the session already started above it. It now binds at
  construction, and a session sitting above its ceiling steps down to it (no
  congestion signal will ever find that: the link is fine, the cap is policy).

Also: a SetBitrate dropped by a full control queue counted toward MAX_UNACKED,
so three of them retired the controller for the session while logging that an
"older host" was at fault. The pump now tells the controller what happened.

Wire format and ABI untouched. 34 abr tests green (3 new).
2026-08-03 18:53:02 +02:00
enricobuehler 926e2ccbdd Merge pull request 'feat(decky): the settings tab covers the whole store, as a SteamOS-style sidebar' (#24) from worktree-decky-stats-overlay-toggle into main
ci / web (push) Successful in 1m7s
ci / rust-arm64 (push) Successful in 2m12s
decky / build-publish (push) Successful in 30s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
ci / docs-site (push) Successful in 1m15s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 19s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m5s
docker / builders-arm64cross (push) Successful in 10s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m26s
docker / deploy-docs (push) Failing after 6m7s
ci / rust (push) Successful in 8m27s
Reviewed-on: #24
2026-08-03 16:44:55 +00:00
enricobuehler b8b38d082e Merge pull request 'fix(plugins): plugin output reaches the console's log page, and /tmp is no longer hidden from the runner' (#27) from worktree-plugin-logs-and-vh-fixes into main
ci / rust (push) Canceled after 23s
ci / rust-arm64 (push) Canceled after 24s
ci / web (push) Canceled after 24s
ci / docs-site (push) Canceled after 24s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
apple / swift (push) Successful in 1m16s
deb / build-publish-client-arm64 (push) Successful in 1m12s
deb / build-publish (push) Successful in 3m52s
deb / build-publish-host (push) Successful in 4m45s
android / android (push) Successful in 5m24s
apple / screenshots (push) Successful in 5m57s
arch / build-publish (push) Successful in 8m4s
windows-host / package (push) Successful in 17m44s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 28s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m34s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 22m42s
Reviewed-on: #27
2026-08-03 16:44:31 +00:00
enricobuehler 9979489b56 fix(host/pads): an unplugged controller actually disappears
ci / web (pull_request) Successful in 1m11s
apple / swift (pull_request) Successful in 1m18s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m24s
ci / rust-arm64 (pull_request) Successful in 1m35s
android / android (pull_request) Successful in 3m11s
ci / rust (pull_request) Successful in 6m3s
Unplug a controller mid-session and the virtual pad it was driving outlives
it: the game keeps seeing a connected, permanently idle device for the rest of
the session. The single-controller session — the common case — hits this every
time.

`PadSlots::sweep` needs two passes to retire a pad. The first pass to see the
mask bit clear only ARMS the 300 ms devnode-churn grace; the drop lands on a
later pass. But sweep runs only from a state frame, and the producer emits
exactly one frame per detach — `native/input.rs` guards the emit on the bit
still being set — so for a pad with no still-changing sibling in the same
manager, the second pass never comes. Nothing periodic reaches sweep:
`heartbeat` and `pump` walk the slots without it.

Split the two halves. `sweep` still folds a frame's mask into the grace
clocks, and `reap` — new — drops whatever has run out, with no frame needed.
Every manager now reaps on the periodic pump it already runs, so the teardown
completes ~300 ms after the detach instead of never.

`reap` deliberately cannot arm a clock: it only reads `inactive_since` and
clears it, so a pad whose bit never went clear has nothing to run out and no
amount of reaping can drop it. That is what makes it safe on a hot loop, and
it keeps the anti-flap guarantee intact — a mask that blips clear and returns
still never churns a devnode.

The two existing tests hand-fed a SECOND removal frame, which production never
sends; they passed while the real path leaked. Both now drive the unplug
through a pump tick, and PadSlots gains three tests pinning the new
invariants. Verified non-vacuous: with the reap neutered, both manager tests
fail with "the pump tick never completed the unplug".

Behaviour notes: this puts UI_DEV_DESTROY on the GameStream control thread's
budget for the first time, and a mask glitch longer than the grace now really
does flap — which is SWEEP_GRACE working as documented, so the constant stays.

Found by the 2026-08-03 force-feedback sweep (B2 — see the backlog in
punktfunk-planning design/haptics-sweep-2026-08-03.md).
2026-08-03 17:34:47 +02:00
enricobuehler 14502769e0 fix(host/input): rumble comes back when a controller does
android / android (pull_request) Failing after 18s
ci / docs-site (pull_request) Successful in 1m7s
ci / web (pull_request) Successful in 1m9s
apple / swift (pull_request) Successful in 1m15s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m45s
ci / rust (pull_request) Successful in 6m9s
Unplug a pad mid-session and plug it back in, and roughly half the time it
never rumbles again for the rest of the session.

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

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

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

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

Found by the 2026-08-03 force-feedback sweep (B1/T5 — see the backlog in
punktfunk-planning design/haptics-sweep-2026-08-03.md).
2026-08-03 16:57:11 +02:00
enricobuehlerandClaude Opus 5 db1faef9fb docs(plugins): don't name a release that doesn't exist yet
ci / web (pull_request) Successful in 1m4s
apple / swift (pull_request) Successful in 1m28s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m45s
ci / rust-arm64 (pull_request) Successful in 2m39s
android / android (pull_request) Successful in 3m16s
ci / rust (pull_request) Successful in 7m58s
The /tmp troubleshooting note said PrivateTmp=yes shipped "until 0.23.1".
0.23.0 is the latest tag and the next number isn't decided, so that could be
wrong on arrival. "In earlier releases" is true whichever number it gets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 16:00:48 +02:00
enricobuehlerandClaude Opus 5 442ea12b96 test(mgmt): pin the two things that would silently break plugin logging
The runner holds the PLUGIN token and nothing else — on Windows its LocalService
principal cannot read the admin one at all. `plugin_may_access` is an exclusion
list, so `/plugins/logs` is reachable today only because it happens not to match
`/ui-credential`. If that ever changed, plugin logs would go quiet in the console
with no other symptom and no failing test. Now asserted on that lane directly.

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 15:56:25 +02:00
enricobuehlerandClaude Opus 5 1e56705b86 fix(sdk/log-ship): a busy plugin's lines survive a POST, and the shutdown tail is actually sent
Two bugs in the log shipper, both found by re-reading it rather than by a
failing test, and both of the kind where the symptom is a missing log line —
which is the one failure a logging path must not have.

The recursion guard was held across the whole `await fetch`, and `enqueue`
checked it. So every line logged while a POST was open was dropped, silently.
That window is milliseconds when the host is healthy and much longer when it is
not, and the lines lost are whatever a busy plugin happened to be saying — so
the shipper was least reliable exactly when it was most needed. The flag now
guards flush re-entry only (the interval can fire while a slow POST is still
open, and two concurrent flushes would splice disjoint batches out of one queue
and deliver them out of order). Nothing on the shipping path logs, so the
recursion it was guarding cannot form; that is now a stated rule at the top of
the file rather than a flag that costs real lines.

An explicit `flush()` hit that same re-entry guard and returned having sent
nothing. That is the shutdown path: the runner flushes once more after its
units' finalizers have run, and those last lines are the ones that say whether
the shutdown was clean. It now waits for an in-flight flush before starting its
own.

Both are covered by tests that fail against the previous code. The first needed
a server that signals when it has the request — logging merely "after calling
flush()" passes against the bug, because flush yields at its own awaits long
before the fetch starts.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 15:47:51 +02:00
enricobuehlerandClaude Opus 5 f71bee917b Merge origin/main; the presenter rebuild's four settings join the sidebar
ci / web (pull_request) Successful in 1m14s
ci / rust-arm64 (pull_request) Successful in 1m41s
ci / docs-site (pull_request) Successful in 1m36s
ci / rust (pull_request) Successful in 6m1s
`#20` landed while this branch was open and added four settings the
console screen groups under a new "Presentation" header: Prioritize,
Smoothness buffer, V-Sync and Follow variable refresh. A branch whose
whole claim is "everything the store holds is reachable" cannot merge
past those, so they get a Presentation page of their own, in the console
screen's position (after Video, before Audio) and with its wording.
Smoothness buffer is indented under Prioritize and disabled until the
intent is Smoothness — the same relationship the console's `enabled`
gate draws.

The docs conflict resolves to main's side plus this branch's correction:
the 4:4:4 advertisement claim main rewrote is the current one and stays,
while "Android, Decky and the console home don't offer it" was wrong
about two of the three before this branch and about all three after it.
The four new settings' paragraphs pick up the console home and Decky the
same way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 00:40:05 +02:00
enricobuehlerandClaude Opus 5 6de78213ee feat(decky): the settings tab covers the whole store, as a SteamOS-style sidebar
ci / web (pull_request) Successful in 1m4s
ci / docs-site (pull_request) Successful in 1m25s
ci / rust-arm64 (pull_request) Successful in 1m31s
ci / rust (pull_request) Canceled after 3m0s
The stats overlay was the visible half of a general problem: nine of the
client's settings had a row here and twenty didn't, so a Deck that never
sees a desktop could not reach its own decoder, chroma, HDR, audio
layout, echo cancellation, touch or mouse model, scroll direction,
auto-wake, or either audio endpoint. Everything the store holds is here
now — except the two things a plugin backend genuinely cannot answer,
named in `backend.ts` so the next reader doesn't go looking: which
physical pad is player 1 (SDL's live device list lives in the client
process, and no CLI enumerates it) and the session's remembered window
size, which is not a preference.

Thirty rows is too many to scroll past on a thumbstick, so they are
split across a `SidebarNavigation` — the 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 point: 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 settings screen's — it
is the other settings editor reachable 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. The six pages take
one shared settings object rather than each holding state, so a change
on one is visible on the others the moment you switch.

Three more rules:

- A dependent setting is INDENTED under what it depends on and DISABLED,
  never hidden: mic device and echo cancellation under the microphone,
  controller type under forwarding. The console dims those rows for the
  same reason, and a row that vanishes as you toggle the one above it is
  a moving target for a thumbstick. The device row at the foot of Audio
  is rendered even while it reads, for that reason.
- A picker with nothing to pick doesn't appear: the GPU row shows up
  only where the enumeration found more than one adapter, so it is
  absent on a Deck and present on a Bazzite desktop with a dGPU.
- A setting that behaves differently HERE says so in its own
  description rather than being dropped. Capture system shortcuts holds
  nothing back under gamescope; fullscreen-on-stream can't lose to a
  launch that always passes `--fullscreen`; the client's library toggle
  isn't this plugin's browser. Each says which.

The device pickers are real, not stubs: `list_devices` reads
`--list-adapters` and `--list-audio` off the SESSION binary, the same
two enumerations the GTK shell shells out for because it links no Vulkan
itself. It is cached for the life of the backend (that call inits Vulkan
and PipeWire) with an explicit Refresh for the headset you just plugged
in, and a failure — a client too old to ship the session binary — leaves
the pickers on Automatic and says so instead of claiming you have no
devices. `_parse_audio_endpoints` is split out and unit-tested with the
malformed lines that must never reach a picker.

Two smaller honesty fixes fall out of building it. A Dropdown can only
display a value that is one of its options, and this store has four
other writers — so a stored value the table doesn't list is carried as
its own entry rather than rendering blank or, worse, showing a different
value than the stream will use. And a stored audio endpoint that isn't
currently connected keeps a "(not connected)" entry, the way the Linux
picker keeps "(not detected)", instead of silently re-pointing the next
stream at the default.

The Settings tab's wrapper deliberately stops being a scroll area: a
SidebarNavigation given an indefinite height to fill collapses its rail,
so the pane hands it the full height and keeps its hands off the
overflow, and the footer inset moves inside the pages.

The docs claimed nine things about this plugin that are no longer true,
and three about the console home that stopped being true when its own
row set grew on 2026-07-31 (4:4:4, echo cancellation, auto-wake and the
library toggle are all there in `screens/settings.rs`). Both corrected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 00:21:15 +02:00
enricobuehler aa3bcfd0d0 Merge pull request 'feat(client/present): desktop presenter rebuild — intent engine, V-Sync/VRR, display-stat split, settings UI' (#20) from worktree-desktop-presenter into main
apple / swift (push) Successful in 1m18s
arch / build-publish (push) Failing after 1m17s
ci / web (push) Successful in 1m46s
ci / docs-site (push) Successful in 1m30s
deb / build-publish-host (push) Failing after 11s
ci / rust-arm64 (push) Successful in 3m51s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 18s
apple / screenshots (push) Successful in 5m54s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 26s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
deb / build-publish-client-arm64 (push) Successful in 1m4s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m42s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 19s
android / android (push) Successful in 6m54s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m37s
docker / builders-arm64cross (push) Successful in 17s
deb / build-publish (push) Failing after 4m50s
ci / rust (push) Failing after 7m29s
docker / deploy-docs (push) Successful in 36s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m54s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 59s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m3s
flatpak / build-publish (push) Successful in 6m28s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m25s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m21s
Reviewed-on: #20
2026-08-02 22:06:37 +00:00
enricobuehlerandClaude Opus 5 6b3c582eb1 feat(client/present): use the driver's queue-free vblank mode where it exists
apple / swift (pull_request) Successful in 1m14s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m9s
ci / docs-site (pull_request) Successful in 1m30s
ci / rust-arm64 (pull_request) Successful in 2m51s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m3s
android / android (pull_request) Successful in 5m32s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m53s
ci / rust (pull_request) Successful in 7m13s
`VK_PRESENT_MODE_FIFO_LATEST_READY_EXT` is FIFO's tear-free vblank pacing that
presents the LATEST READY image at each refresh and retires the older ones,
instead of draining a queue. That is precisely what the software glass gate
emulates — so where the driver offers it, the driver does the job, and it does
it exactly where the gate matters most: a surface with no MAILBOX gets
newest-wins behaviour back without the app holding frames.

Found by asking the surface what it actually offers rather than trusting a
comment: the previous commit's `surface present modes` line read back
`[MAILBOX, 1000361000, FIFO]` on NVIDIA/Wayland, and 1000361000 is this mode.

The extension postdates the Vulkan headers ash 0.38 is generated from (1.3.281),
so there is no binding — hence the bare number in the log. It is hand-declared
here: mode value, extension name, and
`VkPhysicalDevicePresentModeFifoLatestReadyFeaturesEXT` spliced into the device
pNext chain. One trap worth naming: the SURFACE advertises the mode even with
the extension disabled, and using it on that basis is undefined — so the ladder
only offers it when the device feature actually came back true and we enabled it.

The gate/probe predicate had to split in two, and the distinction is the point:

* `needs_glass_gate()` — FIFO and FIFO_RELAXED only. NOT this mode: gating on
  top of a driver that already retires stale images would hold frames back to
  emulate something the presentation engine is doing, paying the serialisation
  twice, which is the ~27 ms the last commit measured.
* `vblank_locked()` — the whole FIFO family INCLUDING this mode, because it
  still presents on the refresh boundary, so the VRR cadence probe's premise
  ("with VRR off, a present waits for vblank") still holds.

Ranking: MAILBOX first (measured good at 1.4 ms), then LATEST_READY, then plain
FIFO — so a MAILBOX-less surface reaches newest-wins in the driver rather than
in our gate.

MEASURED ON GLASS (.21, NVIDIA 610.43.03, GNOME/Wayland): the extension probe,
feature enable and swapchain creation all succeed with a mode ash has no binding
for. Default ladder selects MAILBOX with `fifo_latest_ready=true`; the VRR ladder
selects `present_mode=1000361000` and measures `display 2.6 ms (pace 0.6 + latch
2.0)` — against 13-28 ms for plain FIFO + gate on the same box. The vblank-locked
path is now MAILBOX-class.

That changes the previous commit's reversal. The VRR ladder was reverted to
opt-in because it led with plain FIFO and cost ~27 ms; led with LATEST_READY it
costs 0.6 ms over MAILBOX. So `allow_vrr` is automatic again WHERE THE DEVICE
OFFERS THE MODE, and stays behind `PUNKTFUNK_VRR_FIFO=1` where it does not — on
those drivers the ladder would fall back to plain FIFO and the regression
returns. Both branches are pinned by tests. This also retires a dead switch: the
"Follow variable refresh rate" row did nothing at all after the reversal, and now
does something real on any driver with the extension.

⚠ Still unverified off this box: whether Windows and Intel drivers expose the
mode at all. Nothing measured here carries over — Windows Vulkan WSI goes through
DXGI, so exposing the enum and mapping it usefully onto flip-model semantics are
separate questions, and Intel is a different vendor stack again. Both facts are
logged unconditionally now (`surface present modes` + `fifo_latest_ready=`), so
one run on any box settles it. The code is safe either way: the mode is only
requested where the device feature enabled, and `allow_vrr` only goes automatic
there — everywhere else the shipped MAILBOX-first behaviour is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 00:01:56 +02:00
enricobuehlerandClaude Opus 5 e08474d96d fix(client/present): log the surface's actual present modes, and document the VRR opt-in
"AMD's Windows driver offers no MAILBOX" is the premise the FIFO glass gate is
built on, and it has been carried in a code comment rather than measured. Present
modes are a property of the (surface, device) pair — they vary by platform
surface, driver version and fullscreen state — so the only way to settle it is to
read them back from real machines. One unconditional log line makes every field
log answer the question.

First reading, .21 (NVIDIA 610.43.03, GNOME/Wayland):
  surface present modes available=[MAILBOX, 1000361000, FIFO]

Two things fall out. No IMMEDIATE and no FIFO_RELAXED on this surface, which is
why a PUNKTFUNK_PRESENT_MODE=immediate run reported mode=fifo — the pin was not
offered and the ladder fell through; previously that looked like a puzzling
result and is now evidence. And 1000361000 is
VK_PRESENT_MODE_FIFO_LATEST_READY_EXT: FIFO's tear-free vblank pacing that
presents the LATEST READY image instead of draining a queue — the driver-native
version of what the glass gate emulates in software, and a candidate to replace
it wherever the driver exposes it (needs VK_EXT_present_mode_fifo_latest_ready
enabled at device creation, so a work package rather than a tweak).

Also documents PUNKTFUNK_VRR_FIFO, which the previous commit introduced without
a docs entry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 00:01:56 +02:00
enricobuehlerandClaude Opus 5 f422ae3e38 fix(client/present): what the first on-glass session found, including a reversed default
WP6 ran against .21 (CachyOS, RTX 5070 Ti, NVIDIA 610.43.03, GNOME/Wayland,
1080p60 HDMI, VRR provably disabled — `org.gnome.mutter experimental-features`
is empty), host and client on the same box, `VK_KHR_present_wait` available.

Five defects that unit tests and both CI gates had passed over:

1. The latch learner and the VRR probe observed NOTHING. Both derived spacings
   with `windows(2)` inside a single batch, but the run loop drains present-wait
   samples every pass, so a batch is normally ONE stamp. `period_us` read back
   exactly the mode fallback — correct by luck on a 60 Hz panel, wrong the moment
   a mode lies, which is the entire reason PanelGrid exists. The tests fed
   40-stamp batches, a shape the live loop never produces. Spacings are now
   measured against the previous stamp across calls.

2. The VRR reference was circular. It compared spacings against the LEARNED
   period, but the grid cannot be learned from our own presents when the stream
   runs below panel rate — we only ever observe multiples ≥ our frame interval,
   so the learner adopts our own cadence and every delta is on-grid by
   construction. It learned 18-22 ms from a 40-50 fps stream and reported VRR on
   a display with VRR off. The reference is now the DISPLAY MODE's period, which
   is the vblank grid presents actually quantize to.

3. The probe is meaningless outside FIFO. MAILBOX deliberately decouples presents
   from scanout, so its stamps are never grid-quantized: same panel, same minute,
   FIFO read `no` (correct, period 16.4 ms) and MAILBOX read `yes` (wrong).
   Outside a FIFO-family mode the honest answer is Unknown, and that is now what
   it reports.

4. Round evaluation was per-CALL rather than per-sample, so the verdict depended
   on how the caller batched its stamps. Closed inside the sample loop now, with
   a test pinning bulk-vs-one-at-a-time equivalence — the same invariant (1)
   violated, in a second place.

5. `force_latency` was dead code without the `pyrowave` feature: a warning in the
   `--no-default-features` build CI actually ships (the Windows ARM64 leg). The
   gate only ever tested default features; it now tests both.

DESIGN REVERSAL — the VRR FIFO-first ladder is opt-in (`PUNKTFUNK_VRR_FIFO=1`),
no longer default. It shipped default-on for `allow_vrr` + fullscreen, which is
the default configuration. Measured A/B, same box, back to back, reproduced
across three runs: FIFO+engine `display 28.4 ms (pace 11.8 + latch 16.6)` versus
MAILBOX `1.4 ms (0.2 + 1.2)`. Under a compositor the FIFO present's on-glass
confirmation arrives a whole refresh later and the presenter serialises behind
it. The VRR upside is real in principle but UNMEASURED — no VRR panel was
available — and a default that is measurably ~27 ms worse on the hardware we
could test, bought against an unproven win on hardware we could not, is the
wrong way round. A test pins the default to MAILBOX; flip it back when a VRR
panel confirms the win.

NOT measured, and not claimed: the FIFO glass gate's own headline. The standing
queue only forms when the stream rate approaches the panel rate, and an idle
GNOME desktop is damage-driven at 40-50 fps on a 60 Hz panel, so `gated`/`forced`
read 0 in every mode and the mechanism never engaged. The 11-13 ms figure is
still the code's inherited documentation, not a fresh measurement. It needs its
actual target: AMD-on-Windows (no MAILBOX, direct scanout) under load.

Rig caveats recorded rather than smoothed over: host and client shared one GPU,
so absolute latencies are contended and run-to-run variance was large, and it
could not be visually confirmed what the physical screen showed. Mode selection,
the fallback ladder, the VRR verdict and the counter plumbing are robust to
that; absolute numbers are not.

Gates: fmt, clippy -D warnings over the five client crates AND the
`--no-default-features` build (added because defect 5 hid there), 160 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 00:01:56 +02:00
enricobuehlerandClaude Opus 5 e38e3c44c9 feat(client/present): V-Sync and VRR become real settings, and VRR is measured
WP3 of design/desktop-presentation-rebuild.md. The `vsync` and `allow_vrr`
settings have existed since WP1 but nothing consumed them — the swapchain picked
MAILBOX-or-FIFO once, from an env var, and froze. This makes them mean
something, which is also what unblocks their settings rows (deliberately
withheld from WP5 rather than shipped as dead switches).

Present-mode selection is now a preference ladder, not a constant:

* V-Sync off — IMMEDIATE, then FIFO_RELAXED, then the tear-free modes. Asking
  to tear and silently getting vsync is a lie, so the mode that actually took is
  named in the stats line and a refused preference is logged requested-vs-active.
* V-Sync on + VRR allowed + fullscreen — FIFO first. On a variable-refresh panel
  with direct scanout the FIFO present IS the flip, so the panel follows the
  stream's cadence instead of a fixed grid; MAILBOX would decouple presents from
  scanout and re-quantize to the compositor's clock. This is only safe because
  WP2's glass gate bounds the standing queue that historically made FIFO costly.
* Otherwise — MAILBOX then FIFO, the shipped default, unchanged.

`PUNKTFUNK_PRESENT_MODE` still pins a mode outright and now falls back to the
settings (rather than to mailbox) when the name is unknown.

VRR detection is MEASURED, never queried. No portable query exists — SDL exposes
none, Wayland does not report adaptive-sync state, Windows surfaces nothing
through Vulkan — and the platforms that do answer have been caught lying (see
the Android per-uid refresh-rate finding). The discriminator is quantization: on
a fixed-refresh panel every on-glass instant lands on the vblank grid, so the
spacing between presents is ~k×period for whole k even when the stream runs
slower than the panel (it just picks a larger k); under real VRR the panel
refreshes when we present, so the spacing follows our own cadence and sits off
the grid. `CadenceProbe` folds each delta to its distance from the nearest
multiple of the learned period and takes the median. Tri-state: it stays Unknown
below 24 deltas and after a display change, so `vrr` is reported only when it
has been measured — never inferred from what the display claims.

Also fixes the read-once refresh rate: `native.refresh_hz` was sampled at
startup and never revisited, so dragging the window to another monitor left a
60 Hz-seeded clock pacing a 144 Hz panel. `WindowEvent::DisplayChanged` now
relearns the latch grid, resets the cadence verdict, and clears the served-slot
latch.

Settings rows for both, on all three surfaces (GTK, WinUI, console). The
console's V-Sync row is reachable in Gaming Mode, which is the only editor a
Deck user has.

Gates: punktfunk-rust-ci linux/amd64 — fmt, clippy -D warnings over
pf-client-core, pf-presenter, pf-console-ui, the session binary and the GTK
client, 160 tests (the two new ones cover every ladder and both cadence
regimes, including the case that matters most: a stream slower than a FIXED
panel must still read as fixed). WinUI leg on the Windows runner .133:
clippy=0 tests=0, against a tree proven by content to contain the edit.

⚠ On-glass validation is still owed and is NOT claimed here: every box with a
real display was powered off when this landed, so the VRR ladder and the
detector have been exercised only against synthetic stamps in unit tests.

Rebase follow-up: `20de58a7` landed the same "panel grid can be wrong in both
directions" defect fix on Android and extracted the corrected learner into
`punktfunk_core::phase::PanelGrid` for the iOS and desktop presenters to share.
This clock had the identical bug — it capped the learned period at the display
mode's refresh, and the mode is only a CLAIM, so a display really running slower
than it advertises pinned a grid whose instants never arrive, for the session,
with no way back. Adopted the shared learner rather than carrying a second,
buggier copy; still fed the window's MIN spacing, which preserves the k×period
resistance the cap was actually aimed at while the streak requirement lets a
genuinely slower panel be discovered. New test: seed 120 Hz, real panel 60 Hz,
the clock must climb back out.

Took the same commit's third lesson too: the adaptive margin widened on a
latch over 1.5×period (a number picked here), and now widens on the latch
exceeding one period plus the lead already applied — the slot actually aimed at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 00:01:56 +02:00
enricobuehlerandClaude Opus 5 b1ac4d02de feat(client/present): the display stat splits, and the intent reaches the settings UI
WP4 + WP5 of design/desktop-presentation-rebuild.md, on top of the WP1/WP2
engine. The engine shipped with no way to choose it and no way to see what it
cost; this closes both.

WP4 — the display stage splits into `pace` (decoded → present-submit, our own
pipeline) + `latch` (submit → on-glass, the presentation queue and the vblank
wait), off the `submitted_ns` stamp WP2 already carried. That split is what
makes a high `display` self-diagnosing: latch dominating is the vsync floor or
a standing queue, pace dominating is us. A `present:` line joins the Detailed
tier naming the live swapchain mode — the answer to most "why is my latch a
whole refresh" questions, since a MAILBOX request silently lands on FIFO
wherever the driver has no mailbox — plus the engine's counters, rendered only
when they are non-zero so a healthy latency session shows just the mode.

Deviation from the plan: the planned `display_adj` twin is NOT here. It was
specified as `display − latch_p50` for parity with the Apple HUD's shaved
figure, but with a real per-sample `pace` percentile that twin is the same
quantity derived worse (subtracting percentiles). `pace` IS the
Apple-comparable number — Apple subtracts its OS present floor, the latch is
ours — and the user docs now say exactly that.

WP5 — Prioritize + Smoothness buffer on all three surfaces: the GTK dialog (a
new Presentation group on the Display page), the WinUI settings page, and the
console settings screen, which is the ONLY editor reachable in Gaming Mode and
so the one that decides whether Deck users can reach this at all. The buffer
control follows the intent the way echo cancellation follows the mic: hidden on
the desktop shells, dimmed and inert on the console, where a row that vanished
mid-list would shift everything under the cursor.

The V-Sync and VRR rows are deliberately NOT here. Their settings exist and are
profile-routed, but the swapchain does not honour them until WP3, and a toggle
that does nothing is exactly how "Full chroma (4:4:4)" shipped inert on desktop
for three releases after being announced.

Buffer labels carry no millisecond hints (Apple/Android derive them from the
session refresh): under a Native mode the shells do not know the refresh at
settings time, so the captions state the cost as one refresh per frame rather
than a confident wrong number.

Docs: the stats page documents the split and the `present:` line, and stops
claiming Linux/Windows measure to the present instant (untrue since
present_wait); client-settings documents both new rows and drops the stale
claim that the desktop 4:4:4 toggle has no effect (it was wired to
VIDEO_CAP_444); configuration documents PUNKTFUNK_PRESENTER and
PUNKTFUNK_PRESENT_DEBUG.

Gates: punktfunk-rust-ci linux/amd64 — fmt, clippy -D warnings over
pf-client-core, pf-presenter, pf-console-ui, the session binary and the GTK
client, 158 tests. The WinUI leg cannot be reached by any Linux or macOS check,
so it was compiled on the Windows runner .133: clippy -D warnings and tests
both exit 0, against a tree proven by content to contain the edit. ⚠ The first
run there reported a false pass — the script printed its done-marker while the
log carried a test failure (a STATUS_DLL_NOT_FOUND launch failure, ffmpeg's
DLLs missing from PATH); the harness now echoes each phase's exit code so the
verdict is a fact in the log rather than an inference from a marker.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 00:01:38 +02:00
enricobuehlerandClaude Opus 5 5f55fa874a feat(client/present): the desktop presenter gains the Apple/Android intent model
WP1+WP2 of design/desktop-presentation-rebuild.md. The shared Linux/Windows
session client presented arrival-paced with no pacing layer at all: two depth-2
newest-wins hops into a drain-to-newest and an immediate present. That IS the
lowest-latency intent, but it was unnamed, unselectable, and had no alternative
— and on a surface without MAILBOX (AMD's Windows driver offers none, and any
compositor holding images does the same) the swapchain's own FIFO becomes a
standing queue worth a measured 11-13 ms at 60 Hz.

WP1 — the settings cluster, under the keys the Apple client already writes into
the shared profile catalog (present_priority / smooth_buffer / vsync /
allow_vrr): mismatched names would ride SettingsOverlay::extra, carried but
never applied. PresentPriority::resolve mirrors the Android reference exactly
(anything but an explicit "smooth" is latency; a buffer outside 1..=3 becomes
2), so a profile authored on any client means the same thing on all of them.
Only the first two are consumed here; vsync/allow_vrr land in WP3.

WP2 — the engine (present_pace.rs, pure state + arithmetic, 6 tests):
- FrameStore: newest-wins slot, or the smoothing FIFO with preroll-to-capacity,
  drop-oldest overflow, and an underflow that re-arms the preroll (repeat by
  omission) — the Apple/Android semantics, with qDrop/qDry counters.
- LatchClock: the panel grid learned from VK_KHR_present_wait glass stamps,
  min positive spacing capped by the mode refresh (measured, never queried —
  VRR and Android's per-uid refresh lie both punish trusting a reported rate).
  It now also publishes the host-facing LatchGrid, so the phase-lock report and
  the local scheduler cannot disagree about the grid.
- PresentGate: one undisplayed present in flight on FIFO surfaces, with the
  100 ms stale force-open. This is the standing-queue killer, and it is inert
  on MAILBOX/IMMEDIATE and without present timing — where behaviour stays
  byte-for-byte the shipped arrival pacing.

Wiring: glass samples drain every pass (a 1 Hz batch would starve clock and
gate) and the waiter pushes an SDL wake, so a gate reopen never waits out the
event timeout; smoothness serves one frame per latch slot and tightens the
loop's wait to that deadline; the adaptive slot margin starts at 0 and widens
+500 us per missed window toward 2.5 ms (a fixed lead was measured to be pure
display tax). PUNKTFUNK_PRESENTER=arrival disables the whole engine for field
A/B without a rebuild.

PyroWave collapses smoothness to latency for the stream: its plane-ring
retirement accounting assumes the depth-2 newest-wins hand-off, and all-intra
frames make buffering moot anyway.

Gates (punktfunk-rust-ci, linux/amd64, sources touched first so a warm target
cannot print a vacuous Finished): clippy -D warnings across pf-client-core,
pf-presenter and punktfunk-client-session; 80 + 32 tests pass; rustfmt clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 00:00:50 +02:00
enricobuehlerandClaude Opus 5 8af6e2dd02 feat(decky): the stats overlay gets an off switch in Gaming Mode
Field report: "as of version 0.23 of this plugin, there is no setting to
toggle off the stat overlay." Correct, and it never had one — no commit in
`clients/decky` has ever touched a stats key. Every other client does:
the GTK dialog, the Windows page, the Apple app, and the console's own
settings screen all carry the four-tier picker.

The tier defaults to on. `Settings::default` is `show_stats: true` and
`stats_verbosity: None`, which `Settings::stats_verbosity` resolves to
Normal — so a Deck that has only ever been configured through this panel
streams with the overlay up and no way here to put it down. What escapes
exist are not discoverable: Ctrl+Alt+Shift+S wants a keyboard, and the
three-finger touchscreen tap is documented in `docs/stats`, not on the
glass. The console's picker is reachable (X on console home), but that is
a different shortcut than the one-tap stream this panel launches, and a
user editing stream settings here has no reason to look there.

So the row lands here, last in the section, matching the console's
wording. It writes `stats_verbosity` AND the legacy `show_stats` in the
same pairing `Settings::set_stats_verbosity` keeps, so a client too old
for the tiers still honours an Off chosen here; it reads them back the
way `Settings::stats_verbosity` does, so a pre-tier file — including
every file this plugin wrote before today — shows the Normal the stream
actually runs at.

`set_settings` stops replacing the file and merges onto it instead. This
JSON is shared with the desktop client and the console, and holds many
more keys than this panel models (decoder, GPU, profiles, touch/mouse
model). The panel reads it once when it mounts, so a wholesale write
posts a snapshot that predates anything another editor stored while it
sat open — silently reverting it. That was invisible until 0.23.0:
`9c5af8d7` fixed the GTK shell handing the session a spec built from
`Settings::default()`, and only since then does this file reach a stream
at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 23:53:57 +02:00
enricobuehlerandClaude Fable 5 d839f4c2b6 fix(client/windows): settings stop going stale behind your back, and the log has a door
ci / web (push) Successful in 1m1s
ci / rust-arm64 (push) Successful in 2m35s
ci / docs-site (push) Successful in 2m35s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 5s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 33s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 4s
deb / build-publish-client-arm64 (push) Successful in 1m16s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
deb / build-publish (push) Successful in 3m52s
apple / swift (push) Successful in 1m18s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
deb / build-publish-host (push) Successful in 4m11s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m28s
ci / rust (push) Successful in 7m1s
android / android (push) Successful in 7m5s
arch / build-publish (push) Successful in 8m17s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m55s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m0s
docker / builders-arm64cross (push) Successful in 8s
apple / screenshots (push) Successful in 5m42s
docker / deploy-docs (push) Successful in 26s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m25s
flatpak / build-publish (push) Canceled after 9m13s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 9m13s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 9m11s
A field reporter's codec setting "changed by itself" between sessions. Nothing writes
the negotiated codec back — what they saw was a stale snapshot. `AppCtx.settings` is
loaded ONCE at process start and the page renders from it, but this process is not the
file's only writer (the spawned session persists its match-window size, the console UI
and Decky save too), so the page showed values another process had already replaced —
until a row was touched and `commit`'s rebase pulled the file in, at which point the
value visibly jumped. The 2026-07-31 rebase fix covered the whole-file writers and
missed two spots: nothing re-based on page ENTRY, and the profile-scope commit arm
cloned the snapshot without reloading, so overlay absorption diffed against stale
globals. Both now re-base on the file.

Two more ways a setting could vanish or cost time:

* An older binary's whole-file save DROPPED a newer client's keys — `Settings` had no
  unknown-key passthrough, unlike `SettingsOverlay`, whose `extra` map already gives
  profiles exactly that contract. Extended to the globals: additive, empty on every
  existing store, and an empty map serializes to nothing so no file churns. (`save()`
  was already temp+rename, so the torn-file → silent-Default reset was closed.)
* "Check the client log" never said WHERE. Settings ▸ About grows an Open log folder
  row (%LOCALAPPDATA%\punktfunk\logs, folder not file so the rotated .old generation
  is in reach), and the failed-spawn banner now names the path.

The 4:4:4 caption said "HEVC only, and only where the host can encode it", which sends
people hunting: the host gate is PyroWave or an NVENC backend. It says so now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 23:49:45 +02:00
enricobuehler 0de161e29b Merge pull request 'feat(clients/input): controllers can stop being forwarded, for couches that hand the pad over another way' (#22) from worktree-gamepad-passthrough-toggle into main
ci / web (push) Successful in 1m10s
apple / swift (push) Successful in 1m22s
ci / docs-site (push) Successful in 2m0s
ci / rust-arm64 (push) Successful in 2m37s
decky / build-publish (push) Successful in 48s
deb / build-publish-client-arm64 (push) Successful in 1m27s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 6s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 5s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 5s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m33s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
deb / build-publish (push) Successful in 4m0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m3s
deb / build-publish-host (push) Successful in 3m51s
docker / builders-arm64cross (push) Successful in 14s
arch / build-publish (push) Failing after 5m22s
docker / deploy-docs (push) Successful in 33s
android / android (push) Successful in 7m22s
ci / rust (push) Failing after 9m12s
flatpak / build-publish (push) Failing after 5m29s
release / apple (push) Successful in 9m14s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 8m12s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m34s
apple / screenshots (push) Canceled after 4m33s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 10m21s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 11m8s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 1m31s
Reviewed-on: #22
2026-08-02 21:38:01 +00:00
enricobuehlerandClaude Opus 5 b297542c4d feat(clients/input): controllers can stop being forwarded, for couches that hand the pad over another way
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m4s
apple / swift (pull_request) Successful in 1m21s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m39s
ci / docs-site (pull_request) Successful in 2m6s
ci / rust-arm64 (pull_request) Successful in 2m50s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m55s
android / android (pull_request) Successful in 3m27s
ci / rust (pull_request) Successful in 7m59s
A controller that reaches the host by USB passthrough — VirtualHere and friends, or simply a
pad plugged into the host — arrived there twice: once as the real device, once as the virtual
pad this client built from the same hands. Games read both, so a stick drifts against the
centred second pad and menus take every input twice.

New per-client setting, "Forward controllers", default on (today's behaviour). It is tier-P,
so a profile can decline what another profile forwards.

On Linux and Windows it is deliberately stronger than "send nothing". Opening a controller is
what CLAIMS it — SDL's HIDAPI drivers take the device node — and a claimed device is one a
passthrough tool cannot bind, so with this off the session opens no slot at all and never
enables the Valve HIDAPI drivers. Menu navigation is untouched: the launcher still opens the
active pad, and a session supersedes menu mode whether it forwards or not, so the pad is free
for the whole time a stream is up. The consequence, documented at both the setting and the
chord: the controller escape chord is read off forwarded pads, so it is unavailable there.

The Apple and Android input stacks claim nothing, so those clients keep their slots and their
chords and only gate the wire sends — losing tvOS's only controller way out of a stream would
have been the worse bug. Android does stop its DualSense and Steam Controller 2 USB captures,
which do claim the device.

Surfaces: GTK, WinUI, the console settings screen, Apple's touch and gamepad settings, the
Android touch and gamepad settings, and Decky (which also hides the rows that now have nothing
to act on). Everywhere the "which pad" and "pad type" rows grey out while it is off.

Verified: cargo clippy --all-targets -D warnings + 79 tests on pf-client-core, pf-console-ui,
punktfunk-client-session and punktfunk-client-linux (linux/amd64 container, gate proven
non-vacuous with a planted error); swift build for the Apple clients; gradle compile + 49 unit
tests for Android (likewise proven); tsc for Decky. clients/windows is UNCOMPILED — both
Windows boxes were offline; its edits were reviewed against the helper signatures by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 22:21:26 +02:00
enricobuehlerandClaude Fable 5 98e040fd01 fix(host/stream): the wire holds the session rate when the display outruns it
apple / swift (push) Successful in 1m20s
ci / rust (push) Successful in 5m0s
android / android (push) Successful in 6m30s
ci / rust-arm64 (push) Successful in 1m50s
apple / screenshots (push) Successful in 5m37s
arch / build-publish (push) Successful in 8m0s
ci / web (push) Successful in 1m28s
ci / docs-site (push) Successful in 1m36s
deb / build-publish-client-arm64 (push) Successful in 2m19s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 18s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 6s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 13s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 16s
windows-host / package (push) Successful in 13m17s
windows-host / winget-source (push) Skipped
deb / build-publish-host (push) Successful in 5m33s
deb / build-publish (push) Successful in 5m39s
docker / builders-arm64cross (push) Successful in 7s
docker / deploy-docs (push) Successful in 24s
windows-host / canary-manifest (push) Successful in 15s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m56s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m18s
PUNKTFUNK_VDISPLAY_HZ_MULT promises extra display refreshes without one
extra frame on the wire, but the frame-driven trigger enforced its pace only
as a per-gap floor: sleep to 0.9×interval, then wake on arrival. A source
that always has a frame pending — the overdriven display under uncapped
content — settled at 0.9-interval spacing, 1.11× the negotiated rate. That
is the field report's 132 fps on a 120 fps session: ten percent more
bitrate, encode and decode for frames a 120 Hz panel can only drop.

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 21:54:09 +02:00
enricobuehlerandClaude Opus 5 c2a6d30d7b fix(android/decode): a codec input slot the feeder can't fill goes back, and so does the AU
deb / build-publish (push) Failing after 2s
deb / build-publish-host (push) Failing after 2s
deb / build-publish-client-arm64 (push) Failing after 3s
apple / swift (push) Successful in 1m19s
ci / rust (push) Successful in 5m52s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 5s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 5s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
arch / build-publish (push) Successful in 9m12s
release / apple (push) Successful in 9m14s
ci / rust-arm64 (push) Successful in 1m57s
android / android (push) Successful in 11m23s
windows-host / package (push) Successful in 11m26s
windows-host / winget-source (push) Skipped
ci / docs-site (push) Failing after 11m59s
ci / web (push) Failing after 12m2s
flatpak / build-publish (push) Successful in 6m25s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m33s
docker / builders-arm64cross (push) Successful in 5s
docker / deploy-docs (push) Successful in 28s
apple / screenshots (push) Successful in 5m54s
windows-host / canary-manifest (push) Successful in 18s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m53s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m1s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m12s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m26s
`AMediaCodec_getInputBuffer` returning null for an index the input-available
callback had just handed us dropped both the slot and the access unit on the
floor. Every sibling path in this loop recycles the slot — the orphan-part
discard and the oversize drop both say so in as many words — because nothing was
written and nothing was queued, so it is still ours. Forgetting it leaks one of
the codec's input buffers per occurrence: we never use it again and the codec
never frees what it never received, so the pipeline runs out of input slots,
`pending_aus` overflows into its drop-oldest arm, and the resulting keyframe storm
reads as a decode fault rather than a bookkeeping one.

The AU went with it, silently — no keyframe request, no freeze gate, unlike every
other loss path here — leaving a hole in the reference chain whose concealment
was free to reach the screen.

Both go back now. `break` rather than `continue`, because a codec that cannot
hand out an input buffer it has just advertised is in no state to be fed the rest
of the parked queue on this pass, and retrying the same index against every
parked AU would burn the whole backlog for nothing; the loop comes round again on
the housekeeping wake within 5 ms if it was transient.

Gates: cargo ndk check green on arm64 and armv7, fmt clean, Android clippy at the
same 4 pre-existing warnings as the base commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 20:15:45 +02:00
enricobuehlerandClaude Opus 5 20de58a78a fix(android/present): the panel grid can be wrong in both directions, and the margin listens to the latch
Three defects in the 0.23.0 timeline presenter, all found while root-causing the
field report that turned out to be the slice wire. None of them is that bug; all
three are real, and the first is the one that would still bite once it is fixed.

The panel-period learner could only ever narrow. It is seeded from the display
mode Kotlin asked for — and `preferredDisplayModeId` is a REQUEST the system may
refuse (Smooth Display off, battery saver, thermal, an OEM governor). Ask for
120 Hz on a panel that stays at 60 and the presenter pins an 8.33 ms grid on a
16.67 ms display with no way back, for the rest of the session: it then aims at
instants that never arrive and releases faster than the panel scans. The learner
moves both ways now, and lives in `punktfunk_core::phase::PanelGrid` where it is
host-testable and where the iOS and desktop presenters can share it. The
asymmetry is kept and made explicit — narrowing is immediate (a finer real grid
is always safe to subdivide onto, and it is the per-uid down-rate case the seed
most often gets wrong), widening needs eight consecutive agreeing observations
and then takes the narrowest of them, because one wide sample is a missed
callback and eight in a row is a display that really did slow down.

The glass budget was a prediction with nothing underneath it. `OnFrameRendered`
already reports what actually reached glass, but the budget never consulted it,
so a wrong grid could hand SurfaceFlinger frames indefinitely: BufferQueue fills,
MediaCodec runs out of output buffers, the decoder stalls, and the no-output
backstop starts begging for keyframes. Releases are now counted against their
confirms and the presenter holds back past six outstanding — loose on purpose,
since the callbacks are allowed to arrive batched and a held frame in the
newest-wins slot is a dropped one. It self-clears when the confirms catch up, and
writes the ledger off after the same 100 ms the stale reopen uses, so a platform
that stops confirming can never wedge the stream. `qWait` and `unconfirmed` join
the 1 Hz pf.present line, which is what would have made this visible from a log.

The adaptive latch margin widened on `paced_drops` — the newest-wins store's own
policy evictions, which happen whenever the stream out-runs the panel and say
nothing about SurfaceFlinger's latch lead. On a healthy device that walked the
margin to its 2.5 ms ceiling and re-imposed the display latency the P2e sweep had
just measured away. It now widens on the measured latch exceeding one panel
period plus the live margin, which is what a missed vsync actually looks like.

Also corrects two doc comments that named `display.refreshRate` as the panel_hz
source; it has been the mode table since the A024 down-rate fix.

Gates: 278 punktfunk-core lib tests (7 new PanelGrid cases incl. the refused-mode
regression), clippy -D warnings and fmt clean, cargo ndk check green on arm64 and
armv7. Android clippy reports the same 4 warnings as the base commit and no new
ones. NOT yet confirmed on glass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 20:15:45 +02:00
enricobuehlerandClaude Opus 5 97b2c01ac1 fix(core/packet): a slice-streamed frame costs its own size, not the whole frame ceiling
The 0.23.0 slice wire flushes a block every MIN_STREAM_BLOCK_SHARDS, so every
ordinary access unit is now opened by a SENTINEL — a header with no totals. The
reassembler sized those frames at `max_frame_bytes`, which the QUIC handshake
clamps to 8-64 MiB. That was survivable while sentinels were rare (the streamed
path emitted one only for an AU exceeding a whole FEC block, ~281 KB); it is not
survivable now that every frame is one.

Two consequences, both measured: each access unit allocated and ZEROED a
multi-megabyte buffer, and the in-flight budget (IN_FLIGHT_BUF_FACTOR x
max_frame_bytes) was spent after ~3 concurrent frames — with production geometry,
12 ordinary AUs in flight lost 9 of them outright, every packet dropped before it
could be placed. On a link with normal reorder that is a permanent loss storm:
frames never complete, the re-anchor gate freezes the picture, and the client begs
for keyframes. Only clients advertising VIDEO_CAP_MULTI_SLICE reach this path —
Android and the Linux/Windows session client; Apple and the Windows in-process
client never did, which is why it read as a platform-specific "video pipeline"
fault in the field.

A sentinel carries no total but does pin its own block's extent: a slice sentinel
by its wire base, a legacy one by its full-K position. Size the buffer to that and
grow as later blocks (or the final block's totals) reveal more. The budget is
re-checked on growth for the same reason it is checked at open.

The same flush also drained `pending` to empty whenever the AU's length was an
exact multiple of the shard payload, leaving `finish_streamed` to seal a final
block of one zero-padded FILLER shard. Its derived base overlapped the block
flushed a moment earlier, retro-validation correctly read that as a lying header,
and the whole AU died — one frame in every 1408 on a 1500-MTU link, ~12 s apart at
120 fps, each costing a freeze and a recovery keyframe. A flush now keeps one
whole shard back, restoring the invariant `StreamedAu::pending` already documented.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 20:15:45 +02:00
enricobuehler 29473d6280 Merge pull request 'fix(client/ios): Escape keeps the pointer captured instead of handing it back to iPadOS' (#19) from worktree-ipad-esc-pointer-relock into main
ci / rust-arm64 (push) Failing after 2s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 24s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 27s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 14s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 34s
ci / docs-site (push) Successful in 1m9s
apple / swift (push) Successful in 1m21s
docker / builders-arm64cross (push) Successful in 45s
ci / rust (push) Successful in 4m0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 5m6s
docker / deploy-docs (push) Failing after 9s
ci / web (push) Successful in 6m50s
release / apple (push) Successful in 9m10s
apple / screenshots (push) Successful in 5m53s
Reviewed-on: #19
2026-08-02 17:27:54 +00:00
enricobuehlerandClaude Opus 5 b6acbd096e fix(host/vdisplay): waking the PC stops failing the first session
android / android (push) Failing after 2s
apple / swift (push) Successful in 1m21s
ci / rust-arm64 (push) Successful in 1m49s
ci / web (push) Failing after 1s
ci / docs-site (push) Successful in 1m32s
ci / rust (push) Successful in 6m38s
apple / screenshots (push) Successful in 5m55s
arch / build-publish (push) Successful in 8m58s
deb / build-publish (push) Successful in 6m38s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3m19s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m9s
deb / build-publish-host (push) Successful in 4m57s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 3m43s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 3m59s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
windows-host / package (push) Successful in 12m4s
windows-host / winget-source (push) Skipped
docker / deploy-docs (push) Successful in 55s
windows-host / canary-manifest (push) Successful in 1m30s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Failing after 10m36s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Failing after 10m55s
deb / build-publish-client-arm64 (push) Failing after 11m12s
docker / builders-arm64cross (push) Skipped
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 11m56s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m27s
A woken Windows host refused every connection with "pf-vdisplay driver
interface not found", on a box where the driver was installed and running.

Resuming re-enters D0 and re-registers the IddCx control interface while
the rest of the resume storm is still going. A client reconnecting a
second after wake lands inside that gap. `ensure_available` probed
exactly ONCE, so it read the gap as a dead driver and answered a device
that was seconds from ready by disabling and re-enabling it — then gave
the interface 4 s to come back, which a contended post-resume PnP does
not meet. The session failed, and the log blamed a missing install.

The recovery also could not tell whether it had recovered anything. It
ran the whole cycle under `SilentlyContinue` and reported
`(Get-PnpDevice).Status` — the DEVICE's status, not the cycle's outcome —
so a disable that was REFUSED left the adapter untouched, started, and
reading `OK`. That is the reporter's `cycled the adapter device …
status=OK` line: a recovery that never happened, announcing success. And
a refusal is the expected case here, not the exotic one:
reset-pf-vdisplay.ps1 stops the host service first precisely because the
host holds the driver's control device open, a step an in-process cycle
structurally cannot take.

- Distinguish a devnode MID-TRANSITION (interface registered, not started
  yet, or the open refused) from one genuinely ABSENT. Wait the first
  out; only the second earns a reload. `Probe` carries the counts.
- Report what the reload DID, not what the device looks like afterwards:
  every failable step is `-ErrorAction Stop` in a `try`, and
  `pnputil /restart-device` is the fallback for the in-use device that
  `Disable-PnpDevice` refuses. Failure paths re-enable, so a half-cycle
  can never strand the adapter DISABLED.
- Give the interface 15 s to arrive after a reload, not 4 — under a 30 s
  hard ceiling so a permanently wedged devnode still fails predictably.
- Serialize recovery: N sessions racing in after a wake perform ONE
  reload, not N interleaved ones. The lock is taken only where no manager
  lock is held, so the order stays one-way.
- Retire the manager's cached control handle when a reload runs, instead
  of letting the next session discover it via a failed IOCTL.
- Surface the real reason. `ensure_available` returns `Result`, so the
  log names how long it waited, whether a reload ran, and how many
  interface instances were seen in what state — the detail that would
  have identified this from the field report's log alone.

`VdisplayDriver::open` now shares the wait (brief, no reload) instead of
carrying a second, drifted copy of it — that path is also reached by
`hw_cursor_capable` mid-handshake, where a reload would be the wrong
trade for one capability bool.

Windows-gated, so verified with scripts/xcheck.sh (check + clippy -D
warnings, --all-targets) and cargo fmt; on-glass wake test still owed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 13:12:54 +02:00
enricobuehlerandClaude Opus 5 d63e913f52 fix(client/ios): Escape keeps the pointer captured instead of handing it back to iPadOS
ci / web (pull_request) Successful in 1m2s
apple / swift (pull_request) Successful in 1m18s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m26s
ci / docs-site (pull_request) Successful in 3m46s
ci / rust (pull_request) Successful in 4m8s
iPadOS releases the scene's pointer lock by itself when Escape is pressed — the platform's
built-in "let me out", mirroring the web Pointer Lock API's default unlock gesture. Nothing in
our code does it: a bare Esc never touches `captured`, and it keeps forwarding to the host as
the game key it is. But the lock going away flips the mouse onto the absolute UIKit path and
un-hides the iPadOS cursor, so pressing Esc for an in-game menu silently cost the capture until
the user clicked into the video to win it back.

Esc is a GAME key in a stream, not a request to hand the pointer back to iPadOS, so an unwanted
drop is now re-requested. `syncPointerLock` arms a short, bounded burst (3 attempts over ~0.6 s,
no restart inside 2 s) whenever the lock is wanted, was previously HELD, and is now gone; the
first attempt re-asserts `prefersPointerLocked`, later ones present a real false→true transition
and re-anchor the PointerLockChain. Every deliberate release (⌘⎋, ⌃⌥⇧Q, the Stream menu,
resigning active) clears `captured` first, so `wantsPointerLock` is already false when their drop
is observed and none of them are fought.

The "previously held" half of the condition keeps a scene that never qualifies (Stage Manager,
Split View) from paying for a lock that isn't coming — there, a first grant is still driven by
the chain engage in setCaptured/viewDidAppear exactly as before.

While a re-lock is in flight the local cursor stays hidden and absolute pointer MOTION stays
muted, so the couple of frames it takes read as "Esc did nothing to my mouse" rather than a
cursor that blinks in and out and a host cursor that teleports to the pointer's absolute
position. Buttons still forward (they carry no position), so a click mid-relock isn't swallowed.
The burst clears itself on give-up, so the cursor can never stay hidden on a lock the system
won't grant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 12:08:31 +02:00
169 changed files with 15242 additions and 3875 deletions
Generated
+32 -32
View File
@@ -947,7 +947,7 @@ dependencies = [
[[package]]
name = "cursor-probe"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"pf-capture",
@@ -1036,7 +1036,7 @@ dependencies = [
[[package]]
name = "display-disturb"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
]
@@ -2221,7 +2221,7 @@ dependencies = [
[[package]]
name = "latency-probe"
version = "0.23.0"
version = "0.24.0"
[[package]]
name = "lazy_static"
@@ -2326,7 +2326,7 @@ dependencies = [
[[package]]
name = "libvpl-sys"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"bindgen",
"cmake",
@@ -2361,7 +2361,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"punktfunk-core",
]
@@ -2850,7 +2850,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pf-capture"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"ashpd",
@@ -2871,7 +2871,7 @@ dependencies = [
[[package]]
name = "pf-client-core"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"ash",
@@ -2897,7 +2897,7 @@ dependencies = [
[[package]]
name = "pf-clipboard"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"ashpd",
@@ -2915,7 +2915,7 @@ dependencies = [
[[package]]
name = "pf-console-ui"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"ash",
@@ -2936,7 +2936,7 @@ dependencies = [
[[package]]
name = "pf-encode"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"ash",
@@ -2960,7 +2960,7 @@ dependencies = [
[[package]]
name = "pf-ffvk"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"ash",
"bindgen",
@@ -2969,7 +2969,7 @@ dependencies = [
[[package]]
name = "pf-frame"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"libc",
@@ -2981,7 +2981,7 @@ dependencies = [
[[package]]
name = "pf-gpu"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"pf-host-config",
@@ -2995,11 +2995,11 @@ dependencies = [
[[package]]
name = "pf-host-config"
version = "0.23.0"
version = "0.24.0"
[[package]]
name = "pf-inject"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3028,14 +3028,14 @@ dependencies = [
[[package]]
name = "pf-paths"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"tracing",
]
[[package]]
name = "pf-presenter"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"ash",
@@ -3050,7 +3050,7 @@ dependencies = [
[[package]]
name = "pf-update"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"serde",
"serde_json",
@@ -3058,7 +3058,7 @@ dependencies = [
[[package]]
name = "pf-update-check"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"base64",
@@ -3070,7 +3070,7 @@ dependencies = [
[[package]]
name = "pf-vdisplay"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3103,7 +3103,7 @@ dependencies = [
[[package]]
name = "pf-win-display"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"pf-paths",
@@ -3115,7 +3115,7 @@ dependencies = [
[[package]]
name = "pf-zerocopy"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"ash",
@@ -3323,7 +3323,7 @@ dependencies = [
[[package]]
name = "punktfunk-cli"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"pf-client-core",
"punktfunk-core",
@@ -3334,7 +3334,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"android_logger",
"jni",
@@ -3350,7 +3350,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-linux"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"async-channel",
@@ -3367,7 +3367,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-session"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"pf-client-core",
@@ -3382,7 +3382,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-windows"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"async-channel",
"ffmpeg-next",
@@ -3402,7 +3402,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"aes-gcm",
"bytes",
@@ -3434,7 +3434,7 @@ dependencies = [
[[package]]
name = "punktfunk-host"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"aes",
"aes-gcm",
@@ -3519,7 +3519,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3533,7 +3533,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"ksni",
@@ -3556,7 +3556,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "pyrowave-sys"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"bindgen",
"cmake",
+1 -1
View File
@@ -53,7 +53,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.23.0"
version = "0.24.0"
edition = "2021"
rust-version = "1.82"
license = "MIT OR Apache-2.0"
+90 -1
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.22.3"
"version": "0.23.0"
},
"paths": {
"/api/v1/clients": {
@@ -2170,6 +2170,51 @@
}
}
},
"/api/v1/plugins/logs": {
"post": {
"tags": [
"plugins"
],
"summary": "Ingest runner log lines",
"description": "The plugin/script runner ships its output here so the console's **Logs** page can show it.\n\nPlugins are not host child processes — the runner is a separate `bun` process that `import()`s\neach plugin in-process — so nothing a plugin logs passes through the host's own `tracing`, and\nbefore this endpoint the console's log page could not show a single plugin line. On Linux the\nfallback was `journalctl --user -u punktfunk-scripting`; on Windows the runner task writes no\nlog file at all, so a failing plugin was diagnosable only by stopping the scheduled task and\nre-running the runner by hand. Both are shell access on the host box, which is exactly what the\nconsole exists to avoid.\n\nLines land in the same ring as the host's own, sharing one `seq` cursor, targeted\n`plugin:<source>` — so `GET /logs` needs no second cursor and the console needs no second poll.",
"operationId": "ingestPluginLogs",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PluginLogBatch"
}
}
},
"required": true
},
"responses": {
"204": {
"description": "Lines ingested"
},
"400": {
"description": "Batch too large",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/plugins/{id}": {
"put": {
"tags": [
@@ -6238,6 +6283,50 @@
"gamestream"
]
},
"PluginLogBatch": {
"type": "object",
"description": "A batch of runner log lines.",
"required": [
"entries"
],
"properties": {
"entries": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PluginLogLine"
}
}
}
},
"PluginLogLine": {
"type": "object",
"description": "One log line produced by the runner or a plugin inside it (`POST /plugins/logs`).",
"required": [
"ts_ms",
"level",
"source",
"msg"
],
"properties": {
"level": {
"type": "string",
"description": "`ERROR` | `WARN` | `INFO` | `DEBUG` | `TRACE`. Anything else is coerced to `INFO`."
},
"msg": {
"type": "string"
},
"source": {
"type": "string",
"description": "Which unit emitted it — a plugin's `definePlugin` name, a package name, or `runner`.\nSurfaced in the console's target column as `plugin:<source>`."
},
"ts_ms": {
"type": "integer",
"format": "int64",
"description": "When the line was produced, unix milliseconds. Kept verbatim — see\n[`crate::log_capture::LogRing::push_remote`].",
"minimum": 0
}
}
},
"PluginRegistration": {
"type": "object",
"description": "Register/renew body for `PUT /plugins/{id}`.",
@@ -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))
@@ -401,8 +471,15 @@ private fun buildSettingsRows(
s.echoCancel,
) { update(s.copy(echoCancel = it)) },
toggle(
"padForward", "Controllers", "Forward controllers",
"Send this device's controllers to the host. Turn it off when your controller " +
"already reaches the host another way — USB passthrough such as VirtualHere — " +
"so games don't see two of them.",
s.gamepadForwarding,
) { update(s.copy(gamepadForwarding = it)) },
choice(
"padType", "Controllers", "Controller type",
"padType", null, "Controller type",
"The virtual pad the host creates — Automatic matches this controller.",
GAMEPAD_OPTIONS, s.gamepad,
) { update(s.copy(gamepad = it)) },
@@ -428,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,
)
}
}
@@ -43,6 +43,7 @@ data class SettingsOverlay(
val mouseMode: MouseMode? = null,
val invertScroll: Boolean? = null,
val gamepad: Int? = null,
val gamepadForwarding: Boolean? = null,
val statsVerbosity: StatsVerbosity? = null,
/**
* Android-only tier-P addition (design §3): the decode pipeline is a device fact everywhere
@@ -76,6 +77,7 @@ data class SettingsOverlay(
mouseMode = mouseMode ?: base.mouseMode,
invertScroll = invertScroll ?: base.invertScroll,
gamepad = gamepad ?: base.gamepad,
gamepadForwarding = gamepadForwarding ?: base.gamepadForwarding,
statsVerbosity = statsVerbosity ?: base.statsVerbosity,
lowLatencyMode = lowLatencyMode ?: base.lowLatencyMode,
presentPriority = presentPriority ?: base.presentPriority,
@@ -110,6 +112,9 @@ data class SettingsOverlay(
mouseMode = if (after.mouseMode != before.mouseMode) after.mouseMode else mouseMode,
invertScroll = if (after.invertScroll != before.invertScroll) after.invertScroll else invertScroll,
gamepad = if (after.gamepad != before.gamepad) after.gamepad else gamepad,
gamepadForwarding =
if (after.gamepadForwarding != before.gamepadForwarding) after.gamepadForwarding
else gamepadForwarding,
statsVerbosity = if (after.statsVerbosity != before.statsVerbosity) after.statsVerbosity else statsVerbosity,
lowLatencyMode = if (after.lowLatencyMode != before.lowLatencyMode) after.lowLatencyMode else lowLatencyMode,
presentPriority = if (after.presentPriority != before.presentPriority) after.presentPriority else presentPriority,
@@ -136,6 +141,7 @@ data class SettingsOverlay(
"mouse_mode" -> copy(mouseMode = null)
"invert_scroll" -> copy(invertScroll = null)
"gamepad" -> copy(gamepad = null)
"gamepad_forwarding" -> copy(gamepadForwarding = null)
"stats_verbosity" -> copy(statsVerbosity = null)
"low_latency_mode" -> copy(lowLatencyMode = null)
"present_priority" -> copy(presentPriority = null)
@@ -159,6 +165,7 @@ data class SettingsOverlay(
if (mouseMode != null) add("mouse_mode")
if (invertScroll != null) add("invert_scroll")
if (gamepad != null) add("gamepad")
if (gamepadForwarding != null) add("gamepad_forwarding")
if (statsVerbosity != null) add("stats_verbosity")
if (lowLatencyMode != null) add("low_latency_mode")
if (presentPriority != null) add("present_priority")
@@ -190,6 +197,7 @@ data class SettingsOverlay(
mouseMode?.let { j.put("mouse_mode", it.storedName) }
invertScroll?.let { j.put("invert_scroll", it) }
gamepad?.let { j.put("gamepad", it) }
gamepadForwarding?.let { j.put("gamepad_forwarding", it) }
statsVerbosity?.let { j.put("stats_verbosity", it.name) }
lowLatencyMode?.let { j.put("low_latency_mode", it) }
presentPriority?.let { j.put("present_priority", it) }
@@ -205,7 +213,8 @@ data class SettingsOverlay(
private val KNOWN = setOf(
"width", "height", "refresh_hz", "bitrate_kbps", "render_scale", "codec",
"hdr_enabled", "compositor", "audio_channels", "mic_enabled", "echo_cancel",
"touch_mode", "mouse_mode", "invert_scroll", "gamepad", "stats_verbosity",
"touch_mode", "mouse_mode", "invert_scroll", "gamepad", "gamepad_forwarding",
"stats_verbosity",
"low_latency_mode", "present_priority", "smooth_buffer",
)
@@ -227,6 +236,7 @@ data class SettingsOverlay(
?.let { n -> MouseMode.entries.firstOrNull { it.storedName == n } },
invertScroll = j.optBooleanOrNull("invert_scroll"),
gamepad = j.optIntOrNull("gamepad"),
gamepadForwarding = j.optBooleanOrNull("gamepad_forwarding"),
statsVerbosity = j.optStringOrNull("stats_verbosity")
?.let { n -> StatsVerbosity.entries.firstOrNull { it.name == n } },
lowLatencyMode = j.optBooleanOrNull("low_latency_mode"),
@@ -34,6 +34,17 @@ data class Settings(
val hdrEnabled: Boolean = true,
val compositor: Int = 0,
val gamepad: Int = 0,
/**
* Forward this device's controllers to the host at all. Default on — that was the
* unconditional behaviour before this became a setting.
*
* Off is for a couch whose controller reaches the host another way: a USB passthrough tool
* (VirtualHere and friends), or a pad simply plugged into the host itself. Leaving it on
* there gives the host two controllers for one pair of hands, and games read both. It also
* stops this device CLAIMING the pad — a device held open is one a passthrough tool can't
* bind — which is why it gates the USB capture paths, not just the wire sends.
*/
val gamepadForwarding: Boolean = true,
/** Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
* can capture; the resolved count drives the decoder + AAudio layout. */
val audioChannels: Int = 2,
@@ -216,6 +227,7 @@ class SettingsStore(context: Context) {
hdrEnabled = prefs.getBoolean(K_HDR, true),
compositor = prefs.getInt(K_COMPOSITOR, 0),
gamepad = prefs.getInt(K_GAMEPAD, 0),
gamepadForwarding = prefs.getBoolean(K_GAMEPAD_FORWARDING, true),
audioChannels = prefs.getInt(K_AUDIO_CH, 2),
codec = prefs.getString(K_CODEC, "auto") ?: "auto",
micEnabled = prefs.getBoolean(K_MIC, false),
@@ -262,6 +274,7 @@ class SettingsStore(context: Context) {
.putBoolean(K_HDR, s.hdrEnabled)
.putInt(K_COMPOSITOR, s.compositor)
.putInt(K_GAMEPAD, s.gamepad)
.putBoolean(K_GAMEPAD_FORWARDING, s.gamepadForwarding)
.putInt(K_AUDIO_CH, s.audioChannels)
.putString(K_CODEC, s.codec)
.putBoolean(K_MIC, s.micEnabled)
@@ -291,6 +304,7 @@ class SettingsStore(context: Context) {
const val K_HDR = "hdr_enabled"
const val K_COMPOSITOR = "compositor"
const val K_GAMEPAD = "gamepad"
const val K_GAMEPAD_FORWARDING = "gamepad_forwarding"
const val K_AUDIO_CH = "audio_channels"
const val K_CODEC = "codec"
const val K_MIC = "mic_enabled"
@@ -818,11 +818,23 @@ private fun AudioSettings(s: Settings, update: (Settings) -> Unit, onMicChange:
@Composable
private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenControllers: () -> Unit) {
SettingsGroup(footer = "Applies from the next session.") {
// The master switch, above everything it governs. Profileable, so it shows in both
// scopes: a "Work" profile can decline to forward what "Game" forwards.
ToggleRow(
title = "Forward controllers",
subtitle = "Send this device's controllers 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 = s.gamepadForwarding,
field = "gamepad_forwarding",
onCheckedChange = { on -> update(s.copy(gamepadForwarding = on)) },
)
SettingDropdown(
label = "Controller type",
options = GAMEPAD_OPTIONS,
selected = s.gamepad,
field = "gamepad",
enabled = s.gamepadForwarding,
caption = "The virtual pad the host creates. Automatic matches your controller; " +
"every connected one is forwarded as its own player.",
) { g -> update(s.copy(gamepad = g)) }
@@ -852,6 +864,7 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
subtitle = "Stream a Steam Controller 2 as-is — Steam on the host drives its " +
"trackpads, gyro and haptics directly",
checked = s.sc2Capture,
enabled = s.gamepadForwarding,
onCheckedChange = { on -> update(s.copy(sc2Capture = on)) },
)
// Same no-vibrator-gate reasoning as the SC2 row: this capture renders feedback on
@@ -861,6 +874,7 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
subtitle = "Drive a USB-connected Sony pad directly — rumble on any phone, " +
"plus adaptive triggers, lightbar and gyro",
checked = s.dsCapture,
enabled = s.gamepadForwarding,
onCheckedChange = { on -> update(s.copy(dsCapture = on)) },
)
}
@@ -1013,6 +1027,7 @@ private fun <T> SettingDropdown(
selected: T,
field: String? = null,
caption: String? = null,
enabled: Boolean = true,
onSelect: (T) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
@@ -1020,18 +1035,25 @@ private fun <T> SettingDropdown(
?: options.firstOrNull()?.second.orEmpty()
Column {
OverrideBadge(field)
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
ExposedDropdownMenuBox(
expanded = expanded && enabled,
onExpandedChange = { if (enabled) expanded = it },
) {
OutlinedTextField(
value = selectedLabel,
onValueChange = {},
readOnly = true,
enabled = enabled,
label = { Text(label) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
.fillMaxWidth(),
)
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
ExposedDropdownMenu(
expanded = expanded && enabled,
onDismissRequest = { expanded = false },
) {
options.forEach { (value, lbl) ->
DropdownMenuItem(
text = { Text(lbl) },
@@ -321,7 +321,9 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
// Multi-controller router: a stable wire pad index per connected controller, per-device axis
// state, Arrival/Remove on hot-plug, and feedback routed back by pad index. Forwards every
// controller (Automatic). Built here, released on dispose.
val router = GamepadRouter(context, handle, initialSettings.gamepad)
val router = GamepadRouter(
context, handle, initialSettings.gamepad, initialSettings.gamepadForwarding,
)
activity?.gamepadRouter = router
// Select+Start+L1+R1 chord leaves the stream — a deliberate quit (signal it so the host skips
// the keep-alive linger), unlike a host-ended / backgrounded drop. The router debounces it
@@ -442,7 +444,11 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
// The menu-time capture (UI navigation) must let go before the stream-mode capture can
// claim the interfaces; it resumes in onDispose once the stream releases them.
activity?.stopSc2MenuNav()
val sc2 = if (initialSettings.sc2Capture) Sc2Capture(context, router) else null
val sc2 = if (initialSettings.sc2Capture && initialSettings.gamepadForwarding) {
Sc2Capture(context, router)
} else {
null
}
var sc2UsbReceiver: BroadcastReceiver? = null
if (sc2 != null) {
feedback.onHidRaw = sc2::onHidRaw
@@ -492,7 +498,11 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
// the automatic fallback. Host feedback routes back through feedback.sink; the claim
// frees the pad's InputDevice slot itself (see DsCapture.startUsb), so the wire index
// hands over deterministically.
val ds = if (initialSettings.dsCapture) DsCapture(context, router) else null
val ds = if (initialSettings.dsCapture && initialSettings.gamepadForwarding) {
DsCapture(context, router)
} else {
null
}
var dsUsbReceiver: BroadcastReceiver? = null
if (ds != null) {
feedback.sink = ds
@@ -33,7 +33,24 @@ import java.util.concurrent.ConcurrentHashMap
* InputManager hot-plug callbacks both land there). [deviceForPad] is read from the feedback poll
* threads, so the slot table is a [ConcurrentHashMap].
*/
class GamepadRouter(context: Context, private val handle: Long, private val setting: Int) {
class GamepadRouter(
context: Context,
private val handle: Long,
private val setting: Int,
/**
* Forward this device's controllers to the host at all (`Settings.gamepadForwarding`,
* default true). Off is for a couch whose controller reaches the host another way — USB
* passthrough such as VirtualHere, or a pad plugged into the host itself — where forwarding
* as well would give the host two pads for one pair of hands.
*
* Off still opens slots and tracks held state; it only stops the wire sends. That is
* deliberate: the exit and mic chords are read off the same slots, and a couch that lost its
* quit shortcut because a forwarding preference was off would be the worse bug. Nothing is
* claimed by keeping a slot — the Android input stack shares controllers — unlike the USB
* capture links, which `StreamScreen` does not start at all while this is off.
*/
private val forwarding: Boolean = true,
) {
/** One forwarded controller: its stable wire pad index, per-device axis state, and held buttons. */
private class Slot(val index: Int, val mapper: Gamepad.AxisMapper) {
@@ -123,7 +140,9 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
*/
private fun slotButton(slot: Slot, bit: Int, down: Boolean, send: Boolean) {
if (down) {
if (send) NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
if (send && forwarding) {
NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
}
val wasHeld = slot.held
slot.held = slot.held or bit
// Full chord now held on this pad → start the hold countdown (idempotent while held).
@@ -136,7 +155,9 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
onMicChord?.invoke()
}
} else {
if (send) NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
if (send && forwarding) {
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
}
slot.held = slot.held and bit.inv()
// A chord button lifted before the hold elapsed → cancel, unless another pad still
// holds the full chord.
@@ -186,7 +207,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
val dev = event.device ?: return false
if (!isForwardable(dev)) return false
val slot = slotFor(dev) ?: return false
slot.mapper.onMotion(event)
if (forwarding) slot.mapper.onMotion(event)
return true
}
@@ -221,24 +242,26 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
/** One axis update ([Gamepad].AXIS_*: stick i16 +y=up / trigger 0..255). On-change only. */
fun axis(id: Int, value: Int) {
if (slot != null) NativeBridge.nativeSendGamepadAxis(handle, id, value, index)
if (slot != null && forwarding) NativeBridge.nativeSendGamepadAxis(handle, id, value, index)
}
/** One raw HID report, forwarded verbatim for the host's as-is virtual pad. */
fun hidReport(buf: java.nio.ByteBuffer, len: Int) {
if (slot != null) NativeBridge.nativeSendPadHidReport(handle, index, buf, len)
if (slot != null && forwarding) NativeBridge.nativeSendPadHidReport(handle, index, buf, len)
}
/** One touchpad contact on the rich plane: [finger] 0/1, x/y normalized 0..65535 in
* SCREEN convention (+y down); `active = false` lifts the finger. On-change only. */
fun touch(finger: Int, active: Boolean, x: Int, y: Int) {
if (slot != null) NativeBridge.nativeSendPadTouch(handle, index, finger, active, x, y)
if (slot != null && forwarding) {
NativeBridge.nativeSendPadTouch(handle, index, finger, active, x, y)
}
}
/** One motion sample on the rich plane (gyro pitch/yaw/roll + accel, raw device i16
* units — the host passes them straight into the virtual pad's report). Per report. */
fun motion(gyro: IntArray, accel: IntArray) {
if (slot != null) {
if (slot != null && forwarding) {
NativeBridge.nativeSendPadMotion(
handle, index,
gyro[0], gyro[1], gyro[2],
@@ -260,7 +283,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
// Synthetic ids live below any real InputDevice id (those are positive), so they can't
// collide and InputDevice.getDevice(id) resolves them to null for the feedback path.
val syntheticId = EXTERNAL_ID_BASE - index
NativeBridge.nativeSendGamepadArrival(handle, pref, index)
if (forwarding) NativeBridge.nativeSendGamepadArrival(handle, pref, index)
slots[syntheticId] = Slot(index, Gamepad.AxisMapper(handle, index))
return ExternalPad(syntheticId, index)
}
@@ -317,7 +340,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
// Automatic resolves the pad's type from its VID/PID; an explicit setting forces every pad
// to that type (a single global choice — matches the handshake's session-default pref).
val pref = if (setting == Gamepad.PREF_AUTO) Gamepad.prefFor(dev) else setting
NativeBridge.nativeSendGamepadArrival(handle, pref, index)
if (forwarding) NativeBridge.nativeSendGamepadArrival(handle, pref, index)
val slot = Slot(index, Gamepad.AxisMapper(handle, index))
slots[dev.id] = slot
return slot
@@ -330,7 +353,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
private fun closeSlot(deviceId: Int) {
val slot = slots.remove(deviceId) ?: return
releaseHeld(slot)
NativeBridge.nativeSendGamepadRemove(handle, slot.index)
if (forwarding) NativeBridge.nativeSendGamepadRemove(handle, slot.index)
// If this pad was mid-exit-chord, its removal may have left no pad holding it — drop the timer.
if (slots.values.none { it.held and EXIT_CHORD == EXIT_CHORD }) disarmExit()
// Release this controller's feedback bindings (close its lights session / cancel rumble).
@@ -342,11 +365,11 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
var bits = slot.held
while (bits != 0) {
val bit = bits and -bits // lowest set bit
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
if (forwarding) NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
bits = bits and bit.inv()
}
slot.held = 0
slot.mapper.reset() // zero sticks/triggers + release the HAT dpad
if (forwarding) slot.mapper.reset() // zero sticks/triggers + release the HAT dpad
}
/** Lowest wire index 0..[MAX_PADS) not held by a slot, or null when full — stable lowest-free keeps indices from shuffling on hot-plug. */
+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;
}
@@ -392,7 +392,7 @@ pub(super) fn run_async(
// even when the choreographer clock is absent.
if let Some(p) = presenter.as_mut() {
let clock = vsync.as_ref().map(|v| v.shared().as_ref());
if p.pump(&codec, clock, &tracker, &stats, now_monotonic_ns()) {
if p.pump(&codec, clock, &tracker, &meter, &stats, now_monotonic_ns()) {
rendered += 1;
}
// The 1 Hz window flush doubles as the phase-lock report tick. v3 sensor: the
@@ -822,8 +822,21 @@ fn feed_ready(
}
}
let Some(dst) = codec.input_buffer(idx) else {
log::warn!("decode: input_buffer({idx}) returned None — dropping AU");
continue;
// Nothing was written and nothing was queued, so BOTH stay ours. Dropping the slot
// here leaked one of the codec's input buffers per occurrence — we forget it and the
// codec never frees what it never received, so the pipeline quietly runs out of input
// slots, `pending_aus` overflows, and the resulting drop storm reads as a decode
// fault. Dropping the AU on top of that punched a hole in the reference chain with no
// keyframe request behind it, unlike every sibling path here.
//
// `break`, not `continue`: a codec that cannot hand out an input buffer it just
// advertised is in no state to be fed the rest of the parked queue this pass, and
// retrying the same index against every parked AU would burn the whole backlog. The
// loop re-runs within the housekeeping wake (≤ 5 ms) if it was transient.
log::warn!("decode: input_buffer({idx}) returned None — retrying next pass");
free_inputs.push_front(idx);
pending_aus.push_front(frame);
break;
};
let au = &frame.data;
if au.len() > dst.len() {
+8 -3
View File
@@ -115,9 +115,14 @@ pub(crate) struct DecodeOptions {
/// The smoothness buffer depth (`smooth_buffer` setting): 0 = automatic (2), else 1..=3.
/// Only meaningful with `present_priority` = smooth.
pub smooth_buffer: i32,
/// The display mode's own refresh rate (Kotlin's `display.refreshRate` at stream start;
/// 0 = unknown) — the latch grid the presenter subdivides onto when the app's choreographer
/// stream is down-rated below the panel (see `vsync.rs`).
/// SEED for the panel's refresh period — the latch grid the presenter subdivides onto when
/// the app's choreographer stream is down-rated below the panel (see `vsync.rs`). Kotlin
/// resolves it from the display mode TABLE (`MainActivity.streamPanelFps`), not
/// `display.refreshRate`, which reports a per-uid override rather than the panel. 0 = unknown.
///
/// ⚠ Only a seed: `preferredDisplayModeId` is a REQUEST the system may refuse, so the mode
/// named here is not necessarily the one the panel ends up in. The measured timeline spacing
/// corrects it in both directions ([`punktfunk_core::phase::PanelGrid`]).
pub panel_hz: i32,
}
+156 -20
View File
@@ -4,10 +4,12 @@
//! * a **newest-wins slot** (or a small smoothing FIFO, by user intent) between decode and
//! release, so a burst coalesces in the app — as an explicit, counted drop — instead of
//! queueing behind the display;
//! * a **glass budget of exactly one**: at most one undisplayed release in flight to
//! SurfaceFlinger, reopened on the clock-predicted latch (with a 100 ms stale force-open as
//! the liveness backstop, mirroring Apple's `PresentGate.staleAfter`). The BufferQueue can
//! hold at most the frame being scanned out plus one — a standing queue is unconstructible;
//! * a **glass budget of one**: at most one undisplayed release in flight to SurfaceFlinger,
//! reopened on the clock-predicted latch (with a 100 ms stale force-open as the liveness
//! backstop, mirroring Apple's `PresentGate.staleAfter`), and bounded underneath by what
//! `OnFrameRendered` actually confirmed reached glass ([`UNDISPLAYED_CAP`]) — because the
//! prediction is only as good as the panel grid behind it, and 0.23.0 shipped a grid that
//! could be wrong in one direction forever;
//! * a **timed release**: `AMediaCodec_releaseOutputBufferAtTime` targeting the platform's own
//! frame timeline (API 33+, via [`super::vsync`]), so the latch phase is deterministic instead
//! of inheriting network + decode jitter. On the 31/32 fallback the release is ASAP —
@@ -20,6 +22,7 @@
use ndk::media::media_codec::MediaCodec;
use std::collections::VecDeque;
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
use std::sync::Mutex;
use std::time::Instant;
@@ -36,9 +39,9 @@ use super::vsync::VsyncShared;
///
/// 2.5 ms: SF's latch runs ~1-2 ms before present on modern devices (its `sfOffset`), and the
/// release itself is a binder call well under a ms. 4 ms measured latch p50 8-10; each ms cut
/// here is a ms off every frame's display stage. If a device misses at this margin the `paced`
/// counter shows it (a miss presents one vsync later, coalescing the next frame) — that is the
/// signal to widen, not stutter.
/// here is a ms off every frame's display stage. A device that misses at the live margin shows it
/// as a measured latch beyond one panel period (see the adaptation in
/// [`Presenter::flush_log`]) — that, not a drop counter, is the signal to widen.
const LATCH_MARGIN_NS: i64 = 2_500_000;
/// `debug.punktfunk.latch_margin_us` (0..=8000 µs): PIN the submit margin for a sweep —
@@ -71,6 +74,26 @@ fn latch_margin_ns() -> Option<i64> {
/// `forced` — reads 0 on healthy systems (Apple's `PresentGate.staleAfter`, same value).
const STALE_REOPEN_NS: i64 = 100_000_000;
/// Releases still unconfirmed by `OnFrameRendered` at which the presenter stops handing
/// SurfaceFlinger more work.
///
/// The reopen above is a PREDICTION off the learned panel grid. A grid finer than the panel
/// (0.23.0 could pin one permanently — see [`punktfunk_core::phase::PanelGrid`]) reopens the
/// budget before the display has consumed anything, and the presenter then releases faster than
/// the panel scans: the BufferQueue fills, MediaCodec runs out of output buffers, the decoder
/// stalls, and the no-output backstop starts begging for keyframes. The render callback is the
/// ground truth about what actually reached glass, so it bounds the prediction.
///
/// Six, not one: the platform is explicitly allowed to deliver these callbacks BATCHED, and this
/// module's own `RENDERED_CAP` note records them trailing a release by a vsync or two — so a
/// healthy device sits at 1-3 outstanding and a tight cap would throttle it for nothing (a held
/// frame in the newest-wins slot is a DROPPED frame the moment a fresher one decodes). This is
/// not a pacing knob; it is the "something is structurally wrong" rail, and a presenter genuinely
/// out-running its display climbs past any fixed cap within a second. If a device's BufferQueue
/// is shallower than this the rail simply never engages and the no-output backstop handles it,
/// exactly as before — best-effort, never worse than not having it.
const UNDISPLAYED_CAP: i32 = 6;
/// Fallback latch-prediction period while the vsync clock is unmeasured/absent: one 120 Hz frame.
const FALLBACK_PERIOD_NS: i64 = 8_333_333;
@@ -121,6 +144,14 @@ struct InFlight {
/// a HUD-off wireless A/B readable from logcat.
pub(super) struct PresentMeter {
inner: Mutex<PresentMeterInner>,
/// Frames released to SurfaceFlinger that `OnFrameRendered` has not yet confirmed reached
/// glass. The presenter's structural rail (see [`UNDISPLAYED_CAP`]) and the pf-present line's
/// queue-depth readout. Lock-free because the release side runs on the decode loop and the
/// confirm side on the codec's callback thread, once per frame each.
undisplayed: AtomicI32,
/// This device delivers render callbacks at all (API ≥ 33 and the platform accepted the
/// registration). Until one arrives, `undisplayed` is meaningless and the rail stays down.
confirms: AtomicBool,
}
struct PresentMeterInner {
@@ -147,11 +178,23 @@ impl PresentMeter {
codec_us: Vec::with_capacity(256),
e2e_us: Vec::with_capacity(256),
}),
undisplayed: AtomicI32::new(0),
confirms: AtomicBool::new(false),
}
}
/// One displayed frame's release→displayed latch, µs. Callback thread; poison-proof.
///
/// Also the glass budget's CONFIRM: this frame left the BufferQueue, so one outstanding
/// release is settled. Clamped at zero — the legacy `arrival` path renders without going
/// through [`Presenter::pump`], so confirms can outnumber counted releases.
pub(super) fn note_latch(&self, latch_us: Option<u64>) {
self.confirms.store(true, Ordering::Relaxed);
let _ = self
.undisplayed
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
Some((v - 1).max(0))
});
let mut g = self
.inner
.lock()
@@ -164,6 +207,26 @@ impl PresentMeter {
}
}
/// One frame handed to SurfaceFlinger, awaiting its confirm. Decode thread.
fn note_released(&self) {
self.undisplayed.fetch_add(1, Ordering::Relaxed);
}
/// Releases still unconfirmed, and whether confirms happen on this device at all.
fn outstanding(&self) -> (i32, bool) {
(
self.undisplayed.load(Ordering::Relaxed),
self.confirms.load(Ordering::Relaxed),
)
}
/// Write off the outstanding releases: the platform stopped confirming (it is allowed to
/// drop callbacks under load) or SurfaceFlinger discarded the buffers without presenting
/// them. Never stall the stream on a ledger we cannot audit.
fn forgive_outstanding(&self) {
self.undisplayed.store(0, Ordering::Relaxed);
}
/// One decoded frame's always-on measurements: the `decode`-stage split (feed =
/// received→queued when a receipt stamp matched; codec = queued→decoded when the queued
/// stamp did) and the capture→decoded end-to-end, µs. Decode thread; poison-proof.
@@ -239,6 +302,13 @@ pub(super) struct Presenter {
no_budget: u64,
forced: u64,
dry: u64,
/// Pump passes that held a frame back because too many earlier releases were still
/// unconfirmed ([`UNDISPLAYED_CAP`]) — reads 0 on a healthy device, and a climbing value is
/// the signature of a presenter out-running its display.
queue_waits: u64,
/// When the unconfirmed-release rail first engaged, so it can be forgiven if the confirms
/// simply stopped coming. `None` while the rail is down.
backed_up_since: Option<i64>,
pace_us: Vec<u64>,
last_flush: Instant,
/// The live submit margin. Starts at 0 (P2e on-glass: SurfaceFlinger latched every
@@ -280,6 +350,8 @@ impl Presenter {
no_budget: 0,
forced: 0,
dry: 0,
queue_waits: 0,
backed_up_since: None,
pace_us: Vec::with_capacity(256),
last_flush: Instant::now(),
margin_ns,
@@ -334,6 +406,7 @@ impl Presenter {
codec: &MediaCodec,
clock: Option<&VsyncShared>,
tracker: &DisplayTracker,
meter: &PresentMeter,
stats: &crate::stats::VideoStats,
now_mono_ns: i64,
) -> bool {
@@ -346,6 +419,10 @@ impl Presenter {
self.inflight = None;
}
}
// The measured rail beneath that prediction (see `UNDISPLAYED_CAP`). Evaluated on every
// pass — frame waiting or not — so its forgiveness timer measures real elapsed time
// rather than how often a frame happened to be ready.
let backlogged = self.unconfirmed_backlog(meter, now_mono_ns);
// Pick the frame this pump may release.
let frame = if self.fifo_capacity == 0 {
self.frames.pop_back() // submit() kept it a single slot; back == the newest
@@ -373,9 +450,12 @@ impl Presenter {
self.frames.pop_front()
};
let Some(frame) = frame else { return false };
if self.inflight.is_some() {
if self.inflight.is_some() || backlogged {
// Budget closed — park it back; a fresher submit replaces it (newest-wins), the next
// vsync tick / loop pass retries the pairing.
if backlogged {
self.queue_waits += 1;
}
self.no_budget += 1;
match self.fifo_capacity {
0 => self.frames.push_back(frame),
@@ -412,6 +492,7 @@ impl Presenter {
released_at_ns: now_mono_ns,
});
self.released += 1;
meter.note_released();
let release_real_ns = now_realtime_ns();
let pace_us = ((release_real_ns - frame.decoded_ns).max(0) / 1000) as u64;
if self.pace_us.len() < 4096 {
@@ -422,6 +503,33 @@ impl Presenter {
true
}
/// Whether SurfaceFlinger is sitting on too many unconfirmed releases to be handed another.
///
/// The predicted reopen is only as good as the panel grid behind it; this is the measured
/// rail underneath it (see [`UNDISPLAYED_CAP`]). It self-clears two ways — the confirms catch
/// up, or [`STALE_REOPEN_NS`] passes with the backlog stuck, which means the ledger itself is
/// unreliable (callbacks dropped under load, or SF discarded the buffers) and is written off
/// rather than allowed to wedge the stream.
fn unconfirmed_backlog(&mut self, meter: &PresentMeter, now_ns: i64) -> bool {
let (outstanding, confirms_live) = meter.outstanding();
if !confirms_live || outstanding < UNDISPLAYED_CAP {
self.backed_up_since = None;
return false;
}
match self.backed_up_since {
Some(t) if now_ns - t > STALE_REOPEN_NS => {
meter.forgive_outstanding();
self.backed_up_since = None;
self.forced += 1;
false
}
_ => {
self.backed_up_since.get_or_insert(now_ns);
true
}
}
}
/// Release every held buffer unrendered — the teardown path, BEFORE `codec.stop()`.
pub(super) fn release_all(&mut self, codec: &MediaCodec) {
while let Some(f) = self.frames.pop_front() {
@@ -434,7 +542,9 @@ impl Presenter {
/// `pf-present` line, so a HUD-off on-device A/B is readable wirelessly:
/// `released` (to glass) / `displays` (OnFrameRendered confirms) / `paced` (policy drops) /
/// `noBudget` (waits on the closed budget) / `forced` (stale force-opens — 0 when healthy) /
/// `qDry` (FIFO underflows) / `pace` (decodedrelease) / `latch` (release→displayed) /
/// `qDry` (FIFO underflows) / `qWait` (pumps held back by unconfirmed releases — 0 when
/// healthy) / `unconfirmed` (releases OnFrameRendered hasn't settled) /
/// `pace` (decoded→release) / `latch` (release→displayed) /
/// `feed`+`codec` (the decode stage split: received→queued hand-off/slot wait + the
/// codec-pure queued→decoded time) / `e2e` (capture→decoded, skew-corrected — the wireless
/// A/B headline) / `vsync` (the measured panel period).
@@ -462,14 +572,15 @@ impl Presenter {
let circ = clock.and_then(|c| {
punktfunk_core::phase::circular_latch(&latch, c.panel_period_ns().max(c.period_ns()))
});
let latch_samples = latch.len();
let (latch_p50, latch_max) = p50_max_ms(latch);
let period_ms = clock.map(|c| c.period_ns() as f64 / 1e6).unwrap_or(0.0);
let panel_ms = clock
.map(|c| c.panel_period_ns() as f64 / 1e6)
.unwrap_or(0.0);
let panel_ns = clock.map(|c| c.panel_period_ns()).unwrap_or(0);
let (outstanding, _) = meter.outstanding();
log::info!(
target: "pf.present",
"released={} displays={} paced={} noBudget={} forced={} qDry={} \
qWait={} unconfirmed={} \
paceMs p50={:.2} max={:.2} latchMs p50={:.2} max={:.2} \
feedMs p50={:.2} max={:.2} codecMs p50={:.2} max={:.2} \
e2eMs p50={:.2} max={:.2} circ={:.2}ms coh={} \
@@ -480,6 +591,8 @@ impl Presenter {
self.no_budget,
self.forced,
self.dry,
self.queue_waits,
outstanding,
pace_p50,
pace_max,
latch_p50,
@@ -493,25 +606,48 @@ impl Presenter {
circ.map(|(m, _)| m as f64 / 1e6).unwrap_or(0.0),
circ.map(|(_, c)| c).unwrap_or(0),
period_ms,
panel_ms,
panel_ns as f64 / 1e6,
);
self.released = 0;
// Margin adaptation: repeated latch misses in one window (a miss presents a vsync
// late and coalesces the next frame into `paced`) mean this device's SF does need
// lead — widen toward the pre-sweep ceiling. One-way by design: a margin that once
// proved necessary is never re-gambled mid-stream (the next stream restarts at 0).
if !self.margin_pinned && self.paced_drops > 2 && self.margin_ns < LATCH_MARGIN_NS {
// Margin adaptation, off the MEASURED latch. A release targets the first grid point past
// `now + margin`, so a frame that makes its vsync is on glass within one panel period of
// that margin; beyond it, SurfaceFlinger wanted more lead and the frame waited out an
// extra refresh. Widen toward the pre-sweep ceiling. One-way by design: a margin that
// once proved necessary is never re-gambled mid-stream (the next stream restarts at 0).
//
// ⚠ NOT `paced_drops`, which 0.23.0 used: those are the newest-wins store's own policy
// evictions — a second frame decoding while one is held — which happen whenever the
// stream out-runs the panel and say nothing at all about SF's latch lead. Driving the
// margin from them widened it to the ceiling on healthy devices, re-imposing the 2.5 ms
// of pure display latency the P2e sweep had just measured away.
let latch_p50_ns = (latch_p50 * 1e6) as i64;
if !self.margin_pinned
&& self.margin_ns < LATCH_MARGIN_NS
&& panel_ns > 0
&& latch_samples >= 8
&& latch_p50_ns > panel_ns + self.margin_ns
{
self.margin_ns = (self.margin_ns + 500_000).min(LATCH_MARGIN_NS);
log::warn!(
"presenter: {} latch misses in 1s — margin widened to {}us",
self.paced_drops,
"presenter: latch p50 {:.2}ms over the {:.2}ms panel period — margin widened to {}us",
latch_p50,
panel_ns as f64 / 1e6,
self.margin_ns / 1_000
);
}
if self.queue_waits > 0 {
log::warn!(
"presenter: {} pump(s) held back — {} release(s) still unconfirmed by \
OnFrameRendered (the display is not keeping up with the release rate)",
self.queue_waits,
outstanding
);
}
self.paced_drops = 0;
self.no_budget = 0;
self.forced = 0;
self.dry = 0;
self.queue_waits = 0;
circ
}
}
+32 -22
View File
@@ -58,8 +58,10 @@ pub(super) struct VsyncShared {
/// video to THIS rate would cap the stream — hence `panel_period_ns` + the subdivision in
/// [`Self::next_target`].
period_ns: AtomicI64,
/// The panel's own refresh period (from the display mode Kotlin resolved at stream start;
/// 0 = unknown). The grid SurfaceFlinger actually latches on.
/// The panel's own refresh period — the grid SurfaceFlinger actually latches on (0 = unknown).
/// Seeded from the display mode Kotlin resolved at stream start and then corrected by
/// measurement; the learner itself is [`punktfunk_core::phase::PanelGrid`], owned by the
/// choreographer thread (see [`CallbackCtx::panel`]) and published here for the decode loop.
panel_period_ns: AtomicI64,
/// Callback count, for the one-shot cadence diagnostic log.
ticks: std::sync::atomic::AtomicU32,
@@ -231,6 +233,11 @@ struct CallbackCtx {
choreographer: *mut c_void,
shared: Arc<VsyncShared>,
on_tick: Box<dyn Fn() + Send>,
/// The panel-period learner. `Cell` rather than an atomic because it is touched from exactly
/// one thread — callbacks only ever fire inside this thread's looper poll (see the struct
/// doc) — and its streak state is nobody else's business; only the settled period is
/// published, to `shared.panel_period_ns`.
panel: std::cell::Cell<punktfunk_core::phase::PanelGrid>,
}
impl CallbackCtx {
@@ -240,22 +247,25 @@ impl CallbackCtx {
.shared
.last_vsync_ns
.swap(frame_time_ns, Ordering::Relaxed);
// Panel-grid learner: timeline spacing is SurfaceFlinger's own grid, and the finest
// spacing ever observed is the panel's true period — trustworthy where the configured
// value is not (under a per-uid frame-rate override, `Display.getRefreshRate` REPORTS
// THE OVERRIDE, observed on-glass: a 120 Hz panel read back as 60 while early timelines
// ran at 8.28 ms). Corrects DOWNWARD only: subdividing onto a finer real grid is always
// valid, widening on a later down-rated window never is.
// Panel-grid learner: timeline spacing is SurfaceFlinger's own grid, and therefore the
// only honest witness to what the panel is doing — the configured mode is not (under a
// per-uid frame-rate override `Display.getRefreshRate` REPORTS THE OVERRIDE, observed
// on-glass: a 120 Hz panel read back as 60 while its timelines ran at 8.28 ms), and
// neither is the mode Kotlin *requested* (`preferredDisplayModeId` is a hint the system
// may refuse). Both directions matter and the asymmetry lives in `PanelGrid`.
if timelines.len() >= 2 {
let spacing = timelines[1].expected_present_ns - timelines[0].expected_present_ns;
if (2_000_000..=42_000_000).contains(&spacing) {
let cur = self.shared.panel_period_ns.load(Ordering::Relaxed);
if cur == 0 || spacing < cur - 200_000 {
self.shared
.panel_period_ns
.store(spacing, Ordering::Relaxed);
}
let mut grid = self.panel.get();
if grid.observe(spacing) {
self.shared
.panel_period_ns
.store(grid.period_ns(), Ordering::Relaxed);
log::info!(
"vsync: panel grid now {:.2}ms",
grid.period_ns() as f64 / 1e6
);
}
self.panel.set(grid);
}
// One-shot cadence diagnostic (3rd tick, once deltas exist): the callback cadence vs the
// panel period is exactly the down-rating question, and this line answers it on-glass.
@@ -372,8 +382,9 @@ pub(super) struct VsyncClock {
impl VsyncClock {
/// Spawn the choreographer thread. `on_tick` fires once per vsync ON THAT THREAD — it must
/// only do something cheap and `Send` (the decode loop passes an event-channel send).
/// `panel_hz` is the display mode's own refresh rate (0 = unknown), the latch grid that
/// [`VsyncShared::next_target`] subdivides onto. `None` when the platform surface is missing
/// `panel_hz` SEEDS the panel-grid learner (0 = unknown) the latch grid that
/// [`VsyncShared::next_target`] subdivides onto. A seed, not a fact: it names the display
/// mode Kotlin *requested*, and the observed timeline spacing is what settles it. `None` when the platform surface is missing
/// (very old device) — the presenter then runs clock-less (ASAP targets, predicted-latch
/// budget).
pub(super) fn start(panel_hz: i32, on_tick: Box<dyn Fn() + Send>) -> Option<VsyncClock> {
@@ -383,11 +394,9 @@ impl VsyncClock {
stop: AtomicBool::new(false),
last_vsync_ns: AtomicI64::new(0),
period_ns: AtomicI64::new(0),
panel_period_ns: AtomicI64::new(if panel_hz > 0 {
1_000_000_000 / panel_hz as i64
} else {
0
}),
panel_period_ns: AtomicI64::new(
punktfunk_core::phase::PanelGrid::seeded(panel_hz).period_ns(),
),
ticks: std::sync::atomic::AtomicU32::new(0),
timelines: Mutex::new(Vec::new()),
});
@@ -408,6 +417,7 @@ impl VsyncClock {
choreographer,
shared: thread_shared,
on_tick,
panel: std::cell::Cell::new(punktfunk_core::phase::PanelGrid::seeded(panel_hz)),
};
ctx.repost();
// The bounded poll doubles as the stop check: no cross-thread wake needed, worst
@@ -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 {
@@ -672,7 +672,11 @@ final class SessionModel: ObservableObject {
// back to the pad it's addressed to (rumble always; lightbar/player-LEDs/adaptive-triggers
// when a pad's virtual device is a DualSense). Same trust gate as audio nothing is
// forwarded during the trust prompt.
let capture = GamepadCapture(connection: conn, manager: .shared)
// `gamepadForwarding` off means the host gets this device's pads from somewhere else
// (USB passthrough, or a pad plugged into the host) capture still runs, and still
// watches for the escape chord, but puts nothing on the wire.
let capture = GamepadCapture(
connection: conn, manager: .shared, forwarding: settings.gamepadForwarding)
// The cross-client escape chord (hold L1+R1+Start+Select 1.5 s) on tvOS the only
// controller way out of a stream (B/Menu is swallowed during sessions; see ContentView).
capture.onDisconnectRequest = { [weak self] in self?.disconnect() }
@@ -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,11 +29,16 @@ 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
@AppStorage(DefaultsKey.compositor) private var compositor = 0
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
@AppStorage(DefaultsKey.gamepadForwarding) private var gamepadForwarding = true
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
@AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
@@ -51,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.
@@ -61,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
@@ -71,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)
@@ -79,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))
@@ -95,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)
@@ -137,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 {
@@ -163,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.
@@ -184,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)
@@ -218,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.
@@ -237,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) }
@@ -323,8 +382,15 @@ struct GamepadSettingsView: View {
+ "speaker setups feeding the game back to the host.",
value: $echoCancel),
toggleRow(
id: "padForward", header: "Controller", icon: "gamecontroller",
label: "Forward controllers",
detail: "Send this device's controllers to the host. Turn it off when your "
+ "controller already reaches the host another way — USB passthrough such "
+ "as VirtualHere — so games don't see two of them.",
value: $gamepadForwarding),
choiceRow(
id: "pad", header: "Controller", icon: "gamecontroller", label: "Use controller",
id: "pad", icon: "gamecontroller", label: "Use controller",
detail: "Which pad is forwarded to the host, as player 1.",
options: controllers, current: gamepads.preferredID
) { gamepads.preferredID = $0 },
@@ -386,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
@@ -122,6 +122,10 @@ enum SettingsFields {
.init(name: "gamepad", key: DefaultsKey.gamepadType,
overlay: \.gamepadType, effective: \.gamepadType)
}
static var gamepadForwarding: SettingsField<Bool> {
.init(name: "gamepad_forwarding", key: DefaultsKey.gamepadForwarding,
overlay: \.gamepadForwarding, effective: \.gamepadForwarding)
}
static var statsVerbosity: SettingsField<String> {
.init(name: "stats_verbosity", key: DefaultsKey.statsVerbosity,
overlay: \.statsVerbosity, effective: \.statsVerbosity)
@@ -181,6 +185,7 @@ extension SettingsView {
base.micEnabled = micEnabled
base.echoCancel = echoCancel
base.gamepadType = gamepadType
base.gamepadForwarding = gamepadForwarding
base.statsVerbosity = statsVerbosityRaw
base.fullscreenWhileStreaming = fullscreenWhileStreaming
base.presentPriority = presentPriority
@@ -641,6 +641,15 @@ extension SettingsView {
@ViewBuilder var controllersSection: some View {
Section {
// The master switch, above everything it governs. Profileable, so it renders in
// both scopes: a "Work" profile can decline to forward what "Game" forwards.
described("Sends controllers connected to this device 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 itself — so games don't see "
+ "two of them.",
field: "gamepad_forwarding") {
Toggle("Forward controllers", isOn: scoped(SettingsFields.gamepadForwarding))
}
// Which physical pad this device forwards, and what its own haptics do, are facts
// about THIS device (tier G) only the virtual pad the host creates is profileable.
if !inProfileScope {
@@ -659,6 +668,7 @@ extension SettingsView {
Text(option.label).tag(option.tag)
}
}
.disabled(!effective.gamepadForwarding)
}
}
described("The virtual pad created on the host. Automatic matches your controller "
@@ -669,6 +679,7 @@ extension SettingsView {
Text(option.label).tag(option.tag)
}
}
.disabled(!effective.gamepadForwarding)
}
#if os(iOS)
// iPhone only in practice: hidden where the device itself can't play haptics (iPad).
@@ -49,6 +49,7 @@ struct SettingsView: View {
@AppStorage(DefaultsKey.renderScale) var renderScale = 1.0
@AppStorage(DefaultsKey.compositor) var compositor = 0
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
@AppStorage(DefaultsKey.gamepadForwarding) var gamepadForwarding = true
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
@AppStorage(DefaultsKey.presentPriority) var presentPriority =
SettingsOptions.presentPriorityDefault
@@ -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
}
}
@@ -98,9 +98,27 @@ public final class GamepadCapture {
/// gameplay can't end it (see ContentView's tvOS session branch).
public var onDisconnectRequest: (() -> Void)?
public init(connection: PunktfunkConnection, manager: GamepadManager) {
/// Forward this device's controllers to the host at all (`Settings.gamepadForwarding`,
/// default true). Off is for a couch whose controller reaches the host another way USB
/// passthrough such as VirtualHere, or a pad plugged into the host itself where
/// forwarding as well would give the host two pads for one pair of hands.
///
/// Off still opens slots and tracks button state; it just sends nothing (see `wire`). That
/// is deliberate, not laziness: the escape chord is read off the same slots, and on tvOS it
/// is the ONLY controller way out of a stream a session that silently lost its exit
/// because a forwarding preference was off would be a worse bug than the one this fixes.
/// Unlike pf-client-core's slots, GameController claims nothing exclusive, so holding one
/// open costs the host nothing and blocks no passthrough tool.
public let forwarding: Bool
/// The connection, or nil while forwarding is off every wire send goes through this, so
/// "don't forward" is one fact in one place rather than a condition at twelve call sites.
private var wire: PunktfunkConnection? { forwarding ? connection : nil }
public init(connection: PunktfunkConnection, manager: GamepadManager, forwarding: Bool = true) {
self.connection = connection
self.manager = manager
self.forwarding = forwarding
}
public func start() {
@@ -205,8 +223,8 @@ public final class GamepadCapture {
// core re-sends it a few times against datagram loss; an older host ignores it and uses
// the session-default kind. Then wake the host pad (pads are created lazily from the first
// event; a DualSense's UHID handshake + initial lightbar write only start then).
connection.send(.gamepadArrival(pref: slot.pref.rawValue, pad: slot.pad))
connection.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: slot.pad))
wire?.send(.gamepadArrival(pref: slot.pref.rawValue, pad: slot.pad))
wire?.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: slot.pad))
sync(slot, ext)
if let tp = Self.touchpad(ext) {
@@ -233,7 +251,7 @@ public final class GamepadCapture {
flush(slot)
// Sent after the flush so the core stamps it with a seq past the zeroing snapshots; the host
// seq-gates it, so a reordered snapshot can't resurrect the removed pad.
connection.send(.gamepadRemove(pad: slot.pad))
wire?.send(.gamepadRemove(pad: slot.pad))
let c = slot.controller
if let ext = c.extendedGamepad {
ext.valueChangedHandler = nil
@@ -275,7 +293,7 @@ public final class GamepadCapture {
let changed = newButtons ^ slot.buttons
if changed != 0 {
for bit in GamepadWire.allButtons where changed & bit != 0 {
connection.send(.gamepadButton(bit, down: newButtons & bit != 0, pad: slot.pad))
wire?.send(.gamepadButton(bit, down: newButtons & bit != 0, pad: slot.pad))
}
slot.buttons = newButtons
}
@@ -288,7 +306,7 @@ public final class GamepadCapture {
Int32(g.rightTrigger.value * 255),
]
for (i, v) in newAxes.enumerated() where v != slot.axes[i] {
connection.send(.gamepadAxis(UInt32(i), value: v, pad: slot.pad))
wire?.send(.gamepadAxis(UInt32(i), value: v, pad: slot.pad))
slot.axes[i] = v
}
updateEscapeChord()
@@ -302,7 +320,7 @@ public final class GamepadCapture {
let bit = GamepadWire.guide
let now = down ? (slot.buttons | bit) : (slot.buttons & ~bit)
guard now != slot.buttons else { return }
connection.send(.gamepadButton(bit, down: down, pad: slot.pad))
wire?.send(.gamepadButton(bit, down: down, pad: slot.pad))
slot.buttons = now
}
@@ -365,13 +383,13 @@ public final class GamepadCapture {
if lifted {
if slot.fingerActive[finger] {
slot.fingerActive[finger] = false
connection.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: false, x: 0, y: 0)
wire?.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: false, x: 0, y: 0)
}
return
}
slot.fingerActive[finger] = true
let w = GamepadWire.touchpad(x: x, y: y)
connection.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: true, x: w.x, y: w.y)
wire?.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: true, x: w.x, y: w.y)
}
private func forwardMotion(_ slot: Slot, _ m: GCMotion) {
@@ -394,7 +412,7 @@ public final class GamepadCapture {
}
let gs = GamepadWire.gyroLSBPerRadS
let as_ = GamepadWire.accelLSBPerG
connection.sendMotion(
wire?.sendMotion(
pad: UInt8(slot.pad),
gyro: (
GamepadWire.motionRaw(Float(m.rotationRate.x), scale: gs),
@@ -432,15 +450,15 @@ public final class GamepadCapture {
/// GamepadRemove (that's `closeSlot`).
private func flush(_ slot: Slot) {
for bit in GamepadWire.allButtons where slot.buttons & bit != 0 {
connection.send(.gamepadButton(bit, down: false, pad: slot.pad))
wire?.send(.gamepadButton(bit, down: false, pad: slot.pad))
}
slot.buttons = 0
for (i, v) in slot.axes.enumerated() where v != 0 {
connection.send(.gamepadAxis(UInt32(i), value: 0, pad: slot.pad))
wire?.send(.gamepadAxis(UInt32(i), value: 0, pad: slot.pad))
slot.axes[i] = 0
}
for (f, active) in slot.fingerActive.enumerated() where active {
connection.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(f), active: false, x: 0, y: 0)
wire?.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(f), active: false, x: 0, y: 0)
slot.fingerActive[f] = false
}
}
@@ -175,6 +175,56 @@ public final class StreamViewController: StreamViewControllerBase {
/// renegotiates the host mode (1:1, no presenter resample). iOS only (iPhone naturally no-ops
/// its fixed full-screen scene; tvOS drives display modes via AVDisplayManager instead).
private var matchFollower: MatchWindowFollower?
// MARK: Escape-drop re-lock
//
// iPadOS releases the pointer lock BY ITSELF when the user presses Escape the platform's
// built-in "let me out", mirroring the web Pointer Lock API's default unlock gesture. Nothing
// in our code does it: a bare Esc never touches `captured`, so it keeps forwarding to the host
// as the game key it is. But the lock going away flips the mouse onto the absolute UIKit path
// and un-hides the iPadOS cursor, so hitting Esc for an in-game menu silently costs the capture
// until the user clicks to win it back. Esc is a GAME key here, not a request to hand the
// 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.
private var pointerLockWasEngaged = false
/// Attempts spent in the current re-lock burst, and when the burst began.
private var pointerRelockAttempt = 0
private var pointerRelockBurstStart: CFTimeInterval = 0
/// True from an unwanted drop until the lock is back (or the burst gives up). While pending,
/// the local cursor stays hidden and absolute pointer MOTION stays muted, so a re-lock that
/// lands a frame or two later is invisible instead of flashing the iPadOS cursor and
/// teleporting the host's to the pointer's absolute position.
private var pointerRelockPending = false
/// Forces `prefersPointerLocked` to report false for one resolve pass, so the escalated attempt
/// presents the system with a genuine falsetrue transition instead of re-asserting a value it
/// already holds. See `requestPointerRelock()`.
private var pointerLockForcedOff = false
/// A burst is 3 attempts, and a burst can't restart inside 2 s. A scene the system will never
/// lock (Stage Manager, Split View) therefore costs three cheap re-resolves and then falls back
/// to today's click-to-recapture, rather than retrying forever.
private static let pointerRelockAttemptLimit = 3
private static let pointerRelockBurstWindow: CFTimeInterval = 2
/// Gap between attempts in a burst long enough for the system to answer the previous
/// re-resolve, short enough that the whole burst fits in ~0.6 s. Must exceed
/// `pointerLockForcedOffHold` so an escalated attempt is back to preferring the lock before the
/// next attempt evaluates.
private static let pointerRelockRetryDelay: TimeInterval = 0.2
/// How long an escalated attempt reports `prefersPointerLocked == false` before flipping back,
/// so the system observes a real transition instead of coalescing the flip away.
private static let pointerLockForcedOffHold: TimeInterval = 0.05
#endif
/// Reads whether the scene's pointer is actually locked right now; nil = state
@@ -260,7 +310,7 @@ public final class StreamViewController: StreamViewControllerBase {
captured && pointerCaptureEnabled && UIDevice.current.userInterfaceIdiom == .pad
}
public override var prefersPointerLocked: Bool { wantsPointerLock }
public override var prefersPointerLocked: Bool { wantsPointerLock && !pointerLockForcedOff }
public override var prefersHomeIndicatorAutoHidden: Bool { true }
// NOTE: we deliberately do NOT override `childViewControllerForPointerLock`. The default
@@ -383,6 +433,11 @@ public final class StreamViewController: StreamViewControllerBase {
// is the exact mirror of the GCMouse handlers, which fire only while locked.
streamView.onPointerMoveAbs = { [weak self] p in
guard let self, self.inputCapture?.gcMouseForwarding == false else { return }
// A re-lock is in flight after an Esc-drop: the absolute path would teleport the host
// cursor to wherever the local pointer sits, undoing the relative aiming we're about to
// resume. Motion only BUTTONS still forward (they carry no position, so a click during
// the couple of frames a re-lock takes must not be swallowed mid-firefight).
guard !self.pointerRelockPending else { return }
self.inputCapture?.sendMouseAbs(
x: p.x, y: p.y, surfaceWidth: p.w, surfaceHeight: p.h)
}
@@ -401,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
@@ -693,6 +773,24 @@ public final class StreamViewController: StreamViewControllerBase {
/// change and capture toggle. Main queue.
private func syncPointerLock() {
let locked = pointerLockEngaged() == true
// Wanted, previously HELD, and now gone is the Esc-drop signature. The "previously held"
// half matters: a lock that was never granted is a scene that doesn't qualify (Stage
// Manager, Split View), and burst-requesting there would hide the cursor for the burst's
// duration to win a lock that isn't coming. A first grant is already driven by the chain
// engage in setCaptured/viewDidAppear.
if locked {
pointerLockWasEngaged = true
pointerRelockPending = false
pointerRelockAttempt = 0
} else if wantsPointerLock, pointerLockWasEngaged {
requestPointerRelock()
} else {
// Capture is gone (or the lock was never ours) settle, and let the next capture
// start from a clean "never held" slate.
if !wantsPointerLock { pointerLockWasEngaged = false }
pointerRelockPending = false
pointerRelockAttempt = 0
}
let useGCMouse = captured && locked
// Lock dropped (or capture ended) while the GCMouse path held a button down: once
// gcMouseForwarding flips false its release handler is gated off, so flush any held
@@ -704,7 +802,83 @@ public final class StreamViewController: StreamViewControllerBase {
pointerInteraction?.invalidate() // re-resolve the hidden/visible cursor for the state
if iosInputDebug {
iosInputLog.debug(
"pointer lock isLocked=\(locked, privacy: .public) captured=\(self.captured, privacy: .public)")
"""
pointer lock isLocked=\(locked, privacy: .public) \
captured=\(self.captured, privacy: .public) \
relockPending=\(self.pointerRelockPending, privacy: .public) \
relockAttempt=\(self.pointerRelockAttempt, privacy: .public)
""")
}
}
/// Ask the system for the lock back after it dropped one we still want (see the Escape-drop
/// note on the state above). Bounded to a short burst; idempotent within it. Main queue.
private func requestPointerRelock() {
// Only a frontmost scene can hold the lock at all. Anywhere else the drop is the system
// saying we don't qualify, not the Esc key re-asking would be noise, and the qualifying
// states (foreground, appearance, reparent) each re-resolve on their own already.
guard view.window?.windowScene?.activationState == .foregroundActive else {
pointerRelockPending = false
return
}
let now = CACurrentMediaTime()
// attempt == 0 is a fresh burst (first drop, or one the settle branch cleared); the window
// is the backstop for the pathological case where a grant is immediately revoked again and
// re-arms us. Even then this stays timer-driven at a few Hz never a spin.
if pointerRelockAttempt == 0 || now - pointerRelockBurstStart > Self.pointerRelockBurstWindow {
pointerRelockBurstStart = now
pointerRelockAttempt = 0
}
guard pointerRelockAttempt < Self.pointerRelockAttemptLimit else {
// Out of budget: fall back to exactly today's behavior the iPadOS cursor comes back
// and a click into the video re-captures. The caller invalidates the interaction, so
// the cursor can never stay hidden on a lock the system won't grant.
pointerRelockPending = false
return
}
pointerRelockAttempt += 1
pointerRelockPending = true
let escalate = pointerRelockAttempt > 1
// Deferred a turn so a whose GC keystroke lands after the system's unlock notification
// has already cleared `captured` then the guard below drops this attempt instead of
// fighting the user's own release.
DispatchQueue.main.async { [weak self] in
guard let self, self.pointerRelockPending else { return }
guard self.wantsPointerLock, self.pointerLockEngaged() != true else {
// The grant landed, or the capture went away under us ( / Q / resign).
// Settle through the one decision point rather than returning with `pending` still
// set that flag hides the cursor, so it must never outlive the burst.
self.syncPointerLock()
return
}
if escalate {
// Re-asserting a value the system already holds didn't take. Present a real
// falsetrue transition instead the documented way to change your mind about the
// lock and re-anchor the chain in case a reparent broke the downward walk to us.
// Held for a beat rather than cleared on the next turn: the system resolves the
// property asynchronously, and a same-turn flip back to true can be coalesced into
// no transition at all. We are already unlocked, so the false pass costs nothing.
self.pointerLockForcedOff = true
self.setNeedsUpdateOfPrefersPointerLocked()
self.updatePointerLockChain()
DispatchQueue.main.asyncAfter(deadline: .now() + Self.pointerLockForcedOffHold) {
[weak self] in
guard let self else { return }
self.pointerLockForcedOff = false
self.setNeedsUpdateOfPrefersPointerLocked()
}
} else {
self.setNeedsUpdateOfPrefersPointerLocked()
}
// A GRANT arrives as a didChange syncPointerLock, which settles the burst and makes
// this retry a no-op. Routed back through syncPointerLock (not straight into another
// requestPointerRelock) so the give-up path re-resolves the cursor through the one
// place that does it.
DispatchQueue.main.asyncAfter(deadline: .now() + Self.pointerRelockRetryDelay) {
[weak self] in
guard let self, self.pointerRelockPending else { return }
self.syncPointerLock()
}
}
}
#endif
@@ -724,7 +898,11 @@ extension StreamViewController: UIPointerInteractionDelegate {
// host renders its own cursor from GCMouse deltas and a visible local one would just
// diverge. When the lock isn't held the cursor stays VISIBLE so the user can aim; the
// pointer is forwarded as an absolute position, both cursors tracking together.
captured && pointerLockEngaged() == true ? .hidden() : nil
// except across an Esc-drop we're actively re-locking (`pointerRelockPending`): staying
// hidden for those couple of frames is what turns the fix into "Esc did nothing to my
// mouse" rather than a cursor that blinks in and out. The burst is bounded and clears
// itself on give-up, so the cursor can never stay hidden on a lock that isn't coming.
captured && (pointerLockEngaged() == true || pointerRelockPending) ? .hidden() : nil
}
}
#endif
@@ -32,6 +32,12 @@ public enum DefaultsKey {
public static let compositor = "punktfunk.compositor"
public static let gamepadType = "punktfunk.gamepadType"
public static let gamepadID = "punktfunk.gamepadID"
/// Forward this device's controllers to the host at all (default true). Off is for a
/// couch whose controller reaches the host another way USB passthrough such as
/// VirtualHere, or a pad plugged into the host where forwarding as well would give the
/// host two pads for one pair of hands. Read at connect: `SessionModel` then never starts
/// `GamepadCapture`, so no slot opens, no arrival is sent and no virtual pad is built.
public static let gamepadForwarding = "punktfunk.gamepadForwarding"
public static let bitrateKbps = "punktfunk.bitrateKbps"
/// Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
/// can capture; the resolved count drives the in-core decode + AVAudioEngine layout.
@@ -34,6 +34,7 @@ public struct EffectiveSettings: Equatable, Sendable {
public var mouseMode = "capture"
public var invertScroll = false
public var gamepadType = 0
public var gamepadForwarding = true
/// A `StatsVerbosity` raw value; the enum lives in PunktfunkKit, which this module can't see.
public var statsVerbosity = "normal"
public var fullscreenWhileStreaming = true
@@ -93,6 +94,7 @@ public struct EffectiveSettings: Equatable, Sendable {
mouseMode = str(DefaultsKey.mouseMode, mouseMode)
invertScroll = bool(DefaultsKey.invertScroll, invertScroll)
gamepadType = int(DefaultsKey.gamepadType, gamepadType)
gamepadForwarding = bool(DefaultsKey.gamepadForwarding, gamepadForwarding)
statsVerbosity = Self.storedStatsVerbosity(defaults)
fullscreenWhileStreaming = bool(
DefaultsKey.fullscreenWhileStreaming, fullscreenWhileStreaming)
@@ -140,6 +142,7 @@ public struct EffectiveSettings: Equatable, Sendable {
if let v = overlay.mouseMode { s.mouseMode = v }
if let v = overlay.invertScroll { s.invertScroll = v }
if let v = overlay.gamepadType { s.gamepadType = v }
if let v = overlay.gamepadForwarding { s.gamepadForwarding = v }
if let v = overlay.statsVerbosity { s.statsVerbosity = v }
if let v = overlay.fullscreenWhileStreaming { s.fullscreenWhileStreaming = v }
if let v = overlay.enable444 { s.enable444 = v }
@@ -110,6 +110,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
public var mouseMode: String?
public var invertScroll: Bool?
public var gamepadType: Int?
public var gamepadForwarding: Bool?
/// A `StatsVerbosity` raw value ("off"/"compact"/"normal"/"detailed") the enum lives in
/// PunktfunkKit, which this module must not depend on.
public var statsVerbosity: String?
@@ -151,6 +152,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
case mouseMode = "mouse_mode"
case invertScroll = "invert_scroll"
case gamepadType = "gamepad"
case gamepadForwarding = "gamepad_forwarding"
case statsVerbosity = "stats_verbosity"
case fullscreenWhileStreaming = "fullscreen_on_stream"
case enable444 = "enable_444"
@@ -184,6 +186,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
mouseMode = str(.mouseMode)
invertScroll = bool(.invertScroll)
gamepadType = int(.gamepadType)
gamepadForwarding = bool(.gamepadForwarding)
statsVerbosity = str(.statsVerbosity)
fullscreenWhileStreaming = bool(.fullscreenWhileStreaming)
enable444 = bool(.enable444)
@@ -219,6 +222,8 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
try c.encodeIfPresent(mouseMode, forKey: AnyKey(Key.mouseMode.rawValue))
try c.encodeIfPresent(invertScroll, forKey: AnyKey(Key.invertScroll.rawValue))
try c.encodeIfPresent(gamepadType, forKey: AnyKey(Key.gamepadType.rawValue))
try c.encodeIfPresent(
gamepadForwarding, forKey: AnyKey(Key.gamepadForwarding.rawValue))
try c.encodeIfPresent(statsVerbosity, forKey: AnyKey(Key.statsVerbosity.rawValue))
try c.encodeIfPresent(
fullscreenWhileStreaming, forKey: AnyKey(Key.fullscreenWhileStreaming.rawValue))
@@ -271,6 +276,7 @@ public enum OverlayField {
case "mouse_mode": overlay.mouseMode = nil
case "invert_scroll": overlay.invertScroll = nil
case "gamepad": overlay.gamepadType = nil
case "gamepad_forwarding": overlay.gamepadForwarding = nil
case "stats_verbosity": overlay.statsVerbosity = nil
case "fullscreen_on_stream": overlay.fullscreenWhileStreaming = nil
case "enable_444": overlay.enable444 = nil
@@ -306,6 +312,7 @@ public enum OverlayField {
case "mouse_mode": return o.mouseMode != nil
case "invert_scroll": return o.invertScroll != nil
case "gamepad": return o.gamepadType != nil
case "gamepad_forwarding": return o.gamepadForwarding != nil
case "stats_verbosity": return o.statsVerbosity != nil
case "fullscreen_on_stream": return o.fullscreenWhileStreaming != nil
case "enable_444": return o.enable444 != nil
@@ -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 -49
View File
@@ -2,42 +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** — resolution / refresh / bitrate / gamepad type / host compositor / mic, written
to the client's config.
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:
@@ -48,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 |
| --- | --- |
@@ -81,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
@@ -89,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` | Stream-settings section; 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 "$@"
+223 -586
View File
@@ -6,33 +6,39 @@ STREAM is NOT launched here — it is launched by the frontend through Steam
(SteamClient.Apps.RunGame on a hidden non-Steam shortcut that points at ``bin/punktfunkrun.sh``),
because gamescope only focuses/fullscreens windows in the process tree Steam launched via
``reaper``. A flatpak spawned from this backend would be invisible/unfocused (gamescope#484).
The backend's jobs are the things Steam can't do:
This backend is a THIN SHELL OVER THE HEADLESS CLI (``punktfunk``, shipped in every package
since v0.22.0), plus the handful of things that are genuinely Steam's business. It used to be
a second client its own mDNS parser, its own host-store editor, its own settings writer
and every one of those was a copy of a rule that already lives in Rust, drifting from it. The
rule now has one home; this file builds argv and maps exit codes.
* **discover()** browse the LAN over mDNS (``avahi-browse``) for ``_punktfunk._udp`` hosts.
* **pair(host, port, pin, name)** run the SPAKE2 PIN ceremony headlessly via the flatpak
client's ``--pair`` mode, capturing the result. Pairing uses the SAME flatpak (so the same
identity store the stream uses), so once paired the stream connects silently.
* **library(host, mgmt_port, fp)** fetch a paired host's game library headlessly via the
flatpak client's ``--library`` mode (mTLS with the client's own identity; TSV on stdout),
so the picker UI can offer games to pin.
* **get_pins() / set_pins()** the pinned-games store (``decky-pinned.json`` next to the
client's config, so pins survive plugin reinstalls), annotated with live pairing state.
* **runner_info()** the absolute path to the launch wrapper + the flatpak app id, handed to
the frontend so it can create/point the Steam shortcut.
* **get_settings() / set_settings()** read/write the flatpak client's stream settings JSON
(resolution / bitrate / gamepad), so the Deck UI configures the stream the client reads.
* **kill_stream()** force-stop a wedged stream (``flatpak kill``).
* **check_update()** report pending updates for BOTH the plugin and the client. The plugin's
comes from the registry's per-channel ``manifest.json`` (the frontend then drives Decky's own
install RPC to apply it); the client's depends on how it was installed — a flatpak is compared
by OSTree commit here, anything else is asked of the client itself
(``punktfunk-client --check-update``, which verifies a signed manifest).
* **update_client()** apply the client update by whichever route that install supports:
``flatpak update --user``, ``punktfunk-client --apply-update`` (the packaged root helper), or
a refusal carrying the command to run by hand.
Thin CLI shells each is build argv, run, parse JSON, map the exit code:
The TXT-record keys parsed (``proto`` / ``fp`` / ``pair`` / ``id`` / ``mgmt``) are defined by
the host advert in ``crates/punktfunk-host/src/discovery.rs``.
* **discover()** ``punktfunk discover --json``: the LAN's hosts, already annotated with
whether this device has them saved and paired.
* **hosts()** ``punktfunk hosts list --probe --json``: the saved hosts with a live,
mDNS-independent reachability probe, and their profile bindings and pinned cards already
resolved against the profile catalog.
* **pair(addr, port, pin, name)** ``punktfunk pair``: the SPAKE2 PIN ceremony.
* **trust_host(addr, port, fp, name)** ``punktfunk hosts add --fp``: step 1 of request
access, and the ONLY write this backend makes to the client's store.
Kept because only a Decky plugin can do them:
* **runner_info()** resolve flatpak vs native and hand the frontend the wrapper path.
* **shortcut_art()** base64 grid/hero/logo + icon path for the Steam shortcut.
* **apply_controller_config()** write the native-touch layout into every Steam account's
configset dir, chowned back to the user (this backend is root; Steam is not).
* **check_update() / update_client()** the plugin's own registry manifest (Decky's install
RPC needs artifact + SHA-256) and the client's update route.
* **kill_stream()** force-stop a wedged client.
What is deliberately NOT here: the stream launch. It goes through Steam
(SteamClient.Apps.RunGame on a non-Steam shortcut pointing at ``bin/punktfunkrun.sh``),
because gamescope only focuses/fullscreens windows in the process tree Steam launched via
``reaper`` a client spawned from this backend would come up invisible and unfocused
(gamescope#484). Settings, add-host-by-address, the library browser and profile editing are
not here either: they are one shortcut away in the client's own console home.
"""
import asyncio
@@ -50,51 +56,11 @@ import decky
# Flatpak application id of the GTK client (packaging/flatpak/io.unom.Punktfunk.yml).
APP_ID = "io.unom.Punktfunk"
# Service type advertised by punktfunk/1 hosts (matches NATIVE_SERVICE in the Rust host).
SERVICE_TYPE = "_punktfunk._udp"
# The flatpak client persists identity / known-hosts / settings under HOME/.config/punktfunk.
# The sandbox HOME resolves to the REAL user home (== DECKY_USER_HOME), NOT the per-app
# ~/.var/app/<APP_ID> dir — verified on-device (`flatpak run … sh -c 'echo $HOME'` prints
# /home/deck, and the manifest's `--filesystem=~/.config/punktfunk` grants exactly that path;
# we also pass HOME=DECKY_USER_HOME into `flatpak run`, see _flatpak_env). Pointing here is what
# lets plugin settings actually reach the client AND lets us read the client's known-hosts to
# tell whether THIS device is already paired with a given host.
def _client_config_dir() -> Path:
return Path(decky.DECKY_USER_HOME) / ".config" / "punktfunk"
def _settings_path() -> Path:
return _client_config_dir() / "client-gtk-settings.json"
def _paired_fingerprints() -> set[str]:
"""Host cert fingerprints (lowercase hex) this client has PIN-paired, from the client's
known-hosts store. Keyed by fingerprint so it survives a host changing IP address."""
try:
data = json.loads((_client_config_dir() / "client-known-hosts.json").read_text())
except (OSError, json.JSONDecodeError):
return set()
hosts = data.get("hosts", []) if isinstance(data, dict) else []
return {
h["fp_hex"].lower()
for h in hosts
if isinstance(h, dict) and h.get("paired") and isinstance(h.get("fp_hex"), str)
}
def _runner_path() -> str:
"""Absolute path to the launch wrapper shipped with the plugin (bin/punktfunkrun.sh)."""
return str(Path(decky.DECKY_PLUGIN_DIR) / "bin" / "punktfunkrun.sh")
def _pins_path() -> Path:
"""The pinned-games store — plugin-owned, but deliberately in the CLIENT's config dir
(like everything else we persist): the plugins dir is root-owned and wiped on
reinstall, while ``~/.config/punktfunk`` survives both."""
return _client_config_dir() / "decky-pinned.json"
# --- Steam Input controller config injection (native touchscreen via the ts_n command) --------
# The Deck's touchscreen only reaches the app as native wl_touch when a Steam Input layout with
# the "Touchscreen Native Support" (controller_action ts_n) command is active for the game. We
@@ -182,39 +148,6 @@ def _upsert_configset_entry(text: str, key: str, source_type: str, source_val: s
return text[:last_close] + block + text[last_close:]
def _parse_library_tsv(stdout: str) -> list[dict]:
"""Parse the flatpak client's ``--library`` output: one ``id\\tstore\\ttitle`` line per
game plus a trailing ``N game(s)`` count line (no tabs it self-skips here). A title
may itself contain tabs, so split at most twice."""
games: list[dict] = []
for line in stdout.splitlines():
parts = line.split("\t", 2)
if len(parts) == 3:
games.append({"id": parts[0], "store": parts[1], "title": parts[2]})
return games
def _classify_library_error(stderr: str) -> str:
"""Map the client's ``library: <LibraryError Display>`` stderr line to a stable error
code for the UI. Substring-matched against the Display strings in
``crates/pf-client-core/src/library.rs`` a wording change degrades to ``client-error``
(generic copy), never a crash."""
s = stderr.lower()
if "didn't recognize this device" in s:
return "not-paired"
if "pinned fingerprint" in s:
return "pin-mismatch"
if "couldn't reach the host" in s:
return "unreachable"
if "management api returned http" in s:
return "http"
if "display" in s or "gtk" in s:
# A flatpak so old it predates --library falls through to GTK init, which fails
# headless from this backend.
return "client-outdated"
return "client-error"
# ----------------------------------------------------------------------------------------
# Self-update check (no Decky store). The plugin is distributed via "Install Plugin from
# URL" pointing at our Gitea generic registry, so the official store never sees it and
@@ -343,6 +276,10 @@ def _flatpak() -> str | None:
# settings in the same ~/.config/punktfunk (the flatpak's sandbox HOME resolves to the real
# home), so nothing else in this file has to care which one answered.
NATIVE_BIN = "punktfunk-client"
# The headless CLI — the door this backend does almost everything through (discover, hosts,
# pair, trust). Shipped beside the GTK client in every package since v0.22.0: /app/bin in the
# flatpak, the same bindir as `punktfunk-client` natively.
CLI_BIN = "punktfunk"
# Prefixes to try when PATH doesn't have it. The Decky backend runs with a minimal PATH, and
# SteamOS's read-only /usr pushes native installs into a sysext or the user's own prefix.
@@ -398,6 +335,109 @@ def _client_argv() -> list[str] | None:
return [native] if native else None
def _cli_argv() -> list[str] | None:
"""The argv PREFIX that runs the headless CLI, or None when no client is installed.
Exactly the shape the old ``_session_argv`` used, pointed at ``punktfunk`` instead: the
flatpak ships both binaries in /app/bin so ``--command=`` picks the other one (**the app id
stays LAST** flatpak treats everything after it as the app's own argv), and a native
install puts the CLI in the same bindir as ``punktfunk-client``, so it is its sibling.
"""
prefix = _client_argv()
if not prefix:
return None
if prefix[0] == _flatpak():
return [*prefix[:-1], f"--command={CLI_BIN}", prefix[-1]]
sibling = Path(prefix[0]).with_name(CLI_BIN)
return [str(sibling)] if sibling.exists() else None
async def _run_cli(args: list[str], timeout: float = 20.0) -> tuple[int, str, str]:
"""Run the headless CLI, returning ``(returncode, stdout, stderr)``. SEPARATE pipes: stdout
is the machine interface (JSON/TSV) and stderr carries the log lines, and merging them would
corrupt every payload. ``(-1, "", "")`` when no client is installed or the call times out.
The same ``_flatpak_env`` repair the client runs needed applies here unchanged Decky's
PyInstaller ``LD_LIBRARY_PATH`` leak breaks the flatpak's libcurl whatever binary inside the
sandbox is being started."""
prefix = _cli_argv()
if not prefix:
return -1, "", ""
proc = None
try:
proc = await asyncio.create_subprocess_exec(
*prefix, *args,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
env=_flatpak_env(),
)
out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout)
rc = proc.returncode if proc.returncode is not None else -1
return (
rc,
(out or b"").decode("utf-8", "replace"),
(err or b"").decode("utf-8", "replace"),
)
except asyncio.TimeoutError:
decky.logger.warning("cli %s timed out", " ".join(args))
if proc:
try:
proc.kill()
except ProcessLookupError:
pass
return -1, "", ""
except Exception: # noqa: BLE001
decky.logger.exception("cli %s failed", " ".join(args))
return -1, "", ""
# The CLI's exit-code contract (clients/cli/src/main.rs): 0 ok, 2 connect failed, 3 trust
# rejected, 4 renderer, 5 could not resolve what was asked for, 6 needs a person. Mapped to the
# stable strings the panel renders, so a reworded message can never change what the UI shows.
_CLI_ERRORS = {
2: "unreachable",
3: "refused",
5: "unresolved",
6: "needs-pairing",
}
def _cli_error(rc: int, stderr: str) -> str:
"""One stable error code for a nonzero CLI exit.
The interesting case is a client too old for the verb we just used. That announces itself
DETERMINISTICALLY exit 5 plus ``unknown command "<verb>"`` on stderr rather than by the
guesswork the GTK headless modes needed, so the panel can say "update the client" with
confidence and offer the button that fixes it."""
if rc == -1:
return "client-unavailable"
if rc == 5 and "unknown command" in stderr:
return "client-outdated"
return _CLI_ERRORS.get(rc, "client-error")
async def _cli_json(args: list[str], timeout: float = 20.0) -> dict:
"""Run the CLI and parse its stdout as JSON. ``{"ok": True, **payload}`` on success, else
``{"ok": False, "error": <code>, "detail": <the CLI's last stderr line>}``.
A zero exit with unparseable stdout is a failure, not an empty result: silently returning
"no hosts" for a broken client is exactly the answer a user cannot debug."""
rc, out, err = await _run_cli(args, timeout=timeout)
if rc == 0:
try:
data = json.loads(out)
if isinstance(data, dict):
# `ok` last: a payload that ever grows its own `ok` key must not be able to
# report failure through the field this layer owns.
return {**data, "ok": True}
except json.JSONDecodeError:
decky.logger.warning("cli %s: unparseable output: %s", args[0], out[:200])
return {"ok": False, "error": "client-error", "detail": "unreadable output"}
code = _cli_error(rc, err)
detail = (err.strip().splitlines() or [f"{args[0]} failed"])[-1]
decky.logger.warning("cli %s failed (rc=%s, %s): %s", args[0], rc, code, detail)
return {"ok": False, "error": code, "detail": detail}
def _client_is_flatpak() -> bool:
"""Is the client this plugin actually drives the FLATPAK one?
@@ -511,59 +551,6 @@ async def _run_client(client_args: list[str], timeout: float = 20.0) -> tuple[in
return -1, "", ""
# The QAM panel and the full page each mount their own hosts view, and Gaming Mode remounts the
# QAM often — every mount calls list_hosts, which spawns a flatpak cold-start plus a reachability
# probe. Cache the last result briefly so back-to-back opens reuse it instead of re-probing; any
# mutation (add/edit/forget/reset/pair) invalidates it so a change shows up immediately.
_HOSTS_TTL_S = 12.0
_hosts_cache: dict = {"at": 0.0, "probed": None, "data": None}
def _invalidate_hosts_cache() -> None:
_hosts_cache["data"] = None
def _read_known_hosts() -> list[dict]:
"""The saved-hosts store read straight off disk — the fallback for a client too old to have
``--list-hosts``. Same file the desktop client owns; `online` is left ``None`` (unknown)
because a direct read has no reachability signal."""
try:
data = json.loads((_client_config_dir() / "client-known-hosts.json").read_text())
except (OSError, json.JSONDecodeError):
return []
hosts = data.get("hosts", []) if isinstance(data, dict) else []
out: list[dict] = []
for h in hosts:
if not isinstance(h, dict) or not h.get("addr"):
continue
out.append({
"name": str(h.get("name") or h.get("addr", "")),
"addr": str(h.get("addr", "")),
"port": int(h.get("port", 9777) or 9777),
"fp_hex": str(h.get("fp_hex", "")),
"paired": bool(h.get("paired", False)),
"mac": h.get("mac") if isinstance(h.get("mac"), list) else [],
"last_used": h.get("last_used"),
"online": None,
})
return out
def _mutation_result(rc: int, err: str, op: str) -> dict:
"""Map a headless host-store mutation's exit status to a UI-stable result. ``rc == -1`` means
the flatpak call never ran (missing/timed out); a nonzero rc from a client that PREDATES the
mode falls through to GTK init and fails headless classified ``client-outdated`` so the UI
can prompt an update instead of showing a cryptic error."""
if rc == 0:
return {"ok": True}
if rc == -1:
return {"ok": False, "error": "client-unavailable"}
code = _classify_library_error(err)
detail = (err.strip().splitlines() or [f"{op} failed"])[-1]
decky.logger.warning("%s failed (rc=%s): %s", op, rc, detail)
return {"ok": False, "error": code, "detail": detail}
def _field_from(text: str, name: str) -> str:
"""Pull ``<name>: value`` out of ``flatpak info`` / ``remote-info`` output (e.g. ``Commit``,
``Origin``)."""
@@ -575,6 +562,18 @@ def _field_from(text: str, name: str) -> str:
return ""
def _looks_outdated(stderr: str) -> bool:
"""Does this stderr have the signature of a client too old for the headless flag it was just
handed? Such a client ignores the unknown flag and falls through to GTK init, which fails
with no display so the give-away is display/GTK noise rather than anything about the flag.
Narrow on purpose: the CLI announces the same condition deterministically (exit 5 plus
``unknown command``, see :func:`_cli_error`), and this heuristic is only still here because
the update check drives the GTK client's ``--check-update``, not the CLI."""
s = stderr.lower()
return "display" in s or "gtk" in s
async def _client_update_state() -> dict:
"""Is a newer commit of the flatpak client available in the remote it tracks? The client is a
**per-user** install (so ``sudo flatpak update``, which is system-scope, never touches it), and
@@ -642,312 +641,90 @@ async def _native_update_state() -> dict:
if rc == -1:
return {}
# A client predating `--check-update` ignores the flag and falls through to GTK init, which
# fails headless — the same signature the other headless modes classify.
code = _classify_library_error(err)
decky.logger.info("native check-update unavailable (rc=%s, %s)", rc, code)
return {"error": code} if code == "client-outdated" else {}
def _split_txt(txt: str) -> list[str]:
"""Split an avahi TXT column into tokens, honouring the ``"key=value"`` quoting."""
tokens: list[str] = []
cur: list[str] = []
in_quote = False
for ch in txt:
if ch == '"':
if in_quote:
tokens.append("".join(cur))
cur = []
in_quote = not in_quote
elif in_quote:
cur.append(ch)
if cur:
tokens.append("".join(cur))
return tokens
def _parse_avahi_browse(stdout: str) -> list[dict]:
"""Parse ``avahi-browse -rpt`` output into a list of host dicts (deduped on the TXT ``id``)."""
out: dict[str, dict] = {}
for raw in stdout.splitlines():
line = raw.strip()
if not line.startswith("="):
continue
parts = line.replace("\\;", "\x00").split(";")
parts = [p.replace("\x00", ";") for p in parts]
if len(parts) < 9:
continue
name = parts[3]
address = parts[7]
port_str = parts[8]
txt = parts[9] if len(parts) > 9 else ""
try:
port = int(port_str)
except ValueError:
port = 0
props: dict[str, str] = {}
for token in _split_txt(txt):
if "=" in token:
k, v = token.split("=", 1)
props[k] = v
if props.get("proto") and not props["proto"].startswith("punktfunk/"):
continue
try:
mgmt = int(props.get("mgmt", ""))
except ValueError:
mgmt = 0 # not advertised (standalone punktfunk1-host) — callers default 47990
entry = {
"name": name,
"host": address,
"port": port,
"pair": props.get("pair", "optional"),
"fp": props.get("fp", ""),
"proto": props.get("proto", ""),
"id": props.get("id", ""),
"mgmt": mgmt,
# OS-identity chain for the host row's icon (e.g. "linux/fedora/bazzite");
# empty on an older host that doesn't advertise it.
"os": props.get("os", ""),
}
key = props.get("id") or f"{address}:{port}"
existing = out.get(key)
# Prefer IPv4 over IPv6 for the user-facing host string.
if existing is None or (":" in existing["host"] and ":" not in address):
out[key] = entry
return list(out.values())
# fails headless — that is the signature, and it is the one thing worth reporting here.
outdated = _looks_outdated(err)
decky.logger.info("native check-update unavailable (rc=%s, outdated=%s)", rc, outdated)
return {"error": "client-outdated"} if outdated else {}
class Plugin:
async def discover(self) -> list[dict]:
"""Browse the LAN for punktfunk/1 hosts. Returns ``[{name, host, port, pair, fp}]``."""
avahi = shutil.which("avahi-browse")
if not avahi:
decky.logger.error("avahi-browse not found; install avahi for host discovery")
return []
try:
proc = await asyncio.create_subprocess_exec(
avahi, "-rpt", SERVICE_TYPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=8.0)
except asyncio.TimeoutError:
proc.kill()
decky.logger.warning("avahi-browse timed out")
return []
except Exception: # noqa: BLE001
decky.logger.exception("avahi-browse failed")
return []
if stderr:
decky.logger.debug("avahi-browse stderr: %s", stderr.decode(errors="replace"))
hosts = _parse_avahi_browse(stdout.decode(errors="replace"))
# Mark which hosts THIS device has already paired (by cert fingerprint), so the UI can
# show "Stream" instead of "Pair" — the mDNS `pair` field is the host's policy, not our
# per-device pairing state.
paired = _paired_fingerprints()
for h in hosts:
fp = h.get("fp") or ""
h["paired"] = bool(fp) and fp.lower() in paired
decky.logger.info("discovered %d punktfunk host(s)", len(hosts))
return hosts
# ---- Thin shells over the headless CLI -------------------------------------------------
#
# Each is "build argv, run, parse JSON, map the exit code". No parsing of the client's data
# files happens here and no trust rule is re-implemented here: this backend exists because
# Decky's frontend cannot spawn processes, not because it knows anything the client doesn't.
async def pair(self, host: str, port: int, pin: str, name: str = "Steam Deck") -> dict:
"""Run the SPAKE2 PIN ceremony headlessly via the flatpak client's ``--pair`` mode.
async def discover(self) -> dict:
"""Browse the LAN for hosts (``punktfunk discover --json``).
The user arms pairing on the HOST (which displays a 4-digit PIN) and enters it here.
On success the flatpak persists the host to its known-hosts as paired, so a later
stream connects silently. Returns ``{ok, fp?, error?}``.
"""
flatpak = _flatpak()
if not flatpak:
return {"ok": False, "error": "flatpak-not-found"}
argv = [
flatpak, "run", "--arch=x86_64", APP_ID,
"--pair", str(pin).strip(),
"--connect", f"{host}:{port}",
"--name", name,
"--host-label", host,
]
decky.logger.info("pairing: %s", " ".join(argv[:6] + ["<pin>", "--connect", f"{host}:{port}"]))
try:
proc = await asyncio.create_subprocess_exec(
*argv,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=_flatpak_env(),
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=100.0)
except asyncio.TimeoutError:
return {"ok": False, "error": "pairing timed out"}
except Exception as exc: # noqa: BLE001
decky.logger.exception("pairing failed to launch")
return {"ok": False, "error": str(exc)}
``{ok: True, hosts: [{name, addr, port, fp, pair, id, mgmt, os, saved, paired}]}``, or
``{ok: False, error}`` ``client-outdated`` when the installed client predates the
verb, which the panel renders as one explanatory row plus the update button.
out = stdout.decode(errors="replace")
err = stderr.decode(errors="replace")
if proc.returncode == 0 and "paired " in out:
The 12 s budget covers a cold flatpak start on top of the CLI's own 3 s browse."""
return await _cli_json(["discover", "--json"], timeout=12.0)
async def hosts(self) -> dict:
"""The saved hosts with a live reachability probe
(``punktfunk hosts list --probe --json``).
``--probe`` asks each host directly rather than waiting for an advert, so a host reached
over a routed network (Tailscale/VPN) reports online instead of looking dead. Profile
bindings and pinned cards come back already resolved against the profile catalog
dangling ids dropped, names attached so the panel renders them without ever opening
``client-profiles.json``."""
return await _cli_json(["hosts", "list", "--probe", "--json"], timeout=30.0)
async def pair(self, addr: str, port: int, pin: str, name: str = "Steam Deck") -> dict:
"""The PIN ceremony (``punktfunk pair <addr:port> --pin N --name LABEL``).
The operator arms pairing on the host, which shows a 4-digit PIN; entering it here
verifies the host end to end and pins its fingerprint, so every later connect is silent.
``{ok: True}``, or ``{ok: False, error}`` where ``refused`` is a wrong PIN or a host
that isn't armed, and ``unreachable`` is a host that never answered.
The budget is generous because the ceremony waits on a person at the other end."""
rc, out, err = await _run_cli(
[
"pair", f"{addr}:{int(port)}",
"--pin", str(pin).strip(),
"--name", name,
],
timeout=100.0,
)
if rc == 0:
fp = ""
for tok in out.split():
if tok.startswith("fp="):
fp = tok[3:]
decky.logger.info("paired %s:%s", host, port)
_invalidate_hosts_cache() # the store gained a paired entry — reflect it next list
for token in out.split():
if token.startswith("fp="):
fp = token[3:]
decky.logger.info("paired %s:%s", addr, port)
return {"ok": True, "fp": fp}
decky.logger.warning("pairing failed (rc=%s): %s", proc.returncode, err.strip() or out.strip())
# Surface the client's own one-line reason (wrong PIN / not armed) to the UI.
reason = (err.strip().splitlines() or out.strip().splitlines() or ["pairing failed"])[-1]
return {"ok": False, "error": reason}
detail = (err.strip().splitlines() or ["pairing failed"])[-1]
decky.logger.warning("pairing failed (rc=%s): %s", rc, detail)
return {"ok": False, "error": _cli_error(rc, err), "detail": detail}
async def wake(self, host: str, port: int = 9777) -> dict:
"""Send a Wake-on-LAN magic packet to a saved host via the flatpak client's headless
``--wake`` mode, so a sleeping host is up by the time the stream ``--connect`` runs.
async def trust_host(self, addr: str, port: int, fp: str, name: str = "") -> dict:
"""Step 1 of request access: save the host with the fingerprint it ADVERTISED
(``punktfunk hosts add <addr:port> --fp <hex> --name <label>``).
The MAC comes from the flatpak client's OWN known-hosts store (learned from the host's
mDNS ``mac`` TXT while it was online) no MAC handling here so this is a no-op if none
has been learned yet. Fire it just before launching a stream; it's fast and best-effort.
Returns ``{ok, error?}`` (``ok: False`` when no MAC is known / flatpak missing).
"""
flatpak = _flatpak()
if not flatpak:
return {"ok": False, "error": "flatpak-not-found"}
argv = [flatpak, "run", "--arch=x86_64", APP_ID, "--wake", f"{host}:{port}"]
decky.logger.info("wake: %s:%s", host, port)
try:
proc = await asyncio.create_subprocess_exec(
*argv,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=_flatpak_env(),
)
_, stderr = await asyncio.wait_for(proc.communicate(), timeout=15.0)
except asyncio.TimeoutError:
return {"ok": False, "error": "wake timed out"}
except Exception as exc: # noqa: BLE001
decky.logger.exception("wake failed to launch")
return {"ok": False, "error": str(exc)}
if proc.returncode == 0:
The record lands pinned but unpaired "trusted" which is exactly what a
discovered-but-unapproved host is. The stream launched right after pins that same
fingerprint, so the 185 s wait for an operator's approval cannot be answered by an
impostor. Idempotent: re-running it with the same fingerprint is a no-op that still
exits 0. A DIFFERENT fingerprint comes back ``refused`` and is never overwritten.
This 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."""
args = ["hosts", "add", f"{addr}:{int(port)}", "--fp", fp.strip()]
if name.strip():
args += ["--name", name.strip()]
rc, _out, err = await _run_cli(args, timeout=20.0)
if rc == 0:
return {"ok": True}
reason = (stderr.decode(errors="replace").strip().splitlines() or
["no MAC known for this host yet"])[-1]
decky.logger.info("wake skipped (rc=%s): %s", proc.returncode, reason)
return {"ok": False, "error": reason}
async def library(self, host: str, mgmt_port: int = 0, fp: str = "") -> dict:
"""Fetch a paired host's game library via the flatpak client's headless
``--library`` mode (the client's own mTLS identity + pinned-fingerprint transport —
no trust logic reimplemented here). ``fp`` is passed through whenever the caller
knows the host's cert fingerprint so an IP change can never degrade the pin to a
TOFU accept. Returns ``{ok, games: [{id, store, title}]}`` or
``{ok: False, error: <code>, detail}`` (codes: ``flatpak-not-found`` / ``timeout`` /
``not-paired`` / ``pin-mismatch`` / ``unreachable`` / ``http`` /
``client-outdated`` / ``client-error``)."""
flatpak = _flatpak()
if not flatpak:
return {"ok": False, "error": "flatpak-not-found", "detail": ""}
target = f"{host}:{int(mgmt_port) or 47990}"
argv = [flatpak, "run", "--arch=x86_64", APP_ID, "--library", target]
if fp:
argv += ["--fp", fp]
decky.logger.info("library: fetching %s", target)
proc = None
try:
# Separate pipes (unlike _flatpak_capture): the TSV comes on stdout, the
# client's one-line error reason on stderr. Cold flatpak start on a Deck can
# take seconds — generous timeout, spinner in the UI.
proc = await asyncio.create_subprocess_exec(
*argv,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=_flatpak_env(),
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=45.0)
except asyncio.TimeoutError:
if proc:
try:
proc.kill()
except ProcessLookupError:
pass
return {"ok": False, "error": "timeout", "detail": ""}
except Exception as exc: # noqa: BLE001
decky.logger.exception("library fetch failed to launch")
return {"ok": False, "error": "client-error", "detail": str(exc)}
err = stderr.decode(errors="replace")
if proc.returncode != 0:
detail = (err.strip().splitlines() or ["library fetch failed"])[-1]
code = _classify_library_error(err)
decky.logger.warning("library fetch failed (%s): %s", code, detail)
return {"ok": False, "error": code, "detail": detail}
games = _parse_library_tsv(stdout.decode(errors="replace"))
decky.logger.info("library: %d game(s) from %s", len(games), target)
return {"ok": True, "games": games}
async def get_pins(self) -> dict:
"""The pinned games, each annotated with the LIVE ``paired`` state of its host (by
cert fingerprint an unpaired-since host renders "pairing required" in the QAM)."""
try:
data = json.loads(_pins_path().read_text())
except (OSError, json.JSONDecodeError):
return {"pins": []}
pins = data.get("pins", []) if isinstance(data, dict) else []
paired = _paired_fingerprints()
out = []
for p in pins:
if not isinstance(p, dict) or not p.get("game_id"):
continue
p = dict(p)
p["paired"] = str(p.get("host_fp", "")).lower() in paired
out.append(p)
return {"pins": out}
async def set_pins(self, pins: list) -> dict:
"""Persist the pinned-games list (the frontend sends the whole list — add, remove,
and address-refresh all funnel through here). Validated + deduped on
``(host_fp, game_id)``; written atomically (tmp + rename) pins are long-lived
user data."""
clean: list[dict] = []
seen: set[tuple[str, str]] = set()
for p in pins if isinstance(pins, list) else []:
if not isinstance(p, dict):
continue
game_id = str(p.get("game_id", ""))
host_fp = str(p.get("host_fp", ""))
if not game_id or not (host_fp or p.get("host")):
continue
key = (host_fp, game_id)
if key in seen:
continue
seen.add(key)
clean.append({
"game_id": game_id,
"title": str(p.get("title", game_id)),
"store": str(p.get("store", "")),
"host_fp": host_fp,
"host_id": str(p.get("host_id", "")),
"host_name": str(p.get("host_name", p.get("host", ""))),
"host": str(p.get("host", "")),
"port": int(p.get("port", 9777) or 9777),
"mgmt": int(p.get("mgmt", 0) or 0),
"added_at": int(p.get("added_at", 0) or 0),
})
try:
d = _client_config_dir()
d.mkdir(parents=True, exist_ok=True)
tmp = _pins_path().with_suffix(".json.tmp")
tmp.write_text(json.dumps({"version": 1, "pins": clean}, indent=2))
os.replace(tmp, _pins_path())
return {"ok": True}
except OSError as exc:
decky.logger.exception("could not write pins")
return {"ok": False, "error": str(exc)}
detail = (err.strip().splitlines() or ["could not save the host"])[-1]
decky.logger.warning("trust_host failed (rc=%s): %s", rc, detail)
return {"ok": False, "error": _cli_error(rc, err), "detail": detail}
async def shortcut_art(self) -> dict:
"""The Steam-shortcut artwork shipped with the plugin (committed under ``assets/``):
@@ -1039,148 +816,8 @@ class Plugin:
"client_bin": prefix[0] if native else "",
}
async def get_settings(self) -> dict:
"""Read the flatpak client's stream settings (resolution/bitrate/gamepad…)."""
try:
return json.loads(_settings_path().read_text())
except (OSError, json.JSONDecodeError):
# The client's own defaults (native display, host-default bitrate, auto pad).
return {
"width": 0, "height": 0, "refresh_hz": 0, "render_scale": 1.0,
"bitrate_kbps": 0, "codec": "auto", "gamepad": "auto", "compositor": "auto",
"inhibit_shortcuts": True, "mic_enabled": False,
}
async def set_settings(self, settings: dict) -> dict:
"""Write the stream settings JSON the (sandboxed) client reads on launch."""
try:
d = _client_config_dir()
d.mkdir(parents=True, exist_ok=True)
_settings_path().write_text(json.dumps(settings, indent=2))
return {"ok": True}
except OSError as exc:
decky.logger.exception("could not write settings")
return {"ok": False, "error": str(exc)}
# ---- Shared known-hosts store (the SAME file the desktop client reads/writes) ----
async def list_hosts(self, probe: bool = True) -> dict:
"""The saved-hosts store as a list — the SAME ``client-known-hosts.json`` the desktop
client owns, so a host added/renamed/paired in either surface shows in both. With
``probe`` each host carries a live ``online`` bool from a mDNS-INDEPENDENT reachability
probe (a Tailscale/VPN host is no longer shown offline just because it doesn't advertise);
``online`` is ``None`` when reachability is unknown. Prefers the client's ``--list-hosts``
mode; falls back to reading the JSON directly when the installed client predates it."""
now = time.monotonic()
cache = _hosts_cache
if (
cache["data"] is not None
and cache["probed"] == bool(probe)
and (now - cache["at"]) < _HOSTS_TTL_S
):
return cache["data"]
args = ["--list-hosts"] + (["--probe"] if probe else [])
rc, out, err = await _run_client(args, timeout=30.0)
result: dict | None = None
if rc == 0:
try:
data = json.loads(out)
hosts = data.get("hosts", []) if isinstance(data, dict) else []
result = {"ok": True, "hosts": hosts, "probed": bool(probe)}
except json.JSONDecodeError:
decky.logger.warning("list-hosts: unparseable output: %s", out[:200])
elif rc != -1:
decky.logger.info(
"list-hosts unavailable (%s); reading store directly",
_classify_library_error(err),
)
if result is None:
# Fallback: read the store off disk (old client / no --list-hosts) — no reachability.
result = {"ok": True, "hosts": _read_known_hosts(), "probed": False, "fallback": True}
cache.update(at=now, probed=bool(probe), data=result)
return result
async def add_host(self, target: str, name: str = "", fp: str = "") -> dict:
"""Save a host by address so it can be paired/streamed even when mDNS never sees it (a
Tailscale/VPN box). Without ``fp`` it's an unpaired placeholder the user pairs next; a
later pair replaces it with the fingerprinted entry. Returns ``{ok, error?, detail?}``."""
args = ["--add-host", target.strip()]
if name.strip():
args += ["--host-label", name.strip()]
if fp.strip():
args += ["--fp", fp.strip()]
rc, _out, err = await _run_client(args, timeout=20.0)
_invalidate_hosts_cache()
return _mutation_result(rc, err, "add-host")
async def edit_host(
self, selector: str, name: str = "", addr: str = "", port: int = 0
) -> dict:
"""Edit a saved host — rename and/or re-point its address. ``selector`` is the host's
cert fingerprint (survives IP changes) or its current ``addr[:port]``. Empty fields are
left untouched. Returns ``{ok, error?, detail?}``."""
args = ["--set-host", selector]
if name.strip():
args += ["--host-label", name.strip()]
if addr.strip():
args += ["--addr", addr.strip()]
if port:
args += ["--port", str(int(port))]
rc, _out, err = await _run_client(args, timeout=20.0)
_invalidate_hosts_cache()
return _mutation_result(rc, err, "set-host")
async def forget_host(self, selector: str) -> dict:
"""Remove a saved host (by fingerprint or ``addr[:port]``) — drops the pinned
fingerprint, so a later connect must re-pair/trust. Idempotent."""
rc, _out, err = await _run_client(["--forget-host", selector], timeout=20.0)
_invalidate_hosts_cache()
return _mutation_result(rc, err, "forget-host")
async def reset_config(self) -> dict:
"""Reset this device's Punktfunk state: saved hosts, stream settings, and the plugin's
pinned games. The client's persistent IDENTITY (client-cert/key.pem) is KEPT so the box
isn't seen as brand-new everywhere (re-pairing re-adds hosts). Prefers the client's
``--reset``; if that's unavailable (old client / no flatpak) it clears the shared JSON
stores directly. The plugin-owned pins file is always cleared here (``--reset`` never
touches it)."""
rc, _out, _err = await _run_client(["--reset"], timeout=20.0)
_invalidate_hosts_cache()
errors: list[str] = []
if rc != 0:
decky.logger.info("reset: --reset unavailable (rc=%s); clearing stores directly", rc)
for name in ("client-known-hosts.json", "client-gtk-settings.json"):
try:
(_client_config_dir() / name).unlink()
except FileNotFoundError:
pass
except OSError as exc:
errors.append(f"{name}: {exc}")
try:
_pins_path().unlink() # plugin-owned; the client's --reset leaves it alone
except FileNotFoundError:
pass
except OSError as exc:
errors.append(f"pins: {exc}")
if errors:
return {"ok": False, "error": "; ".join(errors)}
return {"ok": True}
async def probe_host(self, target: str) -> dict:
"""Reachability of one ``host[:port]`` via the client's mDNS-independent QUIC probe —
for a "test this address" check. ``{ok: True, online: bool}`` when determined, else
``{ok: False, error}`` (flatpak missing / client too old)."""
rc, _out, err = await _run_client(["--reachable", target.strip()], timeout=8.0)
if rc == 0:
return {"ok": True, "online": True}
if rc == 1:
return {"ok": True, "online": False}
return {
"ok": False,
"error": _classify_library_error(err) if err.strip() else "client-unavailable",
}
async def kill_stream(self) -> dict:
"""Force-stop a wedged stream client — ``flatpak kill`` for the sandboxed one, a plain
SIGTERM by name for a native install (which has no flatpak instance to kill)."""
+129 -103
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,109 +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}]}'
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")])
# ---- _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",
)
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)
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")
# ---- _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"
)
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 -140
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,26 +100,6 @@ export interface RunnerInfo {
client_bin?: string;
}
// The slice of the flatpak client's settings JSON this UI surfaces. The file can hold more
// keys (decoder, … set from the desktop client's own UI) — they round-trip untouched
// because get_settings returns the whole parsed file and patches are object spreads.
export interface StreamSettings {
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
codec?: string; // "auto" | "hevc" | "h264" | "av1" — soft preference (absent in pre-codec files)
gamepad: string; // "auto" | "xbox360" | "xboxone" | "dualsense" | "dualshock4" | "steamdeck"
compositor: string; // "auto" | "kwin" | "wlroots" | "mutter" | "gamescope"
// Round-trips only — deliberately NOT offered as a row here. It decides whether the session
// grabs the keyboard so Alt+Tab/Super reach the host, and Game Mode is gamescope: it has no
// compositor shortcuts to inhibit and hands the focused window every key already. A toggle
// here would be a dead one. The desktop client's row still edits this same file.
inhibit_shortcuts: boolean;
mic_enabled: boolean;
}
export interface UpdateInfo {
current: string; // installed PLUGIN version (package.json)
latest: string; // newest plugin version in our registry for this channel
@@ -156,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
@@ -181,43 +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",
);
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>
);
};
-590
View File
@@ -1,590 +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>
);
const SettingsTab: FC = () => (
<div style={tabScroll}>
<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) {
-201
View File
@@ -1,201 +0,0 @@
// Stream settings — resolution / refresh / bitrate / gamepad / compositor / mic, written to
// the flatpak client's JSON (main.py set_settings), which the client reads on launch. The
// accepted gamepad/compositor names mirror punktfunk-core's `*Pref::from_name`.
import { Dropdown, Field, SliderField, Spinner, ToggleField } from "@decky/ui";
import { CSSProperties, FC, useEffect, useState } from "react";
import { getSettings, setSettings, StreamSettings } from "./backend";
import { 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",
};
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"],
];
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 GAMEPADS = ["auto", "xbox360", "xboxone", "dualsense", "dualshock4", "steamdeck"];
const GAMEPAD_LABELS: Record<string, string> = {
auto: "Automatic",
xbox360: "Xbox 360",
xboxone: "Xbox One",
dualsense: "DualSense",
dualshock4: "DualShock 4",
steamdeck: "Steam Deck",
};
// Mirrors the desktop client's picker (ui_settings.rs CODECS) — a soft preference the host
// falls back from when its GPU can't encode it.
const CODECS = ["auto", "hevc", "h264", "av1"];
const CODEC_LABELS: Record<string, string> = {
auto: "Automatic",
hevc: "HEVC (H.265)",
h264: "H.264 (AVC)",
av1: "AV1",
};
const COMPOSITORS = ["auto", "kwin", "wlroots", "mutter", "gamescope"];
const COMPOSITOR_LABELS: Record<string, string> = {
auto: "Automatic",
kwin: "KDE Plasma (KWin)",
wlroots: "Sway (wlroots)",
mutter: "GNOME (Mutter)",
gamescope: "gamescope",
};
export const SettingsSection: FC = () => {
const [s, setS] = useState<StreamSettings | null>(null);
useEffect(() => {
void getSettings().then(setS);
}, []);
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 resIdx = Math.max(
0,
RESOLUTIONS.findIndex(([w, h]) => w === s.width && h === s.height),
);
return (
<>
<Field
label="Resolution"
description="The host creates a virtual output at exactly this size"
childrenContainerWidth="max"
>
<RowActions>
<div style={selectShell}>
<Dropdown
rgOptions={RESOLUTIONS.map(([, , label], i) => ({ data: i, label }))}
selectedOption={resIdx}
onChange={(o) => {
const [w, h] = RESOLUTIONS[o.data as number];
patch({ width: w, height: h });
}}
/>
</div>
</RowActions>
</Field>
<Field label="Refresh rate" childrenContainerWidth="max">
<RowActions>
<div style={selectShell}>
<Dropdown
rgOptions={REFRESH.map((r) => ({ data: r, label: r === 0 ? "Native" : `${r} Hz` }))}
selectedOption={s.refresh_hz}
onChange={(o) => patch({ refresh_hz: o.data as number })}
/>
</div>
</RowActions>
</Field>
<Field
label="Render scale"
description="Supersample for sharpness (> 1×, more bandwidth) or render below native (< 1×) — the Deck resamples to its screen"
childrenContainerWidth="max"
>
<RowActions>
<div style={selectShell}>
<Dropdown
rgOptions={RENDER_SCALES.map((x) => ({ data: x, label: renderScaleLabel(x) }))}
// Snap the stored value to the nearest preset so the dropdown always shows a match.
selectedOption={RENDER_SCALES.reduce((best, x) =>
Math.abs(x - (s.render_scale ?? 1)) < Math.abs(best - (s.render_scale ?? 1)) ? x : best,
)}
onChange={(o) => patch({ render_scale: o.data as number })}
/>
</div>
</RowActions>
</Field>
<SliderField
label="Bitrate"
description="Mbit/s · 0 = host default"
value={Math.round(s.bitrate_kbps / 1000)}
min={0}
max={150}
step={5}
showValue
valueSuffix=" Mbit/s"
onChange={(v) => patch({ bitrate_kbps: v * 1000 })}
/>
<Field
label="Video codec"
description="Preferred stream codec — the host falls back when its GPU can't encode it"
childrenContainerWidth="max"
>
<RowActions>
<div style={selectShell}>
<Dropdown
rgOptions={CODECS.map((c) => ({ data: c, label: CODEC_LABELS[c] ?? c }))}
selectedOption={s.codec ?? "auto"}
onChange={(o) => patch({ codec: o.data as string })}
/>
</div>
</RowActions>
</Field>
<Field
label="Gamepad type"
description="Which virtual controller the host creates for your inputs"
childrenContainerWidth="max"
>
<RowActions>
<div style={selectShell}>
<Dropdown
rgOptions={GAMEPADS.map((g) => ({ data: g, label: GAMEPAD_LABELS[g] ?? g }))}
selectedOption={s.gamepad}
onChange={(o) => patch({ gamepad: o.data as string })}
/>
</div>
</RowActions>
</Field>
{(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."
/>
)}
<Field
label="Host compositor"
description="Which compositor backend the host uses for the virtual display — Automatic suits almost every host"
childrenContainerWidth="max"
>
<RowActions>
<div style={selectShell}>
<Dropdown
rgOptions={COMPOSITORS.map((c) => ({ data: c, label: COMPOSITOR_LABELS[c] ?? c }))}
selectedOption={s.compositor}
onChange={(o) => patch({ compositor: o.data as string })}
/>
</div>
</RowActions>
</Field>
<ToggleField
label="Stream microphone"
description="Send the Deck's microphone to the host's virtual mic"
checked={s.mic_enabled}
onChange={(v) => patch({ mic_enabled: v })}
/>
</>
);
};
+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",
};
+163
View File
@@ -156,6 +156,20 @@ mod index {
pub fn gamepad(s: &Settings) -> u32 {
GAMEPADS.iter().position(|&g| g == s.gamepad).unwrap_or(0) as u32
}
pub fn present_priority(s: &Settings) -> u32 {
// Unknown values (a newer client's intent) read as the default, exactly as
// `PresentPriority::resolve` treats them.
PRESENT_PRIORITIES
.iter()
.position(|&p| p == s.present_priority)
.unwrap_or(0) as u32
}
pub fn smooth_buffer(s: &Settings) -> u32 {
// The index IS the stored value: 0 = Automatic, 1..3 = frames.
u32::from(s.smooth_buffer).min(SMOOTH_BUFFER_LABELS.len() as u32 - 1)
}
}
/// The chip palette a profile can carry (`StreamProfile.accent`). Eight entries rather than a
@@ -625,12 +639,27 @@ fn commit_profile(active: &StreamProfile, touched: &Touched, values: &Settings)
if touched.has("gamepad") {
o.gamepad = Some(values.gamepad.clone());
}
if touched.has("gamepad_forwarding") {
o.gamepad_forwarding = Some(values.gamepad_forwarding);
}
if touched.has("stats_verbosity") {
o.stats_verbosity = Some(values.stats_verbosity());
}
if touched.has("fullscreen_on_stream") {
o.fullscreen_on_stream = Some(values.fullscreen_on_stream);
}
if touched.has("present_priority") {
o.present_priority = Some(values.present_priority.clone());
}
if touched.has("smooth_buffer") {
o.smooth_buffer = Some(values.smooth_buffer);
}
if touched.has("vsync") {
o.vsync = Some(values.vsync);
}
if touched.has("allow_vrr") {
o.allow_vrr = Some(values.allow_vrr);
}
// Resets are not handled here: they clear the field and re-seed their row the moment the
// user asks, so by the time this runs the catalog already reflects them and the row is no
// longer marked touched.
@@ -684,6 +713,20 @@ const TOUCH_MODE_CAPTIONS: &[&str] = &[
"The cursor jumps to your finger — a tap clicks there",
"Real multi-touch reaches the host — for touch-native apps",
];
/// Presentation-intent values (persisted under the `present_priority` key the Apple and
/// Android clients share) + labels + dynamic captions. Captions stay ONE line, like the
/// touch/mouse rows.
const PRESENT_PRIORITIES: &[&str] = &["latency", "smooth"];
const PRESENT_PRIORITY_LABELS: &[&str] = &["Lowest latency", "Smoothness"];
const PRESENT_PRIORITY_CAPTIONS: &[&str] = &[
"Each frame shows the moment the display can take it",
"Buffers a little to even out network hiccups",
];
/// Smoothness buffer depth, in frames — the index IS the stored `smooth_buffer` value
/// (0 = Automatic, which resolves to 2). No millisecond hints: the cost is one refresh
/// per frame, and the session's refresh isn't known here when the mode is Native.
const SMOOTH_BUFFER_LABELS: &[&str] = &["Automatic", "1 frame", "2 frames", "3 frames"];
/// Physical-mouse model values (persisted) + labels + dynamic captions — same idiom as
/// the touch rows. Ctrl+Alt+Shift+M flips the model live in-stream.
const MOUSE_MODES: &[&str] = &["capture", "desktop"];
@@ -1213,6 +1256,50 @@ pub fn show_scoped(
row
});
// ---- Display: Presentation ----
// The intent pair the Apple and Android clients already carry. The buffer row only
// means anything under Smoothness, so it hides itself the rest of the time rather
// than sitting there inert.
let present_row = ChoiceRow::new(
&dialog,
inline,
"Prioritize",
PRESENT_PRIORITY_CAPTIONS[0],
PRESENT_PRIORITY_LABELS,
);
let buffer_row = ChoiceRow::new(
&dialog,
inline,
"Smoothness buffer",
"Each frame held absorbs one refresh of hiccup and adds one of delay",
SMOOTH_BUFFER_LABELS,
);
{
let w = present_row.widget().clone();
let buffer = buffer_row.widget().clone();
present_row.connect_changed(move |i| {
let i = (i as usize).min(PRESENT_PRIORITY_CAPTIONS.len() - 1);
set_row_subtitle(&w, PRESENT_PRIORITY_CAPTIONS[i]);
buffer.set_visible(PRESENT_PRIORITIES[i] == "smooth");
});
}
let vsync_row = adw::SwitchRow::builder()
.title("V-Sync")
.subtitle(
"Tear-free. Turning it off removes the wait for the screen's refresh — the \
lowest possible delay, at the cost of visible tearing. Not every driver \
offers it; the stats overlay names the mode actually in use",
)
.build();
let vrr_row = adw::SwitchRow::builder()
.title("Follow variable refresh rate")
.subtitle(
"On a VRR/FreeSync/G-Sync screen, let the panel refresh in step with the \
stream instead of on a fixed cadence. Applies to fullscreen sessions; \
harmless on a fixed-refresh screen",
)
.build();
// ---- Display: Host output ----
let compositor_row = ChoiceRow::new(
&dialog,
@@ -1376,6 +1463,17 @@ pub fn show_scoped(
// controller (single-player). The pin is persisted by stable key (`Settings::forward_pad`),
// so it survives restarts — and disconnects: an offline pinned pad keeps its entry here
// instead of silently snapping back to Automatic.
// Off = this device's controllers are not sent at all, because they reach the host
// another way (USB passthrough such as VirtualHere, or a pad plugged into the host).
// It also stops the session OPENING the pad, which is what frees the device for a
// passthrough tool to bind — so the two rows below have nothing to act on while it is
// off, and are desensitised to say so.
let pad_forward_row = adw::SwitchRow::builder()
.title("Forward controllers")
.subtitle(
"Send this device's controllers to the host — off if it already has them another way",
)
.build();
let pads = gamepads.pads();
let saved_pin = settings.borrow().forward_pad.clone();
let mut pad_names = vec!["Automatic (all controllers)".to_string()];
@@ -1444,6 +1542,18 @@ pub fn show_scoped(
"Steam Deck",
],
);
// Both pad rows only mean something while something is being forwarded (the same
// relationship mic → echo cancellation draws just above, initial state included: the
// seed's `set_active` fires this only when it CHANGES the switch).
{
let (f, t) = (forward_row.widget().clone(), pad_row.widget().clone());
f.set_sensitive(seed.gamepad_forwarding);
t.set_sensitive(seed.gamepad_forwarding);
pad_forward_row.connect_active_notify(move |r| {
f.set_sensitive(r.is_active());
t.set_sensitive(r.is_active());
});
}
// ---- Seed from the effective settings for this scope ----
{
@@ -1454,6 +1564,7 @@ pub fn show_scoped(
hz_row.set_selected(index::refresh(s));
scale_row.set_selected(index::render_scale(s));
bitrate_row.set_value(f64::from(s.bitrate_kbps) / 1000.0);
pad_forward_row.set_active(s.gamepad_forwarding);
pad_row.set_selected(index::gamepad(s));
let touch_i = index::touch(s);
touch_row.set_selected(touch_i);
@@ -1479,6 +1590,19 @@ pub fn show_scoped(
let codec_i = index::codec(s);
codec_row.set_selected(codec_i);
set_row_subtitle(codec_row.widget(), codec_caption(codec_i));
let present_i = index::present_priority(s);
present_row.set_selected(present_i);
set_row_subtitle(
present_row.widget(),
PRESENT_PRIORITY_CAPTIONS[present_i as usize],
);
buffer_row.set_selected(index::smooth_buffer(s));
// `set_selected` never fires the changed hook, so mirror its visibility rule here.
buffer_row
.widget()
.set_visible(PRESENT_PRIORITIES[present_i as usize] == "smooth");
vsync_row.set_active(s.vsync);
vrr_row.set_active(s.allow_vrr);
}
// ---- Override markers, per-row reset, and the touch that creates an override ----
@@ -1671,6 +1795,26 @@ pub fn show_scoped(
index::surround
);
choice!(pad_row, "gamepad", o.gamepad.is_some(), index::gamepad);
toggle!(
pad_forward_row,
"gamepad_forwarding",
o.gamepad_forwarding.is_some(),
gamepad_forwarding
);
choice!(
present_row,
"present_priority",
o.present_priority.is_some(),
index::present_priority
);
choice!(
buffer_row,
"smooth_buffer",
o.smooth_buffer.is_some(),
index::smooth_buffer
);
toggle!(vsync_row, "vsync", o.vsync.is_some(), vsync);
toggle!(vrr_row, "allow_vrr", o.allow_vrr.is_some(), allow_vrr);
toggle!(hdr_row, "hdr_enabled", o.hdr_enabled.is_some(), hdr_enabled);
toggle!(chroma_row, "enable_444", o.enable_444.is_some(), enable_444);
toggle!(
@@ -1775,6 +1919,11 @@ pub fn show_scoped(
if let (Some(r), false) = (&gpu_row, profile_mode) {
quality_group.add(r.widget());
}
let presentation_group = group("Presentation", "");
presentation_group.add(present_row.widget());
presentation_group.add(buffer_row.widget());
presentation_group.add(&vsync_row);
presentation_group.add(&vrr_row);
// The one form-level note (deliberately not repeated on every row).
let output_group = group(
"Host output",
@@ -1783,6 +1932,7 @@ pub fn show_scoped(
output_group.add(compositor_row.widget());
display.add(&resolution_group);
display.add(&quality_group);
display.add(&presentation_group);
display.add(&output_group);
let input = page("Input", "input-keyboard-symbolic");
@@ -1843,6 +1993,10 @@ pub fn show_scoped(
controllers_group.add(&row);
}
}
// Profileable, so it shows in both scopes — unlike the pin below it, which is about
// which of THIS device's pads goes first: a "Work" profile can decline to forward
// controllers to a host that a "Game" profile forwards them to.
controllers_group.add(&pad_forward_row);
if !profile_mode {
controllers_group.add(forward_row.widget());
}
@@ -1915,6 +2069,7 @@ pub fn show_scoped(
s.auto_wake = wake_row.is_active();
s.inhibit_shortcuts = inhibit_row.is_active();
s.invert_scroll = invert_row.is_active();
s.gamepad_forwarding = pad_forward_row.is_active();
s.mic_enabled = mic_row.is_active();
s.echo_cancel = echo_row.is_active();
s.hdr_enabled = hdr_row.is_active();
@@ -1925,6 +2080,14 @@ pub fn show_scoped(
_ => 2,
};
s.codec = CODECS[(codec_row.selected() as usize).min(CODECS.len() - 1)].to_string();
s.present_priority = PRESENT_PRIORITIES
[(present_row.selected() as usize).min(PRESENT_PRIORITIES.len() - 1)]
.to_string();
// The index IS the value (0 = Automatic).
s.smooth_buffer =
(buffer_row.selected() as u8).min(SMOOTH_BUFFER_LABELS.len() as u8 - 1);
s.vsync = vsync_row.is_active();
s.allow_vrr = vrr_row.is_active();
s.library_enabled = library_row.is_active();
};
+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(),
)
+91 -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),
};
@@ -169,6 +171,11 @@ pub fn run(target: Option<&str>) -> u8 {
mouse_mode: settings_at_start.mouse_mode(),
invert_scroll: settings_at_start.invert_scroll,
inhibit_shortcuts: settings_at_start.inhibit_shortcuts,
// Presentation-tier like the rows above: latched at console start, a per-host
// profile cannot move it in this mode (the documented P4 gap).
present_priority: settings_at_start.present_priority(),
vsync: settings_at_start.vsync,
allow_vrr: settings_at_start.allow_vrr,
json_status,
on_connected: Some(Box::new(move |fingerprint: [u8; 32]| {
let fp_hex = trust::hex(&fingerprint);
@@ -202,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;
@@ -216,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),
@@ -298,6 +308,8 @@ fn fake_host_row() -> HostRow {
can_wake: false,
last_used: None,
os: "linux/arch/steamos".into(),
pin: None,
bound_profile: None,
}
}
@@ -501,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.
}
}
}
@@ -539,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| {
@@ -558,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,
@@ -576,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
@@ -607,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));
+9
View File
@@ -188,6 +188,12 @@ mod session_main {
if !settings.forward_pad.is_empty() {
gamepad.set_pinned(Some(settings.forward_pad.clone()));
}
// Whether to forward controllers AT ALL (off = the pad reaches the host by some other
// route — VirtualHere and friends). Set unconditionally, not only when off: browse mode
// reuses one service across launches, so a stream that follows one with it off must put
// it back. It goes on before the attach below, so a non-forwarding session never opens
// — never grabs — the device.
gamepad.set_forwarding(settings.gamepad_forwarding);
let mode = Mode {
width: if settings.width == 0 {
native.width
@@ -617,6 +623,9 @@ mod session_main {
mouse_mode: settings.mouse_mode(),
invert_scroll: settings.invert_scroll,
inhibit_shortcuts: settings.inhibit_shortcuts,
present_priority: settings.present_priority(),
vsync: settings.vsync,
allow_vrr: settings.allow_vrr,
json_status: true,
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
// This host's card carries the accent bar in the desktop client now.
+8 -2
View File
@@ -623,8 +623,14 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
actions.push(
icon_btn("Settings", Symbol::Setting)
.on_click({
let ss = set_screen.clone();
move || ss.call(Screen::Settings)
let (c, ss) = (ctx.clone(), set_screen.clone());
move || {
// Re-base the settings snapshot on the file before the page
// renders — this process is not its only writer (see
// settings::refresh_snapshot).
super::settings::refresh_snapshot(&c);
ss.call(Screen::Settings)
}
})
.into(),
);
+10 -4
View File
@@ -2,7 +2,8 @@
//! Settings).
use super::style::*;
use super::Screen;
use super::{AppCtx, Screen};
use std::sync::Arc;
use windows_reactor::*;
/// punktfunk's own license (MIT OR Apache-2.0).
@@ -15,10 +16,15 @@ const APP_LICENSE: &str = concat!(
/// scripts/gen-third-party-notices.sh; the MSIX also ships this under licenses/).
const THIRD_PARTY_NOTICES: &str = include_str!("../../../../THIRD-PARTY-NOTICES.txt");
pub(crate) fn licenses_page(set_screen: &AsyncSetState<Screen>) -> Element {
pub(crate) fn licenses_page(ctx: &Arc<AppCtx>, set_screen: &AsyncSetState<Screen>) -> Element {
let back_btn = button("Back").accent().icon(Symbol::Back).on_click({
let ss = set_screen.clone();
move || ss.call(Screen::Settings)
let (c, ss) = (ctx.clone(), set_screen.clone());
move || {
// Back RE-ENTERS the settings page — re-base its snapshot on the file, same
// as the hosts page's Settings button (see settings::refresh_snapshot).
super::settings::refresh_snapshot(&c);
ss.call(Screen::Settings)
}
});
let app_card = card(
+5 -1
View File
@@ -172,6 +172,10 @@ pub(crate) struct Shared {
pub struct AppCtx {
pub(crate) identity: (String, String),
/// The settings snapshot the UI renders from. Loaded once at startup, and RE-BASED on
/// the file when the settings page is (re)entered (`settings::refresh_snapshot`) and
/// inside every `commit` — this process is not the file's only writer (session resize,
/// console UI, Decky), so a plain process-lifetime snapshot goes stale on screen.
pub(crate) settings: Mutex<Settings>,
pub(crate) gamepad: GamepadService,
pub(crate) shared: Arc<Shared>,
@@ -688,7 +692,7 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
&set_settings_rev,
nav_progress,
),
Screen::Licenses => licenses::licenses_page(&set_screen),
Screen::Licenses => licenses::licenses_page(ctx, &set_screen),
Screen::Help => help::help_page(&set_screen),
Screen::Pair => component(pair::pair_page, svc),
Screen::SpeedTest => component(speed::speed_page, SpeedProps { svc, state: speed }),
+196 -5
View File
@@ -101,6 +101,19 @@ const MOUSE_MODES: &[(&str, &str)] = &[
("capture", "Capture (games)"),
("desktop", "Desktop (absolute)"),
];
/// Presentation intent: `(stored value, display label)` — the `present_priority` key the
/// Apple and Android clients share, so one profile means the same thing everywhere.
const PRESENT_PRIORITIES: &[(&str, &str)] =
&[("latency", "Lowest latency"), ("smooth", "Smoothness")];
/// Smoothness buffer depth in frames: `(stored value, display label)`. `0` = Automatic,
/// which resolves to 2 (`PresentPriority::resolve`). No millisecond hints — the cost is
/// one refresh per frame, and the refresh isn't known here when the mode is Native.
const SMOOTH_BUFFERS: &[(u8, &str)] = &[
(0, "Automatic"),
(1, "1 frame"),
(2, "2 frames"),
(3, "3 frames"),
];
/// Host compositor presets: `(stored value, display label)`. Advisory — the host falls back to
/// auto-detect when the choice is unavailable. Only meaningful against a Linux host.
const COMPOSITORS: &[(&str, &str)] = &[
@@ -411,7 +424,16 @@ fn commit(
return;
}
let mut catalog = ProfilesFile::load();
let base = ctx.settings.lock().unwrap().clone();
// The same rebase as the global arm above: `base` is what `absorb`'s before/after
// effective settings derive from, and the snapshot is not the file — another process
// (session resize, console UI, Decky) may have moved a global under us. The historical
// rebase fix ("settings saves stop reverting each other") covered the whole-file
// writers but missed this arm.
let base = {
let mut s = ctx.settings.lock().unwrap();
*s = Settings::load();
s.clone()
};
let Some(p) = catalog.profiles.iter_mut().find(|p| p.id == scope) else {
return; // deleted from under us; the next render falls back to the defaults scope
};
@@ -425,6 +447,17 @@ fn commit(
rev.1.call(rev.0 + 1);
}
/// Re-base the process-lifetime settings snapshot on the file — called from the navigation
/// handlers that (re)enter this page, NOT per render pass. `ctx.settings` is loaded once at
/// process start and this process is not the file's only writer (a spawned session persists
/// its match-window size, the console UI and Decky save too — profiles.rs documents the
/// family), so without this the page opens showing values another process already replaced,
/// which then visibly "jump" the moment a row is touched and `commit`'s rebase pulls the
/// file in. The field report this fixes: a codec setting that "changed by itself".
pub(crate) fn refresh_snapshot(ctx: &Arc<AppCtx>) {
*ctx.settings.lock().unwrap() = Settings::load();
}
/// Which tier-P rows the profile in scope overrides. Plain bools rather than a lookup so the
/// call sites read as `over.codec` — the row and its flag stay visibly paired.
#[derive(Default)]
@@ -445,8 +478,13 @@ struct OverrideFlags {
invert_scroll: bool,
inhibit_shortcuts: bool,
gamepad: bool,
gamepad_forwarding: bool,
stats_verbosity: bool,
fullscreen_on_stream: bool,
present_priority: bool,
smooth_buffer: bool,
vsync: bool,
allow_vrr: bool,
}
impl OverrideFlags {
@@ -473,8 +511,13 @@ impl OverrideFlags {
invert_scroll: o.invert_scroll.is_some(),
inhibit_shortcuts: o.inhibit_shortcuts.is_some(),
gamepad: o.gamepad.is_some(),
gamepad_forwarding: o.gamepad_forwarding.is_some(),
stats_verbosity: o.stats_verbosity.is_some(),
fullscreen_on_stream: o.fullscreen_on_stream.is_some(),
present_priority: o.present_priority.is_some(),
smooth_buffer: o.smooth_buffer.is_some(),
vsync: o.vsync.is_some(),
allow_vrr: o.allow_vrr.is_some(),
}
}
}
@@ -851,6 +894,32 @@ pub(crate) fn settings_page(
let chroma_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.enable_444, |s, on| {
s.enable_444 = on
});
// Presentation intent (design/desktop-presentation-rebuild.md). The buffer row is
// rendered only under Smoothness — `commit` bumps the revision, so flipping the
// intent re-renders the section and the row appears/disappears with it.
let (present_names, present_i) = presets(PRESENT_PRIORITIES, |v| *v == s.present_priority);
let present_combo = setting_combo(
ctx,
scope,
(rev, set_rev),
present_names,
present_i,
|s, i| s.present_priority = PRESENT_PRIORITIES[i].0.to_string(),
);
let smoothing = s.present_priority == "smooth";
let (buffer_names, buffer_i) = presets(SMOOTH_BUFFERS, |v| *v == s.smooth_buffer);
let buffer_combo = setting_combo(
ctx,
scope,
(rev, set_rev),
buffer_names,
buffer_i,
|s, i| s.smooth_buffer = SMOOTH_BUFFERS[i].0,
);
let vsync_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.vsync, |s, on| s.vsync = on);
let vrr_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.allow_vrr, |s, on| {
s.allow_vrr = on
});
// --- Input -----------------------------------------------------------------------------
// Controller forwarding: Automatic forwards EVERY real controller, each as its own pad;
@@ -898,6 +967,10 @@ pub(crate) fn settings_page(
s.save();
})
};
let pad_forward_toggle =
setting_toggle(ctx, scope, (rev, set_rev), s.gamepad_forwarding, |s, on| {
s.gamepad_forwarding = on
});
let (pad_names, pad_i) = presets(GAMEPADS, |v| {
GamepadPref::from_name(v) == GamepadPref::from_name(&s.gamepad)
});
@@ -972,6 +1045,21 @@ 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 — 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::real_dir().filter(|d| d.is_dir()) {
let _ = std::process::Command::new("explorer.exe").arg(&dir).spawn();
}
});
let library_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.library_enabled, |s, on| {
s.library_enabled = on
});
@@ -1065,8 +1153,9 @@ pub(crate) fn settings_page(
"HDR10, when the host has HDR content and this display supports it. \
HEVC only; otherwise the stream stays SDR.",
),
// Wording shared with the GTK client (its chroma_row) — same setting,
// same constraints.
// First sentence shared with the GTK client (its chroma_row); the
// constraint sentence names the real gate (host: PyroWave || NVENC) —
// "where the host can encode it" cost field users the discovery time.
described_overridable(
(rev, set_rev),
scope,
@@ -1075,7 +1164,8 @@ pub(crate) fn settings_page(
over.enable_444,
chroma_toggle,
"Full-colour video: crisp small text and thin lines, at more \
bandwidth. HEVC only, and only where the host can encode it.",
bandwidth. Requires an NVIDIA host (NVENC) or the PyroWave \
codec \u{2014} other encoders stream 4:2:0.",
),
],
None,
@@ -1105,6 +1195,60 @@ pub(crate) fn settings_page(
},
None,
));
out.extend(group(
Some("Presentation"),
{
let mut fields = vec![described_overridable(
(rev, set_rev),
scope,
"present_priority",
"Prioritize",
over.present_priority,
present_combo,
"Lowest latency shows each frame the moment the display can take \
it \u{2014} a network hiccup becomes an occasional repeated or \
skipped frame. Smoothness buffers a little to even those out.",
)];
if smoothing {
fields.push(described_overridable(
(rev, set_rev),
scope,
"smooth_buffer",
"Smoothness buffer",
over.smooth_buffer,
buffer_combo,
"Frames held back before showing. Each one absorbs about a \
refresh of network hiccup and adds a refresh of delay. \
Automatic holds two.",
));
}
fields.push(described_overridable(
(rev, set_rev),
scope,
"vsync",
"V-Sync",
over.vsync,
vsync_toggle,
"Tear-free. Turning it off removes the wait for the screen\u{2019}s \
refresh \u{2014} the lowest possible delay, at the cost of visible \
tearing. Not every driver offers it; the stats overlay names the \
mode actually in use.",
));
fields.push(described_overridable(
(rev, set_rev),
scope,
"allow_vrr",
"Follow variable refresh rate",
over.allow_vrr,
vrr_toggle,
"On a VRR/FreeSync/G-Sync screen, let the panel refresh in step with \
the stream instead of on a fixed cadence. Applies to fullscreen \
sessions; harmless on a fixed-refresh screen.",
));
fields
},
None,
));
out.extend(group(
Some("Host output"),
vec![described_overridable(
@@ -1223,6 +1367,23 @@ pub(crate) fn settings_page(
"Plug in or pair a controller and it appears here.",
)
}),
// Whether ANY controller is forwarded — profileable, so it renders in
// both scopes (a "Work" profile can decline what "Game" forwards),
// unlike the device-fact picker below it.
Some(described_overridable(
(rev, set_rev),
scope,
"gamepad_forwarding",
"Forward controllers",
over.gamepad_forwarding,
pad_forward_toggle,
"Sends controllers connected to this PC to the host. Turn it off when \
your controller already reaches the host another way \u{2014} USB \
passthrough such as VirtualHere, or a pad plugged into the host \
itself \u{2014} so games don't see two of them. Off, this PC never \
opens the controller at all, which is what leaves it free for a \
passthrough tool to claim.",
)),
// NOT Apple's wording: Apple forwards ONE pad as player 1, this client
// forwards every controller as its own player. Same picker, different rule.
// Which physical pad this device forwards is a device fact (tier G), so it
@@ -1325,7 +1486,16 @@ pub(crate) fn settings_page(
"About",
group(
None,
vec![about_identity.into(), licenses_button.into()],
vec![
about_identity.into(),
described_labeled(
"Diagnostics",
logs_button,
"The client log (client.log, plus the session\u{2019}s whole \
receive/decode/present trail) \u{2014} attach it to a bug report.",
),
licenses_button.into(),
],
None,
),
),
@@ -1727,5 +1897,26 @@ mod tests {
let f3 = OverrideFlags::of(Some(&p3));
assert!(f3.echo_cancel);
assert!(!f3.mic_enabled);
// The presentation pair, likewise independent: pinning the intent doesn't claim
// the buffer (a "Smoothness, whatever the global buffer is" profile is valid).
let mut p4 = StreamProfile::new("t4".to_string());
p4.overrides = SettingsOverlay {
present_priority: Some("smooth".into()),
..Default::default()
};
let f4 = OverrideFlags::of(Some(&p4));
assert!(f4.present_priority);
assert!(!f4.smooth_buffer);
// V-Sync and VRR are independent of each other and of the intent pair.
let mut p5 = StreamProfile::new("t5".to_string());
p5.overrides = SettingsOverlay {
vsync: Some(false),
..Default::default()
};
let f5 = OverrideFlags::of(Some(&p5));
assert!(f5.vsync);
assert!(!f5.allow_vrr && !f5.present_priority);
}
}
+131 -2
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,13 +25,74 @@ const ROTATE_BYTES: u64 = 10 * 1024 * 1024;
static SINK: OnceLock<Option<Arc<Mutex<File>>>> = OnceLock::new();
/// 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 file's path, for the "logs land here" startup line (and any future UI affordance).
/// 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
@@ -96,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()
);
}
}
+8 -1
View File
@@ -105,7 +105,14 @@ fn parse_line(line: &str) -> Option<ChildLine> {
/// connect that silently drops back to the host list.
pub(crate) fn silent_exit_banner(code: i32) -> Option<String> {
(code != 0 && code != -1).then(|| {
format!("The session didn't start (punktfunk-session exited with code {code}). Check the client log.")
// Name the log's actual location — "check the client log" without a path is a
// scavenger hunt (Settings ▸ About's "Open log folder" reaches it too).
let log = crate::logfile::path()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "the client log".into());
format!(
"The session didn't start (punktfunk-session exited with code {code}). Check {log}."
)
})
}
+6 -1
View File
@@ -612,7 +612,10 @@ pub fn open_portal_monitor(
/// 10-bit PQ/BT.2020 formats instead of the SDR set — pass it only when the output was actually
/// brought up HDR (a gamescope spawned with `--hdr-enabled` off our `pipewire-hdr` build); the
/// host resolves that in `capture::capturer_supports_hdr_for` **before** the Welcome, because a
/// session that negotiated PQ cannot fall back to SDR afterwards.
/// session that negotiated PQ cannot fall back to SDR afterwards. `cursor_id0_hides` declares the
/// producer's cursor-meta contract — pass it for outputs whose compositor rewrites
/// `SPA_META_Cursor` on every buffer (KWin), where an `id == 0` meta is an authoritative
/// "pointer hidden" the composited/forwarded cursor must honor.
#[cfg(target_os = "linux")]
#[allow(clippy::too_many_arguments)]
pub fn open_virtual_output(
@@ -625,6 +628,7 @@ pub fn open_virtual_output(
want_hdr: bool,
policy: ZeroCopyPolicy,
expect_exact_dims: bool,
cursor_id0_hides: bool,
) -> Result<Box<dyn Capturer>> {
linux::PortalCapturer::from_virtual_output(
remote_fd,
@@ -636,6 +640,7 @@ pub fn open_virtual_output(
want_hdr && !hdr_capture_failed(HdrSource::VirtualOutput),
policy,
expect_exact_dims,
cursor_id0_hides,
)
.map(|c| Box::new(c) as Box<dyn Capturer>)
}
+14 -1
View File
@@ -72,6 +72,11 @@ struct CaptureOpts {
/// the doomed birth mode. `false` everywhere else (Mutter SIZES the monitor from negotiation and
/// gamescope fixates its own — gating those would starve legitimate first frames).
expect_exact_dims: bool,
/// The producer rewrites `SPA_META_Cursor` on EVERY buffer, so an `id == 0` meta is an
/// authoritative "pointer hidden / off this output" the blend must honor (KWin). `false` for
/// the stale-meta producers (Mutter recycles buffers without rewriting the region) — see
/// [`pw_cursor::CursorState::id0_hides`](pw_cursor) for the full contract.
cursor_id0_hides: bool,
}
/// The shared state the PipeWire thread PUBLISHES and the capturer READS — one struct instead of
@@ -301,6 +306,10 @@ impl PortalCapturer {
want_444: false,
want_hdr,
expect_exact_dims: false,
// The portal-monitor path today is Mutter (the GNOME HDR mirror) — the stale-meta
// id-0 contract. A KDE portal capture would rewrite per buffer, but nothing routes
// one through here yet; the virtual-output path below carries the real flag.
cursor_id0_hides: false,
},
policy,
)?
@@ -316,7 +325,8 @@ impl PortalCapturer {
/// the GPU zero-copy path subject to `PUNKTFUNK_ZEROCOPY`. `want_444` (a 4:4:4 session) makes the
/// zero-copy worker convert tiled dmabufs to planar YUV444 on the GPU instead of NV12/RGB.
/// `want_hdr` runs the 10-bit PQ/BT.2020 offer instead of the SDR set — see
/// [`crate::open_virtual_output`] for who is allowed to pass it.
/// [`crate::open_virtual_output`] for who is allowed to pass it. `cursor_id0_hides` declares
/// the producer's cursor-meta contract ([`CaptureOpts::cursor_id0_hides`]).
#[allow(clippy::too_many_arguments)]
pub fn from_virtual_output(
remote_fd: Option<OwnedFd>,
@@ -328,6 +338,7 @@ impl PortalCapturer {
want_hdr: bool,
policy: ZeroCopyPolicy,
expect_exact_dims: bool,
cursor_id0_hides: bool,
) -> Result<PortalCapturer> {
tracing::info!(
node_id,
@@ -335,6 +346,7 @@ impl PortalCapturer {
want_444,
want_hdr,
expect_exact_dims,
cursor_id0_hides,
"connecting PipeWire to virtual output"
);
// Most virtual outputs are SDR-only upstream (Mutter's RecordVirtual streams advertise
@@ -350,6 +362,7 @@ impl PortalCapturer {
want_444,
want_hdr,
expect_exact_dims,
cursor_id0_hides,
},
policy,
)?
+2 -1
View File
@@ -811,6 +811,7 @@ pub fn pipewire_thread(
want_444,
want_hdr,
expect_exact_dims,
cursor_id0_hides,
..
} = opts;
crate::pwinit::ensure_init();
@@ -985,7 +986,7 @@ pub fn pipewire_thread(
yuv444: want_444,
linear_nv12_failed: false,
dbg_log_n: 0,
cursor: CursorState::default(),
cursor: CursorState::new(cursor_id0_hides),
expect_dims: if expect_exact_dims {
preferred.map(|(w, h, _)| (w, h))
} else {
+75 -8
View File
@@ -39,9 +39,23 @@ pub(super) struct CursorState {
/// negotiated). Per-stream deliberately — a host serves many sessions per process, and a
/// process-wide latch made the second session's triage read as "no meta".
seen_meta: bool,
/// This stream's producer rewrites the cursor meta on EVERY buffer, so an `id == 0` meta is
/// an authoritative "pointer hidden / off this output" rather than a stale recycled region.
/// True for KWin virtual outputs; false for the stale-meta producers (Mutter) — see
/// [`note_cursor_id`].
id0_hides: bool,
}
impl CursorState {
/// The per-stream state, declaring which `id == 0` contract the producer follows
/// ([`Self::id0_hides`]).
pub(super) fn new(id0_hides: bool) -> CursorState {
CursorState {
id0_hides,
..CursorState::default()
}
}
/// A shareable overlay for the encode/forward paths, or `None` before the first bitmap
/// arrived. A HIDDEN pointer still yields `Some` (with `visible: false`): the
/// cursor-forward channel needs "known but hidden" — an app grabbed the pointer, the
@@ -79,6 +93,31 @@ pub(super) fn decode_bitmap_pixel(vfmt: u32, s: &[u8]) -> (u8, u8, u8, u8) {
}
}
/// Apply one parsed `spa_meta_cursor.id` to the visibility state; returns whether the rest of the
/// meta region (position, bitmap) is worth parsing.
///
/// Two producer contracts meet on `id == 0`. **KWin** rewrites the cursor meta on EVERY enqueued
/// buffer, and writes id 0 whenever `Cursor::isOnOutput` says the pointer is not in this stream —
/// which covers a globally hidden cursor AND a client null-cursor surface (empty cursor geometry
/// intersects nothing). There id 0 is the authoritative hide, and honoring it is what lets a game
/// or Big Picture hide the pointer mid-stream ([`CursorState::id0_hides`], set for KWin virtual
/// outputs; without it the composited arrow outlived every hide — the 0.22.0 field report).
/// **Mutter** only rewrites a buffer's meta region when the cursor changed, so recycled buffers
/// between damage frames carry a stale id-0 meta — treating that as hidden flickered the cursor
/// off between hovers (on-glass round 5). There the last-known state holds, and a pointer that
/// really left/hid simply stops producing updates (the M3 hidden hint has no Mutter signal —
/// Windows has its own CURSOR_SUPPRESSED source).
fn note_cursor_id(cursor: &mut CursorState, id: u32) -> bool {
if id == 0 {
if cursor.id0_hides {
cursor.visible = false;
}
return false;
}
cursor.visible = true;
true
}
/// Update `cursor` from the newest buffer's `SPA_META_Cursor` (no-op when the buffer carries no
/// cursor meta — producer doesn't support it, or the portal isn't in Metadata cursor mode).
/// Called for EVERY dequeued buffer, before the stale-frame skip, so pointer-only movements
@@ -121,16 +160,9 @@ pub(super) fn update_cursor_meta(cursor: &mut CursorState, spa_buf: *mut spa::sy
(*cur).bitmap_offset,
)
};
if id == 0 {
// SPA contract: id 0 = "no cursor information", NOT "cursor hidden". Mutter only
// REWRITES a buffer's meta region when the cursor changed, so recycled buffers
// between damage frames carry a stale id-0 meta — treating that as hidden flickered
// the cursor off between hovers (on-glass round 5). Keep the last-known state; a
// pointer that really left/hid simply stops producing updates. (The M3 hidden hint
// loses its Mutter signal — Windows has its own CURSOR_SUPPRESSED source.)
if !note_cursor_id(cursor, id) {
return;
}
cursor.visible = true;
cursor.x = pos_x - hot_x;
cursor.y = pos_y - hot_y;
cursor.hot_x = hot_x;
@@ -367,9 +399,44 @@ mod tests {
hot_x: 0,
hot_y: 0,
seen_meta: true,
id0_hides: false,
}
}
// ---- note_cursor_id: the two producer id-0 contracts --------------------------------------
#[test]
fn id_zero_hides_only_on_a_rewriting_producer() {
// KWin contract (`id0_hides`): id 0 is written fresh on every buffer, so it IS the hide —
// a game or Big Picture hiding the pointer must reach the stream.
let mut kwin = cursor(10, 10, 8, 8, (255, 255, 255), 255);
kwin.id0_hides = true;
assert!(!note_cursor_id(&mut kwin, 0), "id 0 parses no further");
let o = kwin.overlay().expect("bitmap stays cached across a hide");
assert!(!o.visible, "KWin id 0 must hide the overlay");
// The pointer coming back re-shows the SAME cached bitmap.
assert!(note_cursor_id(&mut kwin, 1));
assert!(kwin.overlay().expect("still cached").visible);
// Mutter contract: recycled buffers carry stale id-0 metas — the last-known state holds
// (honoring them flickered the cursor off between hovers, on-glass round 5).
let mut mutter = cursor(10, 10, 8, 8, (255, 255, 255), 255);
assert!(!note_cursor_id(&mut mutter, 0));
assert!(
mutter.overlay().expect("cached").visible,
"a stale-meta producer's id 0 must NOT hide"
);
}
#[test]
fn id_zero_before_any_bitmap_yields_no_overlay() {
// A KWin stream whose pointer was never on the output: hides arrive before any bitmap —
// `overlay()` must stay `None` (nothing to blend), not a phantom empty cursor.
let mut c = CursorState::new(true);
assert!(!note_cursor_id(&mut c, 0));
assert!(c.overlay().is_none());
}
// ---- bitmap_extent: the guard whose absence SIGSEGVs uncatchably -------------------------
#[test]
+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"]);
}
}
+66 -4
View File
@@ -336,6 +336,7 @@ enum Ctl {
Detach,
Pin(Option<String>),
KindOverride(GamepadPref),
Forwarding(bool),
MenuMode(bool),
MenuRumble(MenuPulse),
}
@@ -482,6 +483,26 @@ impl GamepadService {
let _ = self.ctl.send(Ctl::KindOverride(pref));
}
/// Forward this device's controllers to the host at all ([`Settings::gamepad_forwarding`],
/// default on). Off is for a couch whose pad reaches the host another way — a USB
/// passthrough tool like VirtualHere, or a controller plugged into the host itself —
/// where forwarding as well would give the host two pads for one pair of hands.
///
/// Off holds no slot open, so nothing is sent AND nothing is *grabbed*: no arrival, no
/// virtual pad host-side, and the hidraw node stays free for the passthrough tool to
/// bind (SDL's HIDAPI drivers take it at open — a held device cannot be bound away).
/// It follows that the escape chord, which only listens on forwarded pads, is not
/// available while off; the keyboard chord and the client's own UI still end a session.
///
/// Menu navigation is untouched: the launcher still opens the active pad to drive its
/// UI, and a session — which supersedes menu mode whether it forwards or not — releases
/// it again, so the pad is free for the whole time a stream is up.
///
/// [`Settings::gamepad_forwarding`]: crate::trust::Settings::gamepad_forwarding
pub fn set_forwarding(&self, on: bool) {
let _ = self.ctl.send(Ctl::Forwarding(on));
}
pub fn attach(&self, connector: Arc<NativeClient>) {
let _ = self.ctl.send(Ctl::Attach(connector));
}
@@ -721,6 +742,10 @@ struct Worker {
/// connected pads, so it survives restarts and disconnects. A pin forwards ONLY that pad
/// (an explicit single-player choice); Automatic forwards every real controller.
pinned: Option<String>,
/// Forward controllers to an attached session at all ([`GamepadService::set_forwarding`]).
/// Off makes [`Self::forwarded_ids`] empty, so a session opens no slot — the whole point
/// being that the hardware stays ungrabbed for a USB passthrough tool.
forwarding: bool,
/// The user's explicit "controller type" setting ([`GamepadService::set_kind_override`]);
/// `Auto` = per-pad detection. Applied at slot open to the kind DECLARED to the host, never
/// to [`Slot::pref`] — the local feedback paths must keep reading the physical pad.
@@ -815,6 +840,11 @@ impl Worker {
/// back to the single most-recent pad when only a Steam-virtual pad is present (the Deck
/// game-mode case — otherwise its gyro/paddles/input would have nowhere to land).
fn forwarded_ids(&self) -> Vec<u32> {
// Forwarding off: nothing is forwarded, so nothing is opened either — the device stays
// free for whatever route the user's controller actually takes to the host.
if !self.forwarding {
return Vec::new();
}
if let Some(key) = &self.pinned {
if let Some(id) = self
.order
@@ -1243,10 +1273,16 @@ impl Worker {
Ok(Ctl::Attach(c)) => {
self.attached = Some(c);
self.reset_chord(); // every session starts un-latched (Attach doesn't flush)
// The Valve HIDAPI drivers run only in-session (see set_valve_hidapi);
// enabling them re-enumerates a Deck's built-in pad with paddles/
// trackpads/gyro first-class — sync_open opens a slot per forwarded pad.
set_valve_hidapi(true);
// The Valve HIDAPI drivers run only in-session (see set_valve_hidapi);
// enabling them re-enumerates a Deck's built-in pad with paddles/
// trackpads/gyro first-class — sync_open opens a slot per forwarded pad.
// Not with forwarding off: this session opens no slot, and the drivers'
// mere enumeration both kills the Deck's trackpad-mouse and is the
// opposite of leaving the hardware alone for a passthrough tool.
if self.forwarding {
set_valve_hidapi(true);
}
self.sync_open();
}
Ok(Ctl::Detach) => {
@@ -1269,6 +1305,31 @@ impl Worker {
self.refresh_active();
}
Ok(Ctl::KindOverride(pref)) => self.kind_override = pref,
Ok(Ctl::Forwarding(on)) => {
if self.forwarding == on {
continue;
}
self.forwarding = on;
self.reset_chord(); // no forwarded pad can be mid-chord across the flip
// Applied live rather than at attach only, so a mid-session flip (an
// in-stream settings screen) takes effect on the pad in your hands.
//
// The Valve HIDAPI drivers are an in-session-only thing (see
// set_valve_hidapi), and forwarding off is — for their purpose — not in
// session. Order matters and differs by direction: ON must enable them
// BEFORE `sync_open`, or a Deck's built-in pad opens under its old
// identity; OFF must disable them AFTER, so no slot outlives the driver
// that opened it.
let attached = self.attached.is_some();
if on && attached {
set_valve_hidapi(true);
}
self.sync_open();
if !on && attached {
set_valve_hidapi(false);
}
}
Ok(Ctl::MenuMode(on)) => {
self.menu_mode = on;
if on {
@@ -1608,6 +1669,7 @@ impl Worker {
menu_open: None,
order: Vec::new(),
pinned: None,
forwarding: true,
kind_override: GamepadPref::Auto,
attached: None,
escape_tx,
+4
View File
@@ -982,6 +982,10 @@ mod tests {
height: 1440,
bitrate_kbps: 55000,
codec: "av1".into(),
present_priority: "smooth".into(),
smooth_buffer: 2,
vsync: false,
allow_vrr: false,
..Default::default()
},
clipboard: true,
+139
View File
@@ -74,9 +74,23 @@ pub struct SettingsOverlay {
#[serde(skip_serializing_if = "Option::is_none")]
pub gamepad: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gamepad_forwarding: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stats_verbosity: Option<StatsVerbosity>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fullscreen_on_stream: Option<bool>,
/// The presentation cluster — the keys the Apple client already writes into this
/// same catalog shape (`present_priority`/`smooth_buffer`/`vsync`/`allow_vrr`;
/// Android carries the first two). First-class here so a profile authored on any
/// client applies on all of them instead of riding `extra` unapplied.
#[serde(skip_serializing_if = "Option::is_none")]
pub present_priority: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub smooth_buffer: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
pub vsync: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub allow_vrr: Option<bool>,
/// Overlay keys a newer client wrote and this one doesn't model — carried through a
/// load→save round-trip untouched.
#[serde(flatten)]
@@ -142,6 +156,9 @@ impl SettingsOverlay {
if let Some(v) = &self.gamepad {
s.gamepad = v.clone();
}
if let Some(v) = self.gamepad_forwarding {
s.gamepad_forwarding = v;
}
if let Some(v) = self.stats_verbosity {
// Through the setter so the legacy `show_stats` bool stays coherent for
// pre-tier binaries reading the same settings file.
@@ -150,6 +167,18 @@ impl SettingsOverlay {
if let Some(v) = self.fullscreen_on_stream {
s.fullscreen_on_stream = v;
}
if let Some(v) = &self.present_priority {
s.present_priority = v.clone();
}
if let Some(v) = self.smooth_buffer {
s.smooth_buffer = v;
}
if let Some(v) = self.vsync {
s.vsync = v;
}
if let Some(v) = self.allow_vrr {
s.allow_vrr = v;
}
s
}
@@ -220,12 +249,27 @@ impl SettingsOverlay {
if after.gamepad != before.gamepad {
self.gamepad = Some(after.gamepad.clone());
}
if after.gamepad_forwarding != before.gamepad_forwarding {
self.gamepad_forwarding = Some(after.gamepad_forwarding);
}
if after.stats_verbosity() != before.stats_verbosity() {
self.stats_verbosity = Some(after.stats_verbosity());
}
if after.fullscreen_on_stream != before.fullscreen_on_stream {
self.fullscreen_on_stream = Some(after.fullscreen_on_stream);
}
if after.present_priority != before.present_priority {
self.present_priority = Some(after.present_priority.clone());
}
if after.smooth_buffer != before.smooth_buffer {
self.smooth_buffer = Some(after.smooth_buffer);
}
if after.vsync != before.vsync {
self.vsync = Some(after.vsync);
}
if after.allow_vrr != before.allow_vrr {
self.allow_vrr = Some(after.allow_vrr);
}
}
/// Drop one override by its overlay field name, putting the row back to inheriting. The
@@ -257,8 +301,13 @@ impl SettingsOverlay {
"invert_scroll" => self.invert_scroll = None,
"inhibit_shortcuts" => self.inhibit_shortcuts = None,
"gamepad" => self.gamepad = None,
"gamepad_forwarding" => self.gamepad_forwarding = None,
"stats_verbosity" => self.stats_verbosity = None,
"fullscreen_on_stream" => self.fullscreen_on_stream = None,
"present_priority" => self.present_priority = None,
"smooth_buffer" => self.smooth_buffer = None,
"vsync" => self.vsync = None,
"allow_vrr" => self.allow_vrr = None,
_ => return false,
}
true
@@ -433,6 +482,10 @@ mod tests {
assert_eq!((out.width, out.height), (1920, 1080));
assert_eq!(out.bitrate_kbps, 20000);
assert_eq!(out.codec, "hevc");
assert!(
out.gamepad_forwarding,
"default on, and an empty overlay leaves it alone"
);
assert!(empty.is_empty());
let overlay = SettingsOverlay {
@@ -452,9 +505,14 @@ mod tests {
invert_scroll: Some(true),
inhibit_shortcuts: Some(false),
gamepad: Some("dualsense".into()),
gamepad_forwarding: Some(false),
match_window: Some(true),
fullscreen_on_stream: Some(false),
stats_verbosity: Some(StatsVerbosity::Detailed),
present_priority: Some("smooth".into()),
smooth_buffer: Some(3),
vsync: Some(false),
allow_vrr: Some(false),
..Default::default()
};
assert!(!overlay.is_empty());
@@ -473,9 +531,14 @@ mod tests {
assert!(out.invert_scroll);
assert!(!out.inhibit_shortcuts);
assert_eq!(out.gamepad, "dualsense");
assert!(!out.gamepad_forwarding);
assert!(out.match_window);
assert!(!out.fullscreen_on_stream);
assert_eq!(out.stats_verbosity(), StatsVerbosity::Detailed);
assert_eq!(out.present_priority, "smooth");
assert_eq!(out.smooth_buffer, 3);
assert!(!out.vsync);
assert!(!out.allow_vrr);
// The tier goes through the setter, so the legacy bool a pre-tier binary reads
// stays coherent with it.
assert!(out.show_stats);
@@ -573,6 +636,59 @@ mod tests {
assert!(o.is_empty());
}
/// The presentation cluster is first-class, not `extra` passengers: it applies,
/// absorbs, clears, and serialises under the exact keys the Apple client already
/// writes (`present_priority`/`smooth_buffer`/`vsync`/`allow_vrr`) — one catalog
/// has to round-trip through every platform, and a mismatched key would be carried
/// but never applied.
#[test]
fn presentation_cluster_is_first_class() {
let base = Settings::default();
let mut o = SettingsOverlay::default();
let before = o.apply(&base);
let mut after = before.clone();
after.present_priority = "smooth".into();
o.absorb(&before, &after);
let before = o.apply(&base);
let mut after = before.clone();
after.smooth_buffer = 1;
o.absorb(&before, &after);
assert_eq!(o.present_priority.as_deref(), Some("smooth"));
assert_eq!(o.smooth_buffer, Some(1));
assert!(
o.extra.is_empty(),
"modelled fields must never land in the passthrough"
);
let out = o.apply(&base);
assert_eq!(
out.present_priority(),
crate::trust::PresentPriority::Smooth { buffer: 1 }
);
// Serialised under the shared keys, and read back from a foreign client's file.
let text = serde_json::to_string(&o).unwrap();
assert!(text.contains("\"present_priority\":\"smooth\""), "{text}");
assert!(text.contains("\"smooth_buffer\":1"), "{text}");
let from_apple: SettingsOverlay = serde_json::from_str(
r#"{"present_priority":"latency","smooth_buffer":2,"vsync":true,"allow_vrr":false}"#,
)
.unwrap();
assert_eq!(from_apple.present_priority.as_deref(), Some("latency"));
assert_eq!(from_apple.smooth_buffer, Some(2));
assert_eq!(from_apple.vsync, Some(true));
assert_eq!(from_apple.allow_vrr, Some(false));
assert!(from_apple.extra.is_empty());
assert!(o.clear("present_priority"));
assert!(o.clear("smooth_buffer"));
assert_eq!(o.present_priority, None);
assert!(o.is_empty());
let mut vrr = from_apple;
assert!(vrr.clear("vsync"));
assert!(vrr.clear("allow_vrr"));
assert_eq!((vrr.vsync, vrr.allow_vrr), (None, None));
}
/// `clear` is the explicit way back to inheriting, including the resolution tri-state.
#[test]
fn clear_drops_one_override() {
@@ -591,6 +707,29 @@ mod tests {
assert!(!o.clear("no_such_field"));
}
/// Controller forwarding defaults ON, so its interesting override is the FALSE one — and a
/// `false` that `apply` dropped would silently forward a pad the profile said not to.
/// `absorb` must record it, `clear` must undo it, and the serialized name both carry is the
/// one every client's reset button sends.
#[test]
fn gamepad_forwarding_overrides_off_and_resets_back() {
let base = Settings::default();
assert!(base.gamepad_forwarding, "the shipped default");
let mut o = SettingsOverlay::default();
let mut after = base.clone();
after.gamepad_forwarding = false;
o.absorb(&base, &after);
assert_eq!(o.gamepad_forwarding, Some(false));
assert!(!o.apply(&base).gamepad_forwarding);
assert!(o.clear("gamepad_forwarding"));
assert_eq!(o.gamepad_forwarding, None);
assert!(o.is_empty());
// Back to inheriting: the global's live value, not a remembered false.
assert!(o.apply(&base).gamepad_forwarding);
}
/// Stats verbosity Off must survive `apply` — it is a legitimate override, and going
/// through `set_stats_verbosity` keeps `show_stats` in sync in that direction too.
#[test]
+181 -5
View File
@@ -14,6 +14,7 @@ use anyhow::{anyhow, Context, Result};
use punktfunk_core::client::NativeClient;
use punktfunk_core::quic::endpoint;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
pub fn config_dir() -> Result<PathBuf> {
@@ -231,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.
@@ -787,6 +800,45 @@ impl MouseMode {
}
}
/// Presentation intent — what the presenter optimizes for
/// (design/desktop-presentation-rebuild.md; the Apple/Android clients' shared
/// `present_priority`/`smooth_buffer` pair). Stored stringly in
/// [`Settings::present_priority`] + [`Settings::smooth_buffer`]; resolved with
/// [`PresentPriority::resolve`], whose rules match the Android reference
/// (`decode/presenter.rs`): anything but an explicit `"smooth"` is latency, and a
/// smooth buffer outside 1..=3 (including 0 = Automatic) becomes 2.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum PresentPriority {
/// Every frame presents the moment the display can take it; a network hiccup is an
/// occasional repeated or skipped frame. The default.
Latency,
/// A small frame buffer (13 frames) evens out network/decode jitter, at the
/// buffer's worth of added display latency.
Smooth { buffer: u8 },
}
impl PresentPriority {
/// The shared cross-client resolution rule — pure, so every embedder agrees on what
/// a foreign profile's values mean.
pub fn resolve(name: &str, buffer: u8) -> PresentPriority {
if name == "smooth" {
PresentPriority::Smooth {
buffer: if (1..=3).contains(&buffer) { buffer } else { 2 },
}
} else {
PresentPriority::Latency
}
}
/// Frames the smoothing store holds; `0` = newest-wins (the latency intent).
pub fn fifo_capacity(self) -> u8 {
match self {
PresentPriority::Latency => 0,
PresentPriority::Smooth { buffer } => buffer,
}
}
}
/// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file
/// stays readable; parsed with `*Pref::from_name` at connect time.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -808,6 +860,21 @@ pub struct Settings {
/// container `#[serde(default)]`.
pub render_scale: f64,
pub gamepad: String,
/// Forward this device's controllers to the host at all. Default ON — that was the
/// unconditional behaviour before this became a setting.
///
/// Off is for the couch whose controller reaches the host by some *other* route: a USB
/// passthrough tool (VirtualHere and friends), or a pad simply plugged into the host
/// itself. Leaving forwarding on there gives the host two controllers for one pair of
/// hands, and games read both.
///
/// It is deliberately stronger than "send no input": with it off the client never
/// *opens* the controller, and opening is what grabs the hardware (SDL's HIDAPI drivers
/// take the hidraw node) — a held device is one a passthrough tool cannot bind. Menu
/// navigation in the launcher still opens the active pad, and the session releases it;
/// see [`crate::gamepad::GamepadService::set_forwarding`].
#[serde(default = "default_true")]
pub gamepad_forwarding: bool,
/// Stable identity (`vid:pid:name`, see `PadInfo::key`) of the physical controller
/// forwarded as pad 0; empty = automatic (most recently connected). Applied to the
/// gamepad service at startup so the choice survives restarts.
@@ -874,6 +941,32 @@ pub struct Settings {
/// `default = true`: the Linux stores never carried this and always advertised.
#[serde(default = "default_true")]
pub hdr_enabled: bool,
/// Presentation intent: `"latency"` (default) or `"smooth"` — the Apple/Android
/// clients' shared `present_priority` profile key, resolved with
/// [`PresentPriority::resolve`] (via [`Settings::present_priority`]). Anything
/// unknown reads as latency, so a newer client's future value degrades safely.
#[serde(default = "default_present_priority")]
pub present_priority: String,
/// Smoothness buffer size in frames: `0` = Automatic (resolves to 2), else 13.
/// Only meaningful under `present_priority = "smooth"` (the shared `smooth_buffer`
/// key). Each buffered frame absorbs about one refresh of jitter and adds one
/// refresh of display latency.
#[serde(default)]
pub smooth_buffer: u8,
/// Tear-free presentation (default ON = today's behavior: MAILBOX, FIFO fallback).
/// Off asks for a tearing present mode (IMMEDIATE) for the lowest possible latch
/// latency — best-effort: platforms/drivers without tearing silently stay tear-free
/// and the active mode is visible in the detailed stats. The shared `vsync` profile
/// key; the desktop default differs from macOS's (`false` there) deliberately —
/// sync-off means something different on each platform, the key is the contract.
#[serde(default = "default_true")]
pub vsync: bool,
/// Let a variable-refresh display follow the stream cadence: prefers the present
/// mode that drives VRR panels directly when fullscreen. Inert on fixed-refresh
/// displays (detection is measured from on-glass timestamps, not queried). The
/// shared `allow_vrr` profile key. Default ON, like the Apple client.
#[serde(default = "default_true")]
pub allow_vrr: bool,
/// Legacy on/off for the stats overlay — superseded by `stats_verbosity` but kept
/// written in sync (`set_stats_verbosity`) so pre-tier binaries reading the same
/// file keep working. `alias`: the pre-unification WinUI shell (≤ 0.8.4) persisted
@@ -925,6 +1018,14 @@ pub struct Settings {
/// the user will be looking at. `0` = never stored → the 1280×720 default.
pub last_window_w: u32,
pub last_window_h: u32,
/// Settings keys this build doesn't model (a newer client's field), carried through a
/// load→save round-trip untouched — [`crate::profiles::SettingsOverlay`]'s `extra`
/// pattern extended to the globals. Without it, every whole-file writer of this store
/// (two shells, the console settings screen, the session's resize callback, Decky)
/// running as an OLDER binary silently drops what a newer one persisted. Empty on
/// every existing store, and an empty map serializes to nothing, so files don't churn.
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
fn default_codec() -> String {
@@ -939,6 +1040,10 @@ fn default_mouse_mode() -> String {
"capture".into()
}
fn default_present_priority() -> String {
"latency".into()
}
fn default_true() -> bool {
true
}
@@ -970,6 +1075,12 @@ impl Settings {
MouseMode::from_name(&self.mouse_mode)
}
/// The presentation intent for this session (the resolved
/// `present_priority` × `smooth_buffer` pair).
pub fn present_priority(&self) -> PresentPriority {
PresentPriority::resolve(&self.present_priority, self.smooth_buffer)
}
/// The `codec` setting as a `quic::CODEC_*` preference bit (`0` = auto).
pub fn preferred_codec(&self) -> u8 {
match self.codec.as_str() {
@@ -994,6 +1105,7 @@ impl Default for Settings {
bitrate_kbps: 0,
render_scale: 1.0,
gamepad: "auto".into(),
gamepad_forwarding: true,
forward_pad: String::new(),
compositor: "auto".into(),
touch_mode: "trackpad".into(),
@@ -1007,6 +1119,10 @@ impl Default for Settings {
adapter: String::new(),
enable_444: false,
hdr_enabled: true,
present_priority: "latency".into(),
smooth_buffer: 0,
vsync: true,
allow_vrr: true,
show_stats: true,
stats_verbosity: None,
fullscreen_on_stream: true,
@@ -1018,6 +1134,7 @@ impl Default for Settings {
match_window: false,
last_window_w: 0,
last_window_h: 0,
extra: BTreeMap::new(),
}
}
}
@@ -1144,6 +1261,43 @@ mod tests {
}
}
/// A settings file predating the presentation cluster loads with the shipped
/// defaults (latency intent, Automatic buffer, tear-free, VRR allowed), and the
/// resolution rules match the Apple/Android reference: anything but an explicit
/// `"smooth"` is latency, and a smooth buffer outside 1..=3 becomes 2.
#[test]
fn settings_presentation_defaults_and_resolution() {
let old = r#"{"width":1280,"height":720,"gamepad":"auto","compositor":"auto"}"#;
let s: Settings = serde_json::from_str(old).unwrap();
assert_eq!(s.present_priority, "latency");
assert_eq!(s.smooth_buffer, 0);
assert!(s.vsync);
assert!(s.allow_vrr);
assert_eq!(s.present_priority(), PresentPriority::Latency);
assert_eq!(
PresentPriority::resolve("smooth", 0),
PresentPriority::Smooth { buffer: 2 },
"Automatic resolves to 2"
);
assert_eq!(
PresentPriority::resolve("smooth", 3),
PresentPriority::Smooth { buffer: 3 }
);
assert_eq!(
PresentPriority::resolve("smooth", 9),
PresentPriority::Smooth { buffer: 2 },
"out-of-range pins to the Automatic resolution"
);
assert_eq!(
PresentPriority::resolve("balanced-from-the-future", 2),
PresentPriority::Latency,
"unknown intents degrade to latency"
);
assert_eq!(PresentPriority::Latency.fifo_capacity(), 0);
assert_eq!(PresentPriority::Smooth { buffer: 3 }.fifo_capacity(), 3);
}
/// A pre-`forward_pad` settings file (≤ 0.5.0) loads with the pin on automatic.
#[test]
fn settings_forward_pad_defaults_empty() {
@@ -1192,6 +1346,28 @@ mod tests {
assert!(s.echo_cancel);
}
/// A key this build doesn't model (a newer client's setting) survives a load→save
/// round trip instead of being dropped by the next whole-file write — the same
/// contract `SettingsOverlay.extra` gives profiles. And when there are no unknown
/// keys, the flatten map adds nothing, so existing files don't churn.
#[test]
fn settings_unknown_keys_survive_round_trip() {
let newer = r#"{"width":1920,"height":1080,"frob_mode":"fancy","frob_level":3}"#;
let s: Settings = serde_json::from_str(newer).unwrap();
assert_eq!((s.width, s.height), (1920, 1080));
assert_eq!(
s.extra.get("frob_mode").and_then(|v| v.as_str()),
Some("fancy")
);
let out = serde_json::to_string(&s).unwrap();
assert!(out.contains(r#""frob_mode":"fancy""#), "{out}");
assert!(out.contains(r#""frob_level":3"#), "{out}");
// No unknown keys → no artifact of the passthrough field in the file.
let plain = serde_json::to_string(&Settings::default()).unwrap();
assert!(!plain.contains("extra"), "{plain}");
assert!(!plain.contains("frob"), "{plain}");
}
/// Stats-tier resolution: a pre-tier store falls back to `show_stats` (off → Off,
/// on/absent → Normal), an explicit tier wins, and setting a tier keeps the legacy
/// bool in sync so pre-tier binaries reading the same file agree on off vs on.
+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,
}]
);
}
}
+383 -22
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,
@@ -26,9 +31,14 @@ enum RowId {
Decoder,
Hdr,
Chroma444,
PresentPriority,
SmoothBuffer,
Vsync,
AllowVrr,
Audio,
Mic,
EchoCancel,
PadForward,
Pad,
PadType,
Touch,
@@ -45,8 +55,9 @@ 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.
const ROWS: [RowId; 22] = [
// 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,
RowId::RenderScale,
@@ -56,9 +67,14 @@ const ROWS: [RowId; 22] = [
RowId::Decoder,
RowId::Hdr,
RowId::Chroma444,
RowId::PresentPriority,
RowId::SmoothBuffer,
RowId::Vsync,
RowId::AllowVrr,
RowId::Audio,
RowId::Mic,
RowId::EchoCancel,
RowId::PadForward,
RowId::Pad,
RowId::PadType,
RowId::Touch,
@@ -117,6 +133,17 @@ const DECODERS: [(&str, &str); 4] = [
("software", "Software"),
];
const AUDIO: [(u8, &str); 3] = [(2, "Stereo"), (6, "5.1"), (8, "7.1")];
/// 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: [(&str, &str); 2] =
[("latency", "Lowest latency"), ("smooth", "Smoothness")];
/// Smoothness buffer depth in frames; `0` = Automatic (resolves to 2).
const SMOOTH_BUFFERS: [(u8, &str); 4] = [
(0, "Automatic"),
(1, "1 frame"),
(2, "2 frames"),
(3, "3 frames"),
];
const PAD_TYPES: [(&str, &str); 6] = [
("auto", "Automatic"),
("xbox360", "Xbox 360"),
@@ -128,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,
@@ -147,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
@@ -159,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)
@@ -169,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
@@ -179,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(
@@ -203,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,
@@ -220,11 +309,51 @@ 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;
// Echo cancellation only means anything while the mic streams — dimmed and inert while it
// doesn't, the same relationship the desktop shells draw with a greyed-out row.
let enabled = !matches!(id, RowId::EchoCancel) || s.mic_enabled;
// Several rows follow another: echo cancellation only means anything while the mic
// streams, the pad rows only while any controller is forwarded at all, and the
// smoothness buffer only while that intent is chosen. All go dim and inert otherwise
// — the same relationship the desktop shells draw by greying a row out (they hide the
// buffer row entirely; a fixed row list can't, and a row that vanished mid-list would
// move everything under the cursor).
let enabled = match id {
RowId::EchoCancel => s.mic_enabled,
RowId::Pad | RowId::PadType => s.gamepad_forwarding,
RowId::SmoothBuffer => s.present_priority == "smooth",
_ => true,
};
let (header, label, value): (Option<&'static str>, &str, String) = match id {
RowId::Resolution => (
Some("Stream"),
@@ -279,6 +408,22 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
RowId::Decoder => (None, "Decoder", label_for(&DECODERS, &s.decoder).into()),
RowId::Hdr => (None, "10-bit HDR", on_off(s.hdr_enabled).into()),
RowId::Chroma444 => (None, "Full chroma (4:4:4)", on_off(s.enable_444).into()),
RowId::PresentPriority => (
Some("Presentation"),
"Prioritize",
label_for(&PRESENT_PRIORITIES, &s.present_priority).into(),
),
RowId::SmoothBuffer => (
None,
"Smoothness buffer",
SMOOTH_BUFFERS
.iter()
.find(|(v, _)| *v == s.smooth_buffer)
.map_or("Automatic", |(_, l)| l)
.into(),
),
RowId::Vsync => (None, "V-Sync", on_off(s.vsync).into()),
RowId::AllowVrr => (None, "Follow variable refresh", on_off(s.allow_vrr).into()),
RowId::Audio => (
Some("Audio"),
"Audio channels",
@@ -290,8 +435,13 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
),
RowId::Mic => (None, "Microphone", on_off(s.mic_enabled).into()),
RowId::EchoCancel => (None, "Echo cancellation", on_off(s.echo_cancel).into()),
RowId::Pad => (
RowId::PadForward => (
Some("Controller"),
"Forward controllers",
on_off(s.gamepad_forwarding).into(),
),
RowId::Pad => (
None,
"Use controller",
if s.forward_pad.is_empty() {
"Automatic".into()
@@ -331,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,
@@ -368,6 +519,24 @@ fn detail(id: RowId) -> &'static str {
Needs an NVIDIA host (NVENC) or the PyroWave codec other encoders \
stream 4:2:0 and the session falls back silently."
}
RowId::PresentPriority => {
"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."
}
RowId::SmoothBuffer => {
"Frames held back before showing. Each one absorbs about a refresh of network \
hiccup and adds a refresh of delay. Automatic holds two."
}
RowId::Vsync => {
"Tear-free. Off removes the wait for the screen's refresh — the lowest \
possible delay, at the cost of visible tearing. Not every driver offers it; \
the stats overlay names the mode actually in use."
}
RowId::AllowVrr => {
"On a VRR screen, let the panel refresh in step with the stream instead of on \
a fixed cadence. Applies to fullscreen sessions; harmless on a fixed screen."
}
RowId::Audio => "The speaker layout requested from the host.",
RowId::Mic => {
"Send this device's microphone to the host's virtual mic. \
@@ -377,6 +546,11 @@ fn detail(id: RowId) -> &'static str {
"Stops the host's audio, playing from this device's speakers, being picked up \
and sent back. Turn it off if your microphone already runs its own processing."
}
RowId::PadForward => {
"Send controllers connected to this device 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."
}
RowId::Pad => "Which pad is forwarded to the host, as player 1.",
RowId::PadType => "The virtual pad the host creates — Automatic matches this controller.",
RowId::Touch => {
@@ -403,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."
}
}
}
@@ -463,6 +647,27 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
RowId::Decoder => step_str(&DECODERS, &mut s.decoder, delta, wrap),
RowId::Hdr => toggle(&mut s.hdr_enabled, delta, wrap),
RowId::Chroma444 => toggle(&mut s.enable_444, delta, wrap),
RowId::PresentPriority => {
let cur = PRESENT_PRIORITIES
.iter()
.position(|(v, _)| *v == s.present_priority);
step_option(cur, PRESENT_PRIORITIES.len(), delta, wrap)
.map(|i| s.present_priority = PRESENT_PRIORITIES[i].0.to_string())
}
// Inert unless smoothness is chosen — a boundary thud, matching the dimmed row.
RowId::SmoothBuffer => {
if s.present_priority == "smooth" {
let cur = SMOOTH_BUFFERS
.iter()
.position(|(v, _)| *v == s.smooth_buffer);
step_option(cur, SMOOTH_BUFFERS.len(), delta, wrap)
.map(|i| s.smooth_buffer = SMOOTH_BUFFERS[i].0)
} else {
None
}
}
RowId::Vsync => toggle(&mut s.vsync, delta, wrap),
RowId::AllowVrr => toggle(&mut s.allow_vrr, delta, wrap),
RowId::Audio => {
let cur = AUDIO.iter().position(|(v, _)| *v == s.audio_channels);
step_option(cur, AUDIO.len(), delta, wrap).map(|i| s.audio_channels = AUDIO[i].0)
@@ -476,7 +681,11 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
None
}
}
RowId::PadForward => toggle(&mut s.gamepad_forwarding, delta, wrap),
RowId::Pad => {
if !s.gamepad_forwarding {
return false;
}
// Automatic first, then every connected pad by stable key.
let keys: Vec<String> = std::iter::once(String::new())
.chain(ctx.pads.iter().map(|p| p.key.clone()))
@@ -484,7 +693,12 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
let cur = keys.iter().position(|c| *c == s.forward_pad);
step_option(cur, keys.len(), delta, wrap).map(|i| s.forward_pad = keys[i].clone())
}
RowId::PadType => step_str(&PAD_TYPES, &mut s.gamepad, delta, wrap),
RowId::PadType => {
if !s.gamepad_forwarding {
return false;
}
step_str(&PAD_TYPES, &mut s.gamepad, delta, wrap)
}
RowId::Touch => {
let cur = TouchMode::ALL.iter().position(|m| *m == s.touch_mode());
step_option(cur, TouchMode::ALL.len(), delta, wrap)
@@ -507,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()
}
@@ -632,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"
@@ -641,13 +857,52 @@ 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));
assert!(ctx.settings.echo_cancel);
}
/// The smoothness buffer follows the presentation intent, exactly as echo cancellation
/// follows the mic: dimmed and inert under Lowest latency (where holding frames means
/// nothing), live under Smoothness. The desktop shells hide the row instead; a fixed
/// row list dims it, because a row vanishing mid-list would shift everything under the
/// cursor.
#[test]
fn smoothness_buffer_follows_the_intent() {
let (mut settings, pads) = ctx_parts();
assert_eq!(settings.present_priority, "latency", "the shipped default");
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,
};
assert!(!row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled);
assert!(
!adjust(RowId::SmoothBuffer, 1, false, &mut ctx),
"latency intent = thud"
);
assert_eq!(ctx.settings.smooth_buffer, 0, "and nothing was written");
// Stepping the intent to Smoothness brings the buffer row to life.
assert!(adjust(RowId::PresentPriority, 1, false, &mut ctx));
assert_eq!(ctx.settings.present_priority, "smooth");
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);
}
#[test]
fn touch_mode_steps_and_wraps() {
let (mut settings, pads) = ctx_parts();
@@ -721,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());
}
}
@@ -669,6 +669,11 @@ impl GamepadManager {
/// Service every pad's FF protocol; `send(index, low, high)` is invoked for each pad whose
/// mixed rumble level changed. Call frequently (games block in `EVIOCSFF` until answered).
pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) {
// Finish any unplug whose removal frame only armed the grace — the producer sends that
// frame once, so without this the uinput node would outlive the controller. The swept
// mask is discarded because this manager keeps no per-index sibling state (the pads mix
// rumble internally); if that ever changes, consume it like the other two backends do.
self.slots.reap();
for (i, pad) in self.slots.iter_mut() {
if let Some((low, high)) = pad.pump_ff() {
send(i as u16, low, high);
+96 -20
View File
@@ -62,15 +62,30 @@ impl<P> PadSlots<P> {
self.label
}
/// Drop every allocated pad whose `active_mask` bit has stayed clear for [`SWEEP_GRACE`] (the
/// unplug sweep run on each state frame), logging each. Returns the swept indices as a bitmask
/// so the caller resets its per-index sibling state; an index another manager owns is `None`
/// here, so it is never swept. The grace is the devnode-churn debounce: a mask that glitches
/// clear for a few frames and returns re-arms nothing.
/// Fold one state frame's `active_mask` into the grace clocks, then drop whatever has run out
/// (see [`Self::reap`]). Returns the dropped indices as a bitmask so the caller resets its
/// per-index sibling state; an index another manager owns is `None` here, so it is never
/// touched. The grace is the devnode-churn debounce: a mask that glitches clear for a few
/// frames and returns re-arms nothing.
///
/// A frame can only ARM the grace, never complete it — no time has passed at the instant the
/// clock starts. Since the producer emits exactly ONE frame per detach, [`Self::reap`] on the
/// manager's periodic pump is what actually finishes the unplug; a backend that only ever
/// called `sweep` would keep the detached pad alive for the rest of the session.
pub fn sweep(&mut self, active_mask: u16) -> u16 {
self.sweep_at(active_mask, Instant::now())
}
/// Drop every allocated pad whose grace has run out, logging each — the half of the unplug
/// that needs no state frame. Returns the dropped indices as a bitmask, same as [`Self::sweep`].
///
/// This can only ever *complete* an unplug some frame already started: it never arms a clock,
/// so however often it runs it cannot drop a pad whose `active_mask` bit never went clear.
/// That is what makes it safe to call from a hot pump loop.
pub fn reap(&mut self) -> u16 {
self.reap_at(Instant::now())
}
/// Backdate every armed grace clock by [`SWEEP_GRACE`], so the NEXT sweep drops the pads
/// whose bits are still clear — consumer tests (the managers') drive the debounce without
/// wall-clock sleeps. Test-only: production code has no business expiring the grace.
@@ -81,26 +96,37 @@ impl<P> PadSlots<P> {
}
}
/// [`Self::sweep`] with an injectable clock (unit tests drive the grace window).
/// [`Self::sweep`] with an injectable clock (unit tests drive the grace window): arm or disarm
/// each slot's clock from the mask, then reap whatever has already run out.
fn sweep_at(&mut self, active_mask: u16, now: Instant) -> u16 {
let mut swept = 0u16;
for (i, slot) in self.pads.iter_mut().enumerate() {
for i in 0..MAX_PADS {
if active_mask & (1 << i) != 0 {
self.inactive_since[i] = None; // active (again): a glitch never reaches the drop
} else if self.pads[i].is_some() && self.inactive_since[i].is_none() {
self.inactive_since[i] = Some(now); // newly inactive — start the grace
}
}
self.reap_at(now)
}
/// [`Self::reap`] with an injectable clock. Deliberately arms nothing — it only ever reads
/// `inactive_since` and clears it, so a pad whose bit never went clear has no clock to run out
/// and cannot be dropped here.
fn reap_at(&mut self, now: Instant) -> u16 {
let mut swept = 0u16;
for i in 0..MAX_PADS {
let Some(since) = self.inactive_since[i] else {
continue; // active, or never went clear — nothing to complete
};
if self.pads[i].is_none() {
self.inactive_since[i] = None; // the slot went away by some other route
continue;
}
if slot.is_none() {
continue;
}
match self.inactive_since[i] {
None => self.inactive_since[i] = Some(now), // newly inactive — start the grace
Some(since) if now.duration_since(since) >= SWEEP_GRACE => {
tracing::info!(index = i, "controller unplugged ({})", self.label);
*slot = None;
self.inactive_since[i] = None;
swept |= 1 << i;
}
Some(_) => {} // inside the grace — hold
if now.duration_since(since) >= SWEEP_GRACE {
tracing::info!(index = i, "controller unplugged ({})", self.label);
self.pads[i] = None;
self.inactive_since[i] = None;
swept |= 1 << i;
}
}
swept
@@ -161,6 +187,56 @@ mod tests {
PadSlots::new("Test", "test pad", "")
}
#[test]
fn a_single_frame_plus_a_reap_completes_the_unplug() {
// The shape production actually produces: ONE cleared-mask frame, then time, then a reap
// with no further frame. Before the arm/reap split the pad survived here forever.
let mut s = slots();
assert!(s.ensure(2, |i| Ok(i as u32)));
assert_eq!(
s.sweep(0b0),
0,
"a frame arms the grace but cannot itself drop"
);
assert!(s.get(2).is_some());
s.expire_grace();
assert_eq!(s.reap(), 1 << 2, "the reap did not complete the unplug");
assert!(s.get(2).is_none());
assert_eq!(s.reap(), 0, "nothing left to reap");
}
#[test]
fn reap_never_drops_a_pad_no_frame_ever_deactivated() {
// Reaping COMPLETES an unplug; it must never invent one. A pad whose bit never went clear
// has no armed clock, so any number of reaps — even with the clock backdated — leaves it.
let mut s = slots();
assert!(s.ensure(0, |i| Ok(i as u32)));
for _ in 0..10 {
assert_eq!(s.reap(), 0);
s.expire_grace();
}
assert!(
s.get(0).is_some(),
"reap dropped a pad that never went inactive"
);
}
#[test]
fn a_glitch_that_returns_inside_the_grace_never_drops_the_pad() {
// The anti-flap guarantee, now that reaps are frequent: a client mask that blips clear and
// comes back must not churn a PnP devnode.
let mut s = slots();
assert!(s.ensure(0, |i| Ok(i as u32)));
assert_eq!(s.sweep(0b0), 0); // bit clears — arms only
for _ in 0..5 {
assert_eq!(s.reap(), 0, "dropped a pad inside its grace");
}
assert_eq!(s.sweep(0b1), 0); // the bit returns — disarms
s.expire_grace();
assert_eq!(s.reap(), 0, "a returned bit must leave nothing armed");
assert!(s.get(0).is_some());
}
#[test]
fn ensure_creates_once_and_reports_freshness() {
let mut s = slots();
+52 -14
View File
@@ -217,13 +217,10 @@ impl<B: PadProto> UhidManager<B> {
if idx >= MAX_PADS {
return;
}
// Unplugs: drop any allocated pad whose mask bit cleared, resetting its state.
// Unplugs: arm the grace for any pad whose mask bit cleared (the drop itself lands
// on a later `pump` tick — this frame is the only one the producer sends).
let swept = self.slots.sweep(f.active_mask);
for i in 0..MAX_PADS {
if swept & (1 << i) != 0 {
self.reset_pad(i);
}
}
self.reset_swept(swept);
if f.active_mask & (1 << idx) == 0 {
return; // this event WAS the unplug
}
@@ -282,6 +279,12 @@ impl<B: PadProto> UhidManager<B> {
mut hidout: impl FnMut(HidOutput),
) {
let now = Instant::now();
// Finish any unplug whose removal frame only armed the grace. The producer emits that
// frame exactly once, so without this a detached pad — the single-pad session being the
// common case — would never be destroyed. Runs BEFORE the loop so a reaped index is
// already gone for `get_mut` here and for `heartbeat`'s `get` later in the same tick.
let swept = self.slots.reap();
self.reset_swept(swept);
for i in 0..MAX_PADS {
let Some(pad) = self.slots.get_mut(i) else {
continue;
@@ -360,6 +363,18 @@ impl<B: PadProto> UhidManager<B> {
}
}
/// Reset the sibling state of every index a sweep or reap just dropped. Both halves of the
/// unplug land here, so a pad torn down on the pump tick clears exactly what one torn down on
/// a state frame would — in particular `hidout_dedup`, which has no watchdog to re-arm it and
/// would otherwise swallow an identical lightbar/trigger re-assert after a re-plug.
fn reset_swept(&mut self, swept: u16) {
for i in 0..MAX_PADS {
if swept & (1 << i) != 0 {
self.reset_pad(i);
}
}
}
/// Reset one pad's sibling state (on create and unplug) so the first frame/feedback after a
/// (re)connect starts from scratch and is always forwarded.
fn reset_pad(&mut self, idx: usize) {
@@ -494,18 +509,36 @@ mod tests {
}
#[test]
fn removal_frame_never_recreates_the_pad_it_swept() {
fn one_removal_frame_plus_a_pump_tick_completes_the_unplug() {
// The producer emits the cleared-mask frame exactly ONCE — `native/input.rs` guards it on
// the bit still being set — so the teardown has to finish on the periodic pump. The
// previous version of this test hand-fed a SECOND removal frame, which is what let the
// never-reaped pad hide: with one frame and no pump, the device outlived the session.
let mut m = mgr();
m.handle(&frame(1, 0b10, 0));
assert!(m.slots.get(1).is_some());
// Bit 1 cleared: the first sweep only ARMS the devnode-churn grace — the pad holds (a
// mask glitch must not flap PnP devices; see pad_slots::SWEEP_GRACE).
// The one removal frame: arms the devnode-churn grace, drops nothing.
m.handle(&frame(1, 0b00, 0));
assert!(m.slots.get(1).is_some(), "inside the grace — not yet swept");
// Grace elapsed: the frame IS pad 1's removal — sweep, then early-return (no ensure).
// A tick inside the grace must NOT flap the devnode (pad_slots::SWEEP_GRACE).
m.pump(|_, _, _| {}, |_| {});
assert!(
m.slots.get(1).is_some(),
"a tick inside the grace dropped it"
);
// Grace elapsed: the next tick completes the unplug, with no further frame.
m.slots.expire_grace();
m.pump(|_, _, _| {}, |_| {});
assert!(
m.slots.get(1).is_none(),
"the pump tick never completed the unplug"
);
// …and a further cleared-mask frame must not resurrect it (the arm branch early-returns).
m.handle(&frame(1, 0b00, 0));
assert!(m.slots.get(1).is_none());
assert!(
m.slots.get(1).is_none(),
"a cleared-mask frame recreated the pad"
);
}
#[test]
@@ -551,10 +584,15 @@ mod tests {
assert_eq!(collect(&mut m), vec![(0, 100, 0)]); // first value forwards
assert_eq!(collect(&mut m), vec![]); // exact repeat deduped
assert_eq!(collect(&mut m), vec![(0, 7, 7)]); // change forwards
// Unplug + recreate re-arms the dedup: the same level forwards again.
m.handle(&frame(0, 0b0, 0)); // arms the sweep grace
// Unplug + recreate re-arms the dedup: the same level forwards again. The unplug completes
// on a PUMP tick, not on a second frame — that is all production ever sends.
m.handle(&frame(0, 0b0, 0)); // the one removal frame — arms the grace
m.slots.expire_grace();
m.handle(&frame(0, 0b0, 0)); // grace elapsed — actually swept
assert_eq!(collect(&mut m), vec![]); // this tick reaps; nothing queued to forward
assert!(
m.slots.get(0).is_none(),
"the pump tick completed the unplug"
);
m.handle(&frame(0, 0b1, 0));
*m.backend.feedback.borrow_mut() = vec![rumble((7, 7))];
assert_eq!(collect(&mut m), vec![(0, 7, 7)]);
@@ -318,14 +318,10 @@ impl GamepadManager {
if idx >= MAX_PADS {
return;
}
// Unplugs: drop any allocated pad whose mask bit cleared.
// Unplugs: arm the grace for any pad whose mask bit cleared (the drop itself lands
// on a later `pump_rumble` tick — this frame is the only one the producer sends).
let swept = self.slots.sweep(f.active_mask);
for i in 0..MAX_PADS {
if swept & (1 << i) != 0 {
self.last_rumble[i] = (0, 0);
self.last_active[i] = Instant::now();
}
}
self.reset_swept(swept);
if f.active_mask & (1 << idx) == 0 {
return;
}
@@ -345,10 +341,25 @@ impl GamepadManager {
}
}
/// Reset the sibling state of every index a sweep or reap just dropped, so both halves of the
/// unplug clear the same things.
fn reset_swept(&mut self, swept: u16) {
for i in 0..MAX_PADS {
if swept & (1 << i) != 0 {
self.last_rumble[i] = (0, 0);
self.last_active[i] = Instant::now();
}
}
}
/// Relay any changed rumble level to the client. XUSB motors are 0..255; the wire carries
/// 0..65535, so scale by 257. `large` (low-frequency) → the datagram's `low`, `small`
/// (high-frequency) → `high` — matching the other backends.
pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) {
// Finish any unplug whose removal frame only armed the grace — the producer sends that
// frame once, so without this the XUSB devnode would outlive the controller.
let swept = self.slots.reap();
self.reset_swept(swept);
for (i, pad) in self.slots.iter_mut() {
if let Some((large, small)) = pad.service() {
// The game drove the pad this poll (SET_STATE bumped the seq) — refresh the
+2
View File
@@ -52,6 +52,8 @@ pub mod keymap_sdl;
#[cfg(any(target_os = "linux", windows))]
pub mod overlay;
#[cfg(any(target_os = "linux", windows))]
mod present_pace;
#[cfg(any(target_os = "linux", windows))]
mod run;
#[cfg(any(target_os = "linux", windows))]
pub mod touch;
+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
+751
View File
@@ -0,0 +1,751 @@
//! The presentation intent engine (design/desktop-presentation-rebuild.md WP2): the
//! store, clock, and gate the run loop composes into the two intents.
//!
//! * [`FrameStore`] — newest-wins slot (latency) or smoothing FIFO with preroll
//! (smoothness), ported from the Apple `FrameStore` / Android `presenter.rs` so all
//! three clients agree on what the intents mean.
//! * [`LatchClock`] — the panel latch grid, learned from `VK_KHR_present_wait` on-glass
//! stamps (measured, never queried — the Android refresh-rate lie and VRR both punish
//! trusting a reported rate). Without present-wait it degrades to a grid rooted at the
//! last submit on the mode's refresh period.
//! * [`PresentGate`] — the FIFO glass budget: one undisplayed present in flight, so the
//! swapchain's own queue can never become a standing queue (+1 refresh per slot,
//! forever — the law every bounded-FIFO pacing rediscovered on Apple). MAILBOX cannot
//! queue and never needs it.
//!
//! Everything here is pure state + arithmetic on `CLOCK_REALTIME` ns (the
//! `pf_client_core::session::now_ns` domain the on-glass stamps live in); the run loop
//! owns all clocks and Vulkan calls, which is what keeps this testable.
use std::collections::VecDeque;
/// Stale-present force-open: an undisplayed present older than this is presumed lost
/// (occluded window, wedged compositor) and the gate opens anyway, counted as `forced`
/// — reads 0 on healthy systems. The Apple/Android presenters use the same 100 ms.
const STALE_REOPEN_NS: u64 = 100_000_000;
/// The adaptive slot-pick margin's ceiling and step (Android's measured values: start
/// at 0 — a fixed lead was pure display tax on the reference device — and widen only
/// when measured misses demand it).
pub(crate) const MARGIN_STEP_NS: u64 = 500_000;
pub(crate) const MARGIN_MAX_NS: u64 = 2_500_000;
/// The decoded-frame store between the wake channel and the present call.
///
/// `capacity == 0` = newest-wins (latency intent): `submit` replaces, `take` clears.
/// `capacity 1..=3` = smoothing FIFO: preroll-to-capacity, drop-oldest on overflow,
/// an underflow after preroll re-arms the preroll (the previous frame persists on
/// glass — a repeat by omission) while headroom rebuilds.
pub(crate) struct FrameStore<T> {
capacity: usize,
frames: VecDeque<T>,
prerolled: bool,
/// Newest-wins displacements (normal operation under latency, not a fault signal).
replaced: u32,
/// FIFO drop-oldest evictions — the Apple debug line's `qDrop`.
overflow_drops: u32,
/// FIFO dry-after-preroll events — `qDry`.
underflows: u32,
}
impl<T> FrameStore<T> {
pub(crate) fn new(capacity: usize) -> FrameStore<T> {
FrameStore {
capacity,
frames: VecDeque::with_capacity(capacity.max(1) + 1),
prerolled: false,
replaced: 0,
overflow_drops: 0,
underflows: 0,
}
}
pub(crate) fn is_smoothing(&self) -> bool {
self.capacity > 0
}
pub(crate) fn is_empty(&self) -> bool {
self.frames.is_empty()
}
pub(crate) fn submit(&mut self, f: T) {
if self.capacity == 0 {
if self.frames.pop_front().is_some() {
self.replaced += 1;
}
self.frames.push_back(f);
} else {
self.frames.push_back(f);
// Drop the OLDEST past capacity: bounded added latency, the newest keeps
// flowing. Also trims a transient capacity+1 a put_back left behind.
while self.frames.len() > self.capacity {
self.frames.pop_front();
self.overflow_drops += 1;
}
}
}
pub(crate) fn take(&mut self) -> Option<T> {
if self.capacity == 0 {
return self.frames.pop_front();
}
if !self.prerolled {
// Preroll gate: without it a steady stream drains every frame on arrival
// and jitter headroom never builds (the Apple store's lesson).
if self.frames.len() < self.capacity {
return None;
}
self.prerolled = true;
}
match self.frames.pop_front() {
Some(f) => Some(f),
None => {
self.underflows += 1;
self.prerolled = false;
None
}
}
}
/// A frame taken but not presented (gate closed, present failed before consuming
/// it). Newest-wins reinserts only into an empty slot — a fresher decode wins;
/// FIFO puts it back at the front (it is the oldest).
pub(crate) fn put_back(&mut self, f: T) {
if self.capacity == 0 {
if self.frames.is_empty() {
self.frames.push_back(f);
}
} else {
self.frames.push_front(f);
}
}
/// Collapse to newest-wins for the rest of the stream (PyroWave: its plane-ring
/// retirement accounting assumes the depth-2 newest-wins hand-off, and its all-intra
/// frames make buffering pointless anyway).
///
/// Gated with its only caller: the power-user build (`--no-default-features`, which
/// the Windows ARM64 leg ships) has no PyroWave decode path, and an ungated helper
/// is dead code there.
#[cfg(feature = "pyrowave")]
pub(crate) fn force_latency(&mut self) {
if self.capacity == 0 {
return;
}
self.capacity = 0;
self.prerolled = false;
while self.frames.len() > 1 {
self.frames.pop_front();
}
}
/// Drain the window's counters: `(replaced, overflow_drops, underflows)`.
pub(crate) fn take_counters(&mut self) -> (u32, u32, u32) {
let c = (self.replaced, self.overflow_drops, self.underflows);
self.replaced = 0;
self.overflow_drops = 0;
self.underflows = 0;
c
}
}
/// The panel latch grid: a recent on-glass instant + the latch period, extrapolated
/// forward for slot targeting.
///
/// The period learner is the SHARED [`punktfunk_core::phase::PanelGrid`], not a local
/// rule. An earlier version of this clock capped the learned period at the display
/// mode's refresh, on the reasoning that a stream running below panel rate spaces its
/// presents at k×period and the cap stops a 30 fps stream claiming a 30 Hz panel. That
/// cap is the same defect the Android presenter shipped in 0.23.0: the seed is only what
/// the *mode* claims, and when the real panel is slower (a refused mode switch, a
/// compositor running its own rate) a downward-only learner pins a grid that never
/// arrives, for the whole session, with no way back. `PanelGrid` moves both ways —
/// narrowing at once, widening only after eight consecutive agreeing observations and
/// then to the narrowest of them.
///
/// What is fed to it is still the window's MIN spacing: within one window that resists
/// the k×period inflation the old cap was aimed at, while the streak requirement means a
/// genuinely slower panel is still discovered. Same grid the host-facing `LatchGrid`
/// publish reads, so the phase-lock report and the local scheduler cannot disagree.
pub(crate) struct LatchClock {
anchor_ns: u64,
/// The previous stamp, kept ACROSS calls. The run loop drains present-wait samples
/// every pass, so a "batch" is very often a single stamp — computing spacings only
/// within a batch (`windows(2)`) observed nothing at all on glass, and the learner
/// silently ran on its seed forever.
last_ns: u64,
/// Narrowest spacing seen since the last handoff to the grid, and how many have
/// accumulated. The grid is fed the MIN of a run rather than every spacing: our
/// observations are the spacing of OUR presents, which is k×period whenever the
/// stream runs below panel rate, and the min over a run is the best available
/// estimate of the true grid step.
pending_min_ns: u64,
pending_count: u32,
grid: punktfunk_core::phase::PanelGrid,
fallback_period_ns: u64,
}
/// Spacings per handoff to [`punktfunk_core::phase::PanelGrid`]. Small enough that a real
/// mode change is picked up in well under a second at any sane frame rate.
const GRID_OBSERVE_EVERY: u32 = 16;
impl LatchClock {
pub(crate) fn new(refresh_hz: u32) -> LatchClock {
LatchClock {
anchor_ns: 0,
last_ns: 0,
pending_min_ns: 0,
pending_count: 0,
grid: punktfunk_core::phase::PanelGrid::seeded(refresh_hz as i32),
fallback_period_ns: 1_000_000_000 / u64::from(refresh_hz.max(1)),
}
}
/// Fold on-glass stamps (ascending). Spacings are measured against the previous
/// stamp whatever the batching, so the loop's one-sample-per-pass drain still feeds
/// the learner.
pub(crate) fn note_batch(&mut self, stamps: &[u64]) {
for &s in stamps {
if self.last_ns != 0 && s > self.last_ns {
let d = s - self.last_ns;
// < 1 ms apart = a queued pair, not a grid step.
if d > 1_000_000 {
self.pending_min_ns = if self.pending_min_ns == 0 {
d
} else {
self.pending_min_ns.min(d)
};
self.pending_count += 1;
if self.pending_count >= GRID_OBSERVE_EVERY {
self.grid.observe(self.pending_min_ns as i64);
self.pending_min_ns = 0;
self.pending_count = 0;
}
}
}
self.last_ns = s;
}
if let Some(&last) = stamps.last() {
self.anchor_ns = last;
}
}
pub(crate) fn period_ns(&self) -> u64 {
let learned = self.grid.period_ns();
if learned > 0 {
learned as u64
} else {
self.fallback_period_ns
}
}
pub(crate) fn anchor_ns(&self) -> u64 {
self.anchor_ns
}
/// The first predicted latch strictly after `after_ns` (`anchor + k·period`). With
/// no anchor yet: one period out — callers get a usable, if unanchored, deadline.
pub(crate) fn next_slot_after(&self, after_ns: u64) -> u64 {
let p = self.period_ns();
if self.anchor_ns == 0 || after_ns < self.anchor_ns {
return after_ns.saturating_add(p);
}
let k = (after_ns - self.anchor_ns) / p + 1;
self.anchor_ns + k * p
}
}
/// Whether the panel is refreshing on a fixed grid or following our cadence.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub(crate) enum Cadence {
/// Not enough evidence yet — say nothing rather than guess.
#[default]
Unknown,
/// On-glass instants land on multiples of the panel period: a fixed-refresh panel.
Fixed,
/// On-glass instants track our present spacing instead: variable refresh is live.
Variable,
}
impl Cadence {
pub(crate) fn label(self) -> &'static str {
match self {
Cadence::Unknown => "",
Cadence::Fixed => "no",
Cadence::Variable => "yes",
}
}
}
/// Is variable refresh actually live? **Measured, never queried** — no portable query
/// exists (SDL exposes none, Wayland does not report adaptive-sync state, and Windows
/// surfaces nothing through Vulkan), and the platforms that *do* answer have been caught
/// lying before (Android reports a game-uid's down-rated refresh as the panel's).
///
/// The discriminator is quantization. On a fixed-refresh panel every on-glass instant
/// lands on the vblank grid, so the spacing between consecutive presents is always
/// ~k×period for whole k — even when the stream runs slower than the panel, where it just
/// picks a larger k. Under real VRR the panel refreshes *when we present*, so the spacing
/// follows our own cadence and sits wherever it likes relative to the grid.
///
/// So: fold each delta to its distance from the nearest multiple of the period. Tight
/// against the grid ⇒ Fixed; consistently off it ⇒ Variable. A stream running exactly at
/// panel rate is indistinguishable either way (both give delta ≈ period), which is
/// harmless — at that rate VRR has nothing to do.
pub(crate) struct CadenceProbe {
/// Off-grid distances as a fraction of the period, in thousandths.
off_grid_milli: Vec<u32>,
/// Previous stamp, kept across calls for the same reason [`LatchClock`] does: the
/// live drain hands over one sample at a time.
last_ns: u64,
/// The last round's raw reading and how many rounds have agreed — a verdict is only
/// published once [`CADENCE_STABLE_ROUNDS`] agree.
candidate: Cadence,
agree_rounds: u8,
verdict: Cadence,
}
/// Enough deltas to distinguish jitter from a real off-grid cadence.
const CADENCE_MIN_SAMPLES: usize = 24;
/// Consecutive agreeing rounds before a verdict is published.
///
/// ⭐ On glass (GNOME/Wayland, .21, 2026-08-02) the raw per-round verdict FLAPPED between
/// runs with VRR provably disabled. The cause is structural, not a tuning miss: under a
/// compositor our on-glass stamp is the compositor's release, so anything that perturbs
/// delivery — an occluded or unfocused surface being throttled, a distressed pipeline
/// missing vblanks — smears the spacings exactly the way real VRR does. This probe can
/// therefore only ever say "presents are not landing on the grid", so it demands
/// agreement across rounds and refuses evidence from a distressed window (see
/// [`CadenceProbe::note`]'s `healthy` flag) before claiming anything.
const CADENCE_STABLE_ROUNDS: u8 = 2;
/// Median off-grid distance under this fraction of a period reads as grid-locked. Present
/// stamps carry real measurement jitter (the wait returns, then we read the clock), so
/// this is deliberately loose — the two regimes differ by far more than this in practice.
const CADENCE_FIXED_MILLI: u32 = 150;
impl CadenceProbe {
pub(crate) fn new() -> CadenceProbe {
CadenceProbe {
off_grid_milli: Vec::with_capacity(64),
last_ns: 0,
candidate: Cadence::Unknown,
agree_rounds: 0,
verdict: Cadence::Unknown,
}
}
/// Fold on-glass stamps against the learned panel period. Spacings are measured
/// against the previous stamp whatever the batching.
///
/// `healthy` is the caller's statement that this window's presents were flowing
/// normally (no stale force-opens). A distressed pipeline smears spacings for reasons
/// that have nothing to do with the panel, so its evidence is dropped — the timeline
/// continuity is still advanced, it simply does not count as a sample.
pub(crate) fn note(&mut self, stamps: &[u64], period_ns: u64, healthy: bool) {
if period_ns == 0 || !healthy {
self.last_ns = stamps.last().copied().unwrap_or(self.last_ns);
return;
}
for &s in stamps {
let prev = std::mem::replace(&mut self.last_ns, s);
if prev == 0 || s <= prev {
continue;
}
let delta = s - prev;
let rem = delta % period_ns;
// Distance to the NEAREST multiple, so a delta just under k×period reads as
// close to the grid rather than a whole period away from k-1.
let off = rem.min(period_ns - rem);
self.off_grid_milli
.push((off.saturating_mul(1000) / period_ns) as u32);
// A round closes on the SAMPLE count, inside the loop — not once per call.
// Evaluating per call would make the verdict depend on how the caller happens
// to batch its stamps (one big batch = one round, forever short of the
// agreement requirement), and the live drain and the tests batch differently.
self.close_round_if_ready();
}
}
/// Publish a verdict once a round's worth of spacings agree with the previous round.
fn close_round_if_ready(&mut self) {
if self.off_grid_milli.len() >= CADENCE_MIN_SAMPLES {
self.off_grid_milli.sort_unstable();
let median = self.off_grid_milli[self.off_grid_milli.len() / 2];
let round = if median <= CADENCE_FIXED_MILLI {
Cadence::Fixed
} else {
Cadence::Variable
};
if round == self.candidate {
self.agree_rounds = self.agree_rounds.saturating_add(1);
} else {
self.candidate = round;
self.agree_rounds = 1;
}
if self.agree_rounds >= CADENCE_STABLE_ROUNDS {
self.verdict = round;
}
self.off_grid_milli.clear();
}
}
pub(crate) fn verdict(&self) -> Cadence {
self.verdict
}
/// A mode switch / display change invalidates the evidence.
pub(crate) fn reset(&mut self) {
self.off_grid_milli.clear();
self.last_ns = 0;
self.candidate = Cadence::Unknown;
self.agree_rounds = 0;
self.verdict = Cadence::Unknown;
}
}
/// The FIFO glass budget: at most one undisplayed present in flight, measured by the
/// present-wait waiter's outstanding count. Never consulted under MAILBOX/IMMEDIATE
/// (they cannot queue) or without present-wait (nothing to count with — behavior is
/// then exactly the shipped arrival pacing).
#[derive(Default)]
pub(crate) struct PresentGate {
/// Submit stamp of the newest tracked present; 0 = none yet.
last_present_ns: u64,
gated: u32,
forced: u32,
}
impl PresentGate {
/// May a new present go out? Open when nothing undisplayed is in flight; a stale
/// in-flight present (occlusion, wedged compositor) force-opens after 100 ms so the
/// stream survives, counted as `forced`.
pub(crate) fn open(&mut self, outstanding: usize, now_ns: u64) -> bool {
if outstanding == 0 {
return true;
}
if self.last_present_ns != 0
&& now_ns.saturating_sub(self.last_present_ns) > STALE_REOPEN_NS
{
self.forced += 1;
return true;
}
self.gated += 1;
false
}
pub(crate) fn note_present(&mut self, now_ns: u64) {
self.last_present_ns = now_ns;
}
/// Drain the window's counters: `(gated, forced)`.
pub(crate) fn take_counters(&mut self) -> (u32, u32) {
let c = (self.gated, self.forced);
self.gated = 0;
self.forced = 0;
c
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Newest-wins: submit replaces, take clears, put_back only fills an empty slot.
#[test]
fn newest_wins_replaces_and_putback_never_clobbers() {
let mut s: FrameStore<u32> = FrameStore::new(0);
assert!(!s.is_smoothing());
assert_eq!(s.take(), None);
s.submit(1);
s.submit(2);
s.submit(3);
assert_eq!(s.take(), Some(3), "only the newest survives");
assert_eq!(s.take(), None);
// A taken-but-unpresented frame returns — unless a fresher one arrived.
s.submit(4);
let f = s.take().unwrap();
s.put_back(f);
assert_eq!(s.take(), Some(4));
let f = s.take();
assert_eq!(f, None);
s.submit(5);
let f = s.take().unwrap();
s.submit(6);
s.put_back(f); // 6 arrived while 5 was out — 6 wins
assert_eq!(s.take(), Some(6));
assert_eq!(
s.take_counters(),
(2, 0, 0),
"two displacements, no fifo counters"
);
}
/// FIFO: preroll to capacity, drop-oldest overflow, underflow re-arms the preroll.
#[test]
fn fifo_prerolls_overflows_oldest_and_rearms_on_dry() {
let mut s: FrameStore<u32> = FrameStore::new(2);
assert!(s.is_smoothing());
s.submit(1);
assert_eq!(s.take(), None, "prerolling: below capacity, nothing vends");
s.submit(2);
assert_eq!(s.take(), Some(1), "preroll reached — FIFO order");
assert_eq!(
s.take(),
Some(2),
"once prerolled the buffer drains normally"
);
// Dry after preroll = one underflow, preroll re-arms.
assert_eq!(s.take(), None);
s.submit(3);
assert_eq!(s.take(), None, "re-armed preroll holds again");
s.submit(4);
assert_eq!(s.take(), Some(3));
// Overflow drops the OLDEST: [4] → [4,5] → 6 evicts 4 → 7 evicts 5.
s.submit(5);
s.submit(6);
s.submit(7);
assert_eq!(s.take(), Some(6));
assert_eq!(s.take(), Some(7));
let (replaced, drops, dry) = s.take_counters();
assert_eq!(replaced, 0);
assert_eq!(drops, 2, "6 evicted 4, 7 evicted 5");
assert_eq!(dry, 1);
}
/// put_back under FIFO goes to the FRONT (it is the oldest), and the transient
/// capacity+1 is trimmed by the next submit.
#[test]
fn fifo_putback_restores_order() {
let mut s: FrameStore<u32> = FrameStore::new(2);
s.submit(1);
s.submit(2);
let f = s.take().unwrap();
s.put_back(f);
assert_eq!(s.take(), Some(1), "the put-back frame is still first");
}
/// force_latency collapses a smoothing store to a newest-wins slot mid-stream.
#[cfg(feature = "pyrowave")]
#[test]
fn force_latency_collapses_to_one_slot() {
let mut s: FrameStore<u32> = FrameStore::new(3);
s.submit(1);
s.submit(2);
s.submit(3);
s.force_latency();
assert!(!s.is_smoothing());
assert_eq!(s.take(), Some(3), "only the newest survives the collapse");
s.submit(4);
s.submit(5);
assert_eq!(s.take(), Some(5));
}
/// The clock learns the min positive spacing (capped at the mode refresh), anchors
/// on the newest stamp, and extrapolates the next slot; sub-ms pairs (a queued
/// double-present) never become the period.
#[test]
fn latch_clock_learns_and_extrapolates() {
const P: u64 = 16_666_666; // 60 Hz
let mut c = LatchClock::new(60);
assert_eq!(c.period_ns(), P, "fallback = the mode refresh");
// No anchor: a usable deadline one period out.
assert_eq!(c.next_slot_after(1_000), 1_000 + P);
c.note_batch(&[1_000_000_000, 1_000_000_000 + P, 1_000_000_000 + 2 * P]);
assert_eq!(c.period_ns(), P);
assert_eq!(c.anchor_ns(), 1_000_000_000 + 2 * P);
let next = c.next_slot_after(c.anchor_ns());
assert_eq!(next, 1_000_000_000 + 3 * P);
// Mid-slot query lands on the same boundary; a later one steps whole periods.
assert_eq!(c.next_slot_after(next - 1), next);
assert_eq!(c.next_slot_after(next), next + P);
// A queued pair (< 1 ms apart) must not poison the period.
c.note_batch(&[2_000_000_000, 2_000_000_500]);
assert_eq!(c.period_ns(), P);
assert_eq!(c.anchor_ns(), 2_000_000_500, "the anchor still advances");
// A stream presenting every OTHER refresh spaces its glass stamps at 2×P. One
// such window must NOT move the grid — the shared learner needs a streak before
// it will widen, which is what keeps a briefly-slow stream from claiming a slow
// panel while still allowing a genuinely slower display to be discovered.
c.note_batch(&[3_000_000_000, 3_000_000_000 + 2 * P]);
assert_eq!(c.period_ns(), P, "one wide window is not a slower panel");
// A single stamp re-anchors without touching the period.
c.note_batch(&[5_000_000_000]);
assert_eq!(c.anchor_ns(), 5_000_000_000);
assert_eq!(c.period_ns(), P);
// A faster panel learns its own finer grid.
let mut fast = LatchClock::new(120);
fast.note_batch(&[1_000_000_000, 1_008_333_333]);
assert_eq!(fast.period_ns(), 8_333_333);
}
/// ⭐ The live loop drains present-wait samples EVERY pass, so stamps arrive one at a
/// time. Measuring spacings only within a batch meant the learner observed nothing on
/// glass and silently ran on its seed (found on .21, 2026-08-02: `period_us` read back
/// exactly the 60 Hz fallback while the panel really was 60 Hz — correct by luck, and
/// wrong the moment the mode lies).
#[test]
fn latch_clock_learns_from_one_sample_at_a_time() {
const REAL: u64 = 16_666_666;
let mut c = LatchClock::new(120); // seeded too fast, as a refused mode switch would
let mut t = 1_000_000_000u64;
for _ in 0..(GRID_OBSERVE_EVERY * 8 + 8) {
t += REAL;
c.note_batch(&[t]); // ONE stamp per call — the live shape
}
assert_eq!(
c.period_ns(),
REAL,
"single-stamp batches must still feed the grid learner"
);
assert_eq!(c.anchor_ns(), t);
}
/// The mode's refresh is a CLAIM, not a measurement — a refused mode switch or a
/// compositor running its own rate leaves the seed too fast. The old downward-only
/// cap pinned that wrong grid for the session (the Android 0.23.0 defect); the
/// shared learner climbs back out once the evidence is consistent.
#[test]
fn latch_clock_recovers_from_a_seed_faster_than_the_real_panel() {
const REAL: u64 = 16_666_666; // the panel is really 60 Hz…
let mut c = LatchClock::new(120); // …but the mode claimed 120
assert_eq!(c.period_ns(), 8_333_333, "seeded from the claim");
// Consistent 60 Hz evidence. The grid is fed the MIN of every
// GRID_OBSERVE_EVERY spacings, and PanelGrid widens only after 8 agreeing
// observations, so a real widen needs 8 × GRID_OBSERVE_EVERY spacings — the
// deliberate cost of not letting one slow patch redefine the panel.
let mut t = 1_000_000_000u64;
for _ in 0..(GRID_OBSERVE_EVERY * 8 + GRID_OBSERVE_EVERY) {
t += REAL;
c.note_batch(&[t]);
}
assert_eq!(
c.period_ns(),
REAL,
"a sustained slower grid is adopted instead of aimed past forever"
);
}
/// The VRR discriminator: presents landing on the vblank grid read Fixed, presents
/// landing wherever our own cadence puts them read Variable — including the case that
/// matters most, a stream SLOWER than the panel, where a fixed panel still quantizes
/// to a larger whole multiple.
#[test]
fn cadence_probe_separates_grid_locked_from_variable() {
const P: u64 = 8_333_333; // 120 Hz
// Enough spacings for CADENCE_STABLE_ROUNDS full rounds: a verdict is published
// only once consecutive rounds agree (on glass a single round FLAPPED).
const ROUNDS: u64 = (CADENCE_MIN_SAMPLES as u64) * (CADENCE_STABLE_ROUNDS as u64) + 4;
// Fixed panel, stream at panel rate: every delta is exactly one period.
let mut probe = CadenceProbe::new();
assert_eq!(probe.verdict(), Cadence::Unknown, "no evidence yet");
let stamps: Vec<u64> = (0..ROUNDS).map(|i| 1_000_000_000 + i * P).collect();
probe.note(&stamps, P, true);
assert_eq!(probe.verdict(), Cadence::Fixed);
// Fixed panel, stream at HALF panel rate: deltas are 2×P — still grid-locked.
let mut probe = CadenceProbe::new();
let stamps: Vec<u64> = (0..ROUNDS).map(|i| 1_000_000_000 + i * 2 * P).collect();
probe.note(&stamps, P, true);
assert_eq!(
probe.verdict(),
Cadence::Fixed,
"a slower stream on a fixed panel picks a larger k, it does not leave the grid"
);
// Fixed panel with realistic measurement jitter (±0.5 ms on an 8.3 ms period)
// must not read as variable.
let mut probe = CadenceProbe::new();
let jitter = [0i64, 300_000, -250_000, 120_000, -400_000, 80_000];
let stamps: Vec<u64> = (0..ROUNDS as usize)
.map(|i| (1_000_000_000 + i as i64 * P as i64 + jitter[i % jitter.len()]) as u64)
.collect();
probe.note(&stamps, P, true);
assert_eq!(probe.verdict(), Cadence::Fixed, "jitter is not VRR");
// VRR live: a 100 fps stream on a 120 Hz-max panel. 10 ms is not a multiple of
// 8.33 ms, so every present sits off the grid.
let mut probe = CadenceProbe::new();
let stamps: Vec<u64> = (0..ROUNDS)
.map(|i| 1_000_000_000 + i * 10_000_000)
.collect();
probe.note(&stamps, P, true);
assert_eq!(probe.verdict(), Cadence::Variable);
// A display change throws the evidence away rather than carrying a stale verdict.
probe.reset();
assert_eq!(probe.verdict(), Cadence::Unknown);
// Below the sample floor nothing is claimed.
let mut probe = CadenceProbe::new();
probe.note(&[1_000_000_000, 1_010_000_000, 1_020_000_000], P, true);
assert_eq!(probe.verdict(), Cadence::Unknown);
// ⭐ THE SHAPE THE LIVE LOOP ACTUALLY PRODUCES: the run loop drains present-wait
// samples every pass, so stamps arrive ONE AT A TIME. Measuring spacings only
// within a batch observed nothing at all on glass — `vrr` stayed Unknown and the
// latch clock ran on its seed forever. Found on .21, 2026-08-02.
let mut probe = CadenceProbe::new();
for i in 0..ROUNDS {
probe.note(&[1_000_000_000 + i * 10_000_000], P, true); // 100 fps, off a 120 Hz grid
}
assert_eq!(
probe.verdict(),
Cadence::Variable,
"one-sample batches must still yield spacings"
);
// A period we never learned can't discriminate anything.
let mut probe = CadenceProbe::new();
let stamps: Vec<u64> = (0..ROUNDS)
.map(|i| 1_000_000_000 + i * 10_000_000)
.collect();
probe.note(&stamps, 0, true);
assert_eq!(probe.verdict(), Cadence::Unknown);
}
/// ⭐ Batching must not change the verdict. The same spacings delivered as one big
/// batch, or one stamp at a time, must reach the same conclusion — the live loop
/// drains one at a time while tests hand over vectors, and an evaluation keyed to
/// call boundaries silently made the two disagree.
#[test]
fn cadence_verdict_is_independent_of_batching() {
const P: u64 = 8_333_333;
let n = (CADENCE_MIN_SAMPLES as u64) * (CADENCE_STABLE_ROUNDS as u64) + 4;
let stamps: Vec<u64> = (0..n).map(|i| 1_000_000_000 + i * P).collect();
let mut bulk = CadenceProbe::new();
bulk.note(&stamps, P, true);
let mut drip = CadenceProbe::new();
for s in &stamps {
drip.note(&[*s], P, true);
}
assert_eq!(bulk.verdict(), Cadence::Fixed);
assert_eq!(drip.verdict(), bulk.verdict(), "batching must not matter");
}
/// Gate: open at zero outstanding, closed at one, force-open past the stale bound.
#[test]
fn gate_budgets_one_undisplayed_present() {
let mut g = PresentGate::default();
let t0 = 1_000_000_000u64;
assert!(g.open(0, t0));
g.note_present(t0);
assert!(!g.open(1, t0 + 8_000_000), "one in flight — hold");
assert!(
g.open(1, t0 + STALE_REOPEN_NS + 1),
"stale in-flight present force-opens"
);
let (gated, forced) = g.take_counters();
assert_eq!((gated, forced), (1, 1));
assert_eq!(g.take_counters(), (0, 0), "counters drain");
}
}
+492 -45
View File
@@ -18,12 +18,15 @@
use crate::input::{Capture, FingerPhase};
use crate::overlay::{FrameCtx, Overlay, OverlayAction, OverlayFrame, SessionPhase};
use crate::present_pace::{
Cadence, CadenceProbe, FrameStore, LatchClock, PresentGate, MARGIN_MAX_NS, MARGIN_STEP_NS,
};
use crate::touch::Abs;
use crate::vk::{FrameInput, Presenter};
use anyhow::{Context as _, Result};
use pf_client_core::gamepad::GamepadService;
use pf_client_core::session::{self, SessionEvent, SessionHandle, SessionParams, Stats};
use pf_client_core::trust::{MouseMode, StatsVerbosity, TouchMode};
use pf_client_core::trust::{MouseMode, PresentPriority, StatsVerbosity, TouchMode};
use pf_client_core::video::VulkanDecodeDevice;
use pf_client_core::video::{DecodedFrame, DecodedImage};
use punktfunk_core::client::NativeClient;
@@ -63,6 +66,20 @@ pub struct SessionOpts {
/// work profile that streams on a second screen and still Alt-Tabs here. Never applies
/// under the `desktop` mouse model, which is something you Alt-Tab *away* from.
pub inhibit_shortcuts: bool,
/// Presentation intent ([`Settings::present_priority`] resolved): `Latency` keeps the
/// shipped arrival pacing (newest-wins, present the moment a frame can go out);
/// `Smooth { buffer }` runs the smoothing FIFO drained one frame per latch slot
/// (design/desktop-presentation-rebuild.md). `PUNKTFUNK_PRESENTER=arrival` overrides
/// the whole engine back to the legacy drain for field A/B without a rebuild.
pub present_priority: PresentPriority,
/// Tear-free presentation ([`Settings::vsync`], default on). Off asks for a tearing
/// present mode for the lowest possible latch — best-effort, and the mode that
/// actually took is named in the stats line.
pub vsync: bool,
/// Let a variable-refresh display follow the stream cadence ([`Settings::allow_vrr`],
/// default on) — prefers the present mode that drives VRR panels directly when the
/// session starts fullscreen.
pub allow_vrr: bool,
/// Emit the `{"ready":true}` stdout line after the first presented frame.
pub json_status: bool,
/// Called once on `Connected` with the host's fingerprint (trust persistence is the
@@ -213,8 +230,47 @@ struct StreamState {
// capture→displayed (host-clock corrected) p50+p95, display = decoded→displayed p50.
win_e2e_us: Vec<u64>,
win_disp_us: Vec<u64>,
/// The display stage's two halves (present-timing sessions only): decoded→submit and
/// submit→on-glass. See [`PresentedWindow::pace_ms`].
win_pace_us: Vec<u64>,
win_latch_us: Vec<u64>,
win_start: Instant,
presented: PresentedWindow,
/// The intent engine (design/desktop-presentation-rebuild.md WP2): the decoded-frame
/// store between the wake channel and the present call — a newest-wins slot under
/// the latency intent (behaviorally the shipped drain), the smoothing FIFO under
/// smoothness. NOTE: a smoothing store holds decoder-pool frames (Vulkan-Video
/// AVFrames) up to `buffer` deep on top of the depth-2 wake channels — within pool
/// headroom for 1..=3, but any deeper store must revisit pool sizing.
store: FrameStore<DecodedFrame>,
/// The panel latch grid (present-wait glass stamps; submit-anchored fallback) — the
/// smoothness slot clock, and the values published to the host-facing `latch_grid`.
clock: LatchClock,
/// The FIFO glass budget (one undisplayed present in flight) — inert off FIFO modes
/// or without present timing.
gate: PresentGate,
/// Is variable refresh actually live? Measured from the same on-glass stamps (no
/// portable query exists) — see [`CadenceProbe`].
cadence: CadenceProbe,
/// The DISPLAY MODE's refresh period — the vblank grid presents quantize to when
/// VRR is off, and so the cadence probe's reference. Deliberately not the learned
/// period (see the probe's call site).
mode_period_ns: u64,
/// The latch slot the last smoothness present served (one present per slot); 0 =
/// none yet.
last_target_ns: u64,
/// Smoothness slot-pick margin: starts 0 (a fixed lead is pure display tax —
/// measured on Android), widens +500 µs per >2-miss window toward 2.5 ms.
margin_ns: u64,
/// This window's latch misses (a present that reached glass > 1.5 latch periods
/// after submit) — the adaptive margin's error signal.
win_misses: u32,
/// This window's peak undisplayed-presents-in-flight (present timing only).
win_out_max: usize,
/// One-shot log latch: smoothness was requested but a PyroWave stream collapsed the
/// store to latency (its plane-ring retirement assumes the newest-wins hand-off).
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
pyro_latency_forced: bool,
// Hardware-path health: a failure streak (or a device with no import support at
// all) demotes the decoder to software via the shared flag — once per session.
dmabuf_demoted: bool,
@@ -279,6 +335,8 @@ impl StreamState {
params: SessionParams,
force_software: Arc<AtomicBool>,
wake: sdl3::event::EventSender,
priority: PresentPriority,
native_refresh_hz: u32,
) -> StreamState {
let profile = params.profile.clone();
// The presenter's half of phase-locked capture: it writes the latch grid the
@@ -316,8 +374,21 @@ impl StreamState {
hdr_untonemapped: false,
win_e2e_us: Vec::with_capacity(256),
win_disp_us: Vec::with_capacity(256),
win_pace_us: Vec::with_capacity(256),
win_latch_us: Vec::with_capacity(256),
win_start: Instant::now(),
presented: PresentedWindow::default(),
store: FrameStore::new(usize::from(priority.fifo_capacity())),
clock: LatchClock::new(native_refresh_hz),
gate: PresentGate::default(),
cadence: CadenceProbe::new(),
mode_period_ns: 1_000_000_000 / u64::from(native_refresh_hz.max(1)),
last_target_ns: 0,
margin_ns: 0,
win_misses: 0,
win_out_max: 0,
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
pyro_latency_forced: false,
dmabuf_demoted: false,
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
pyro_present_warned: false,
@@ -356,6 +427,25 @@ impl StreamState {
}
self.handle.stop.store(true, Ordering::SeqCst);
}
/// The event-loop wait bound: a smoothness stream with buffered frames sleeps only
/// to its next latch-slot deadline; everything else keeps the 15 ms housekeeping
/// tick (frames, input, and present completions all wake the loop early anyway).
fn wake_timeout(&self) -> Duration {
const TICK: Duration = Duration::from_millis(15);
if !self.store.is_smoothing() || self.store.is_empty() {
return TICK;
}
let now = session::now_ns();
let mut target = self
.clock
.next_slot_after(now.saturating_add(self.margin_ns));
if target == self.last_target_ns {
// This slot is already served — the next boundary is the deadline.
target += self.clock.period_ns();
}
Duration::from_nanos(target.saturating_sub(now)).clamp(Duration::from_millis(1), TICK)
}
}
/// Whether a present error is `VK_ERROR_DEVICE_LOST` anywhere in its chain. A lost
@@ -438,9 +528,43 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
let instance_exts = window
.vulkan_instance_extensions()
.map_err(|e| anyhow::anyhow!("vulkan instance extensions: {e}"))?;
let mut presenter = Presenter::new(&window, &instance_exts).context("vulkan presenter")?;
let mut presenter = Presenter::new(
&window,
&instance_exts,
crate::vk::PresentPref {
vsync: opts.vsync,
allow_vrr: opts.allow_vrr,
fullscreen: opts.fullscreen,
// `vrr_fifo_opt_in` (env) and `fifo_latest_ready` (device capability) are
// both resolved inside `Presenter::new` — the swapchain owns those, so every
// caller gets the same answer. `..Default` keeps this site from breaking each
// time the struct learns another one.
..Default::default()
},
)
.context("vulkan presenter")?;
// A valid black frame immediately — the window is honest while the connect runs.
presenter.present(&window, FrameInput::Redraw, None)?;
// `PUNKTFUNK_PRESENTER=arrival` — the legacy drain, the intent engine's field-A/B
// kill switch (the Android sysprop pattern: no rebuild to bisect a pacing suspicion).
let arrival_override = std::env::var("PUNKTFUNK_PRESENTER").ok().as_deref() == Some("arrival");
let present_priority = if arrival_override {
tracing::info!("PUNKTFUNK_PRESENTER=arrival — presentation pacing disabled");
PresentPriority::Latency
} else {
opts.present_priority
};
let pacing_active = !arrival_override;
let present_debug = std::env::var_os("PUNKTFUNK_PRESENT_DEBUG").is_some();
// Present completions wake the loop exactly like decoded frames: a glass-gate
// reopen or a smoothness slot must not wait out the event timeout.
{
let sender = events.event_sender();
presenter.set_present_wake(Box::new(move || {
let _ = sender.push_custom_event(FrameWake);
}));
}
// Browse mode is "ready" the moment the library window presents — there may never be
// a stream. (Single mode announces on the first VIDEO frame instead, further down, so
// a shell only yields to a window that actually shows the stream.)
@@ -517,6 +641,8 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
params,
force_software,
events.event_sender(),
present_priority,
native.refresh_hz,
))
}
ModeCtl::Browse(_) => None,
@@ -544,8 +670,11 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
// forwarder's FrameWake) all land in this one queue, so the loop wakes exactly
// when there is work — a short-timeout poll here burned a full core (measured;
// the timeout only bounds stop-flag/pump-tick latency now). In browse-idle the
// per-iteration FIFO present vsync-throttles the loop anyway.
let timeout = Duration::from_millis(15);
// per-iteration FIFO present vsync-throttles the loop anyway. A smoothness
// stream tightens the bound to its next latch-slot deadline.
let timeout = stream
.as_ref()
.map_or(Duration::from_millis(15), |st| st.wake_timeout());
let first = event_pump.wait_event_timeout(timeout);
let mut queued: Vec<Event> = Vec::new();
if let Some(e) = first {
@@ -608,6 +737,29 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
}
}
}
// Dragged to another monitor (or the mode changed under us): the
// latch grid and the VRR verdict both belong to the OLD panel. The
// refresh rate used to be read once at startup and never revisited,
// so a 60 Hz-seeded clock would keep pacing a 144 Hz panel.
WindowEvent::DisplayChanged(..) => {
let hz = window
.get_display()
.and_then(|d| d.get_mode())
.map(|m| m.refresh_rate.round().max(0.0) as u32)
.unwrap_or(0);
if let Some(st) = stream.as_mut() {
if hz > 0 {
st.clock = LatchClock::new(hz);
st.mode_period_ns = 1_000_000_000 / u64::from(hz);
}
st.cadence.reset();
st.last_target_ns = 0;
tracing::info!(
refresh_hz = hz,
"display changed — relearning the latch grid"
);
}
}
WindowEvent::Exposed => {
presenter.present(&window, FrameInput::Redraw, overlay_frame.as_ref())?;
}
@@ -1032,6 +1184,8 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
*params,
force_software,
events.event_sender(),
present_priority,
native.refresh_hz,
));
if let Some(o) = overlay.as_mut() {
o.session_phase(SessionPhase::Connecting);
@@ -1279,11 +1433,148 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
presenter.set_hdr_metadata(m);
}
}
let mut newest: Option<DecodedFrame> = None;
while let Ok(f) = st.frames.try_recv() {
newest = Some(f);
// Present-wait completions drive the latch clock, the glass gate, and the
// host-facing grid — drained every pass (a 1 Hz batch would starve all
// three; the waiter's SDL wake pairs with this so completions never wait
// out the event timeout).
if presenter.present_timing_active() {
let samples = presenter.take_presented_samples();
if !samples.is_empty() {
let clock_offset_ns = st
.clock_offset
.as_ref()
.map_or(0, |o| o.load(Ordering::Relaxed));
let period = st.clock.period_ns();
let mut stamps = Vec::with_capacity(samples.len());
for s in &samples {
let e2e = (s.displayed_ns as i128 + clock_offset_ns as i128
- s.pts_ns as i128)
.max(0) as u64;
if e2e > 0 && e2e < 10_000_000_000 {
st.win_e2e_us.push(e2e / 1000);
}
st.win_disp_us
.push(s.displayed_ns.saturating_sub(s.decoded_ns) / 1000);
// The display split (WP4): our pipeline vs the vsync latch. Only
// meaningful with true glass stamps, which is exactly when this
// branch runs.
st.win_pace_us
.push(s.submitted_ns.saturating_sub(s.decoded_ns) / 1000);
st.win_latch_us
.push(s.displayed_ns.saturating_sub(s.submitted_ns) / 1000);
// Latch miss (the adaptive margin's error signal): glass later
// than one panel period past submit, PLUS the lead we already
// applied — i.e. the slot we aimed at was missed. Measuring the
// real latch rather than the store's own evictions is the
// Android 0.23.0 correction: policy drops happen whenever the
// stream out-runs the panel and say nothing about the latch, and
// widening on them walked the margin to its ceiling on healthy
// devices, re-imposing the very display latency it had removed.
if st.store.is_smoothing()
&& s.displayed_ns.saturating_sub(s.submitted_ns) > period + st.margin_ns
{
st.win_misses += 1;
}
stamps.push(s.displayed_ns);
}
st.clock.note_batch(&stamps);
// Same stamps answer "is VRR live" — the panel either quantizes them
// to its grid or follows our cadence. Evidence only counts from a
// window whose presents were flowing normally: a distressed pipeline
// (stale force-opens) smears spacings for reasons that have nothing
// to do with the panel, and on glass that flapped the verdict.
//
// ⚠ The reference is the DISPLAY MODE's period, NOT the learned one.
// The learned grid comes from our own present spacings, and a stream
// running below panel rate only ever produces multiples ≥ its frame
// interval — so the learner adopts our cadence as "the grid" and every
// delta then looks on-grid by construction. Measured on .21
// (2026-08-02): a 40-50 fps stream on a 60 Hz panel learned 18-22 ms
// and the probe reported VRR on a display with VRR provably disabled.
// The vblank grid is the mode's refresh; that is what presents
// quantize to when VRR is off.
//
// ⚠⚠ And it is only asked under a FIFO-family mode. The whole test
// rests on "with VRR off, a present waits for vblank" — MAILBOX and
// IMMEDIATE deliberately break that, so their stamps are never
// grid-quantized and the probe would call every mailbox session VRR.
// Measured on .21: same panel, same second — fifo read `no`
// (correct, period 16.56 ms), mailbox read `yes` (wrong). Outside
// FIFO the honest answer is "cannot tell", i.e. Unknown.
let healthy = st.presented.forced == 0;
if presenter.vblank_locked() {
st.cadence.note(&stamps, st.mode_period_ns, healthy);
}
// Phase-locked capture, the presenter's half: publish the grid the
// local clock just learned — a recent TRUE on-glass instant plus
// the latch period — for the pump's ~1 Hz PhaseReport. One learner
// feeds both, so the report and the scheduler cannot disagree.
if let Some(grid) = &st.latch_grid {
grid.period_ns
.store(st.clock.period_ns(), Ordering::Relaxed);
grid.anchor_ns
.store(st.clock.anchor_ns(), Ordering::Relaxed);
}
}
}
if let Some(f) = newest {
// Intake into the intent store: a newest-wins slot under latency (the
// shipped drain, now with displacement counters), the smoothing FIFO under
// smoothness. PyroWave collapses smoothness to latency for the stream: its
// plane-ring retirement accounting assumes the newest-wins hand-off
// (`video_pyrowave::RETIRE_HANDOVERS`), and all-intra frames make
// buffering moot anyway.
while let Ok(f) = st.frames.try_recv() {
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
if st.store.is_smoothing() && matches!(f.image, DecodedImage::PyroWave(_)) {
st.store.force_latency();
if !st.pyro_latency_forced {
st.pyro_latency_forced = true;
tracing::info!(
"PyroWave stream — smoothness buffering does not apply \
(latency pacing)"
);
}
}
st.store.submit(f);
}
// One frame out, by intent: latency takes the newest whenever the glass
// gate allows; smoothness serves at most one frame per latch slot (the
// preroll/underflow behavior lives in the store).
let now_ns = session::now_ns();
let mut slot_target = 0u64;
let mut to_present = if st.store.is_smoothing() {
let target = st
.clock
.next_slot_after(now_ns.saturating_add(st.margin_ns));
if target != st.last_target_ns {
slot_target = target;
st.store.take()
} else {
None
}
} else {
st.store.take()
};
// The FIFO glass budget: one undisplayed present in flight, so the
// swapchain's own FIFO can never become a standing queue (a measured
// 11-13 ms at 60 Hz on MAILBOX-less drivers). Only FIFO modes queue and
// only present timing can count, so everywhere else this stays inert and
// behavior is the shipped arrival pacing.
if pacing_active && presenter.needs_glass_gate() && presenter.present_timing_active() {
if let Some(f) = to_present.take() {
if st.gate.open(presenter.presents_outstanding(), now_ns) {
to_present = Some(f);
} else {
// Parked: a newest-wins store replaces it if a fresher frame
// lands; the waiter's wake (or the 100 ms stale force-open)
// retries.
st.store.put_back(f);
}
}
}
if let Some(f) = to_present {
// Resize END: a frame at the steered target size means the sharp new-mode
// picture is here — lift the scrim. A no-op unless a switch is in flight.
let (fw, fh) = f.image.dimensions();
@@ -1472,6 +1763,12 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
};
if did_present {
presented_video = true;
// Smoothness: this latch slot is served — one present per slot.
// (Set only on success: a gated or failed present leaves the slot
// open for the retry.)
if slot_target != 0 {
st.last_target_ns = slot_target;
}
if opts.json_status && !st.ready_announced {
st.ready_announced = true;
println!("{{\"ready\":true}}");
@@ -1481,6 +1778,8 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
// e2e/display samples arrive via `take_presented_samples` with a
// TRUE on-glass stamp instead of the submit-time one below.
presenter.note_presented(pts_ns, decoded_ns);
st.gate.note_present(now_ns);
st.win_out_max = st.win_out_max.max(presenter.presents_outstanding());
} else {
let displayed_ns = session::now_ns();
// The `displayed` stamp (same clamp rules as the pump's windows).
@@ -1495,59 +1794,81 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
}
st.win_disp_us
.push(displayed_ns.saturating_sub(decoded_ns) / 1000);
// No glass stamps on this stack: the submit instant anchors an
// approximate grid on the mode's refresh period, so smoothness
// still drains one frame per (approximate) slot.
st.clock.note_batch(&[displayed_ns]);
}
}
}
// Fold the presenter window into the shared stats line once per second.
// (The on-glass samples themselves are drained every pass above — they
// drive the latch clock and glass gate, not just this fold.)
if st.win_start.elapsed() >= Duration::from_secs(1) {
// On-glass samples the present-wait waiter completed this window (empty
// when timing is inactive — the legacy submit-time pushes fill in then).
let clock_offset_ns = st
.clock_offset
.as_ref()
.map_or(0, |o| o.load(Ordering::Relaxed));
let samples = presenter.take_presented_samples();
// Phase-locked capture, the presenter's half: publish this window's latch
// grid — a recent TRUE on-glass instant plus the panel period — for the
// pump's ~1 Hz PhaseReport. The period is the min positive spacing of
// consecutive on-glass stamps (Apple's method: honest under VRR), capped
// by the display mode's refresh — under arrival-paced MAILBOX a stream
// running below the panel rate spaces its presents at k×period, and the
// cap keeps a 30 fps stream from claiming a 30 Hz panel grid.
if let Some(grid) = &st.latch_grid {
if let Some(last) = samples.last() {
let refresh_period = 1_000_000_000u64 / u64::from(native.refresh_hz.max(1));
let min_delta = samples
.windows(2)
.map(|w| w[1].displayed_ns.saturating_sub(w[0].displayed_ns))
.filter(|&d| d > 1_000_000) // < 1 ms apart = queued pair, not a grid step
.min()
.unwrap_or(refresh_period);
grid.period_ns
.store(min_delta.min(refresh_period), Ordering::Relaxed);
grid.anchor_ns.store(last.displayed_ns, Ordering::Relaxed);
}
}
for s in samples {
let e2e = (s.displayed_ns as i128 + clock_offset_ns as i128 - s.pts_ns as i128)
.max(0) as u64;
if e2e > 0 && e2e < 10_000_000_000 {
st.win_e2e_us.push(e2e / 1000);
}
st.win_disp_us
.push(s.displayed_ns.saturating_sub(s.decoded_ns) / 1000);
}
let (e2e_p50, e2e_p95) = session::window_percentiles(&mut st.win_e2e_us);
let (disp_p50, _) = session::window_percentiles(&mut st.win_disp_us);
let (pace_p50, _) = session::window_percentiles(&mut st.win_pace_us);
let (latch_p50, _) = session::window_percentiles(&mut st.win_latch_us);
// Drained ONCE per window and shared by the HUD and the log line below —
// a second `take_counters` would read zeros.
let (replaced, q_drop, q_dry) = st.store.take_counters();
let (gated, forced) = st.gate.take_counters();
st.presented = PresentedWindow {
e2e_p50_ms: e2e_p50 as f32 / 1000.0,
e2e_p95_ms: e2e_p95 as f32 / 1000.0,
display_ms: disp_p50 as f32 / 1000.0,
pace_ms: pace_p50 as f32 / 1000.0,
latch_ms: latch_p50 as f32 / 1000.0,
mode: presenter.present_mode_name(),
vrr: st.cadence.verdict(),
smoothing: st.store.is_smoothing(),
q_drop,
q_dry,
gated,
forced,
};
st.win_e2e_us.clear();
st.win_disp_us.clear();
st.win_pace_us.clear();
st.win_latch_us.clear();
st.win_start = Instant::now();
// Adaptive slot margin (the Android presenter's measured recipe):
// start at 0 — a fixed lead is pure display tax — and widen one step
// per window whose measured latch misses demand it. One-way per
// stream; the next stream restarts at 0.
if st.store.is_smoothing() && st.win_misses > 2 && st.margin_ns < MARGIN_MAX_NS {
st.margin_ns = (st.margin_ns + MARGIN_STEP_NS).min(MARGIN_MAX_NS);
tracing::info!(
margin_us = st.margin_ns / 1000,
misses = st.win_misses,
"smoothness slot margin widened (measured latch misses)"
);
}
// The 1 Hz presenter line (the Apple `pf-present` analogue): emitted
// when anything moved, or always under PUNKTFUNK_PRESENT_DEBUG=1 —
// the field-triage instrument for the intent engine.
if pacing_active && (present_debug || q_drop + q_dry + gated + forced > 0) {
tracing::info!(
smoothing = st.presented.smoothing,
mode = st.presented.mode,
vrr = st.presented.vrr.label(),
replaced,
q_drop,
q_dry,
gated,
forced,
misses = st.win_misses,
out_max = st.win_out_max,
pace_ms = st.presented.pace_ms,
latch_ms = st.presented.latch_ms,
period_us = st.clock.period_ns() / 1000,
margin_us = st.margin_ns / 1000,
"presenter window"
);
}
st.win_misses = 0;
st.win_out_max = 0;
}
}
@@ -2007,6 +2328,32 @@ struct PresentedWindow {
e2e_p50_ms: f32,
e2e_p95_ms: f32,
display_ms: f32,
/// The display stage split (design/desktop-presentation-rebuild.md WP4):
/// `pace` = decoded → present-submit (our own pipeline), `latch` = submit → on-glass
/// (the presentation engine's queue + the vblank wait). Both `0` without
/// `VK_KHR_present_wait`, where the two are not separable — the HUD then shows the
/// unsplit figure rather than inventing a zero latch.
///
/// This split is what makes a high `display` self-diagnosing: latch dominating means
/// the vsync/queue floor (or a standing queue), pace dominating means us.
/// `pace` is also the honest cross-platform twin of the Apple client's shaved
/// number — Apple subtracts its measured OS present floor, and the latch IS our
/// floor, so `pace` is what remains on both sides of that comparison.
pace_ms: f32,
latch_ms: f32,
/// The live swapchain present mode (`mailbox`/`fifo`/…). Shown because a mode is
/// chosen from what the surface offers, so "why is my latch a refresh long" is
/// usually answered by a MAILBOX request having landed on FIFO.
mode: &'static str,
/// Whether variable refresh is measurably live (never claimed without evidence).
vrr: Cadence,
/// Presenter-engine counters for the window: the smoothing FIFO's overflow drops and
/// post-preroll underflows, and the FIFO glass gate's holds/stale force-opens.
smoothing: bool,
q_drop: u32,
q_dry: u32,
gated: u32,
forced: u32,
}
/// The capture hints (`ui_stream` parity — the words the user reads while released).
@@ -2112,6 +2459,15 @@ fn stats_text(
" · decode {:.1} · display {:.1} ms",
s.decode_ms, p.display_ms
));
// The display split (WP4). Only with true on-glass stamps — without them the
// two halves are not separable and the unsplit figure stands alone rather than
// implying a zero latch.
if p.latch_ms > 0.0 || p.pace_ms > 0.0 {
text.push_str(&format!(
" (pace {:.1} + latch {:.1})",
p.pace_ms, p.latch_ms
));
}
// Extended 0xCF host-stage split (T0.1): its own line so the per-stage attribution
// (queue → encode → seal/xfer → pace) reads as the host pipeline in order.
if s.staged {
@@ -2120,6 +2476,32 @@ fn stats_text(
s.host_queue_ms, s.host_encode_ms, s.host_xfer_ms, s.host_pace_ms
));
}
// The presenter line: the swapchain mode that is actually live, the chosen
// intent, and the engine's own counters. Present-mode alone answers most
// "why is my latch a whole refresh" questions; the counters only render when
// they are non-zero, so a healthy latency session shows just the mode.
if !p.mode.is_empty() {
text.push_str(&format!("\npresent: {}", p.mode));
// Only once measured — an unproven "vrr no" would be a claim, not a reading.
if p.vrr != Cadence::Unknown {
text.push_str(&format!(" · vrr {}", p.vrr.label()));
}
if p.smoothing {
text.push_str(" · smoothing");
}
if p.q_drop > 0 {
text.push_str(&format!(" · qdrop {}", p.q_drop));
}
if p.q_dry > 0 {
text.push_str(&format!(" · qdry {}", p.q_dry));
}
if p.gated > 0 {
text.push_str(&format!(" · gated {}", p.gated));
}
if p.forced > 0 {
text.push_str(&format!(" · forced {}", p.forced));
}
}
}
if s.lost > 0 {
text.push_str(&format!("\nlost {} ({:.1}%)", s.lost, s.lost_pct));
@@ -2393,6 +2775,7 @@ mod tests {
e2e_p50_ms: 6.4,
e2e_p95_ms: 9.1,
display_ms: 1.1,
..Default::default()
},
)
}
@@ -2430,6 +2813,70 @@ mod tests {
!normal.contains("queue"),
"host-stage split is Detailed-only"
);
assert!(
!detailed.contains("pace 1.1"),
"no glass stamps in this sample — the display stage stays unsplit"
);
}
/// WP4: with true on-glass stamps the display stage reads as its two halves, the
/// live present mode is named, and the engine counters render only when non-zero —
/// so a healthy latency session shows the mode and nothing else. Without glass
/// stamps (no `VK_KHR_present_wait`) the split is absent rather than a zero latch.
#[test]
fn detailed_splits_display_into_pace_and_latch() {
let (s, mut p) = sample();
p.display_ms = 12.4;
p.pace_ms = 1.1;
p.latch_ms = 11.3;
p.mode = "fifo";
let split = stats_text(
StatsVerbosity::Detailed,
"m",
&s,
&p,
false,
false,
false,
None,
);
assert!(split.contains("display 12.4 ms (pace 1.1 + latch 11.3)"));
assert!(split.contains("\npresent: fifo"));
assert!(
!split.contains("qdrop") && !split.contains("gated") && !split.contains("smoothing"),
"quiet counters stay off the HUD: {split}"
);
// The smoothing FIFO and the glass gate surface once they actually do something.
p.smoothing = true;
p.q_drop = 2;
p.q_dry = 1;
p.gated = 7;
p.forced = 1;
let busy = stats_text(
StatsVerbosity::Detailed,
"m",
&s,
&p,
false,
false,
false,
None,
);
assert!(busy.contains("present: fifo · smoothing · qdrop 2 · qdry 1 · gated 7 · forced 1"));
// A tier below Detailed never carries any of it.
let normal = stats_text(
StatsVerbosity::Normal,
"m",
&s,
&p,
false,
false,
false,
None,
);
assert!(!normal.contains("present:") && !normal.contains("pace"));
}
/// The honest HDR badges: a PQ stream on the software-decode lane is shown WITHOUT
+67 -2
View File
@@ -33,7 +33,7 @@ mod reconfig;
mod resources;
mod setup;
pub use setup::list_adapters;
pub use setup::{list_adapters, PresentPref};
/// One presenter iteration's video input.
pub enum FrameInput<'a> {
@@ -247,10 +247,75 @@ impl Presenter {
/// (the presenter itself never sees them). No-op when timing is inactive.
pub(crate) fn note_presented(&mut self, pts_ns: u64, decoded_ns: u64) {
if let (Some(t), Some((sc, id))) = (&self.present_timer, self.last_presented.take()) {
t.enqueue(sc, id, pts_ns, decoded_ns);
// The submit stamp: `present()` already returned, so "now" is within the
// present-call tail — the pace/latch split point.
t.enqueue(
sc,
id,
pts_ns,
decoded_ns,
pf_client_core::session::now_ns(),
);
}
}
/// Undisplayed id-carrying presents in flight (0 when timing is inactive) — the
/// FIFO glass gate's budget count.
pub(crate) fn presents_outstanding(&self) -> usize {
self.present_timer.as_ref().map_or(0, |t| t.outstanding())
}
/// Install the run loop's wake for present completions (an SDL event push). No-op
/// without present timing — there is nothing to wake on then.
pub(crate) fn set_present_wake(&self, cb: Box<dyn Fn() + Send>) {
if let Some(t) = &self.present_timer {
t.set_wake(cb);
}
}
/// The live swapchain present mode, for the stats overlay: a mode is picked from
/// what the surface actually offers, so the requested one and this can differ (a
/// MAILBOX request lands on FIFO wherever the driver has no mailbox — AMD's Windows
/// driver, notably). Showing it is what makes that visible instead of puzzling.
pub(crate) fn present_mode_name(&self) -> &'static str {
match self.present_mode {
vk::PresentModeKHR::MAILBOX => "mailbox",
vk::PresentModeKHR::FIFO => "fifo",
vk::PresentModeKHR::FIFO_RELAXED => "fifo-relaxed",
vk::PresentModeKHR::IMMEDIATE => "immediate",
setup::fifo_latest_ready::MODE => "fifo-latest-ready",
_ => "other",
}
}
/// The active present mode QUEUES presents — the only modes where the swapchain
/// itself can become a standing queue, and so the only ones the glass gate governs.
///
/// MAILBOX and IMMEDIATE replace/flip and never queue. Nor does
/// `FIFO_LATEST_READY`, which retires stale images in the driver: gating on top of it
/// would hold frames back to emulate something the presentation engine is already
/// doing, paying the serialisation twice.
pub(crate) fn needs_glass_gate(&self) -> bool {
matches!(
self.present_mode,
vk::PresentModeKHR::FIFO | vk::PresentModeKHR::FIFO_RELAXED
)
}
/// The active present mode shows images ON THE VBLANK GRID — the premise the VRR
/// cadence probe rests on ("with VRR off, a present waits for vblank"). The whole
/// FIFO family qualifies, `FIFO_LATEST_READY` included: it drops stale images but
/// still presents on the refresh boundary. MAILBOX/IMMEDIATE do not, and under them
/// the probe reports Unknown rather than calling every session VRR.
pub(crate) fn vblank_locked(&self) -> bool {
matches!(
self.present_mode,
vk::PresentModeKHR::FIFO
| vk::PresentModeKHR::FIFO_RELAXED
| setup::fifo_latest_ready::MODE
)
}
/// Take the window's completed on-glass samples (empty when timing is inactive).
pub(crate) fn take_presented_samples(&self) -> Vec<present_timing::PresentedSample> {
self.present_timer
+38 -2
View File
@@ -26,6 +26,9 @@ pub(crate) struct PresentedSample {
pub pts_ns: u64,
/// Decode-complete stamp (client clock) — the display-stage anchor.
pub decoded_ns: u64,
/// `vkQueuePresentKHR`-return stamp (client clock) — the pace/latch split point:
/// `submitted decoded` is our pipeline, `displayed submitted` the vsync latch.
pub submitted_ns: u64,
/// `vkWaitForPresentKHR` completion = the image is visible (client clock).
pub displayed_ns: u64,
}
@@ -35,15 +38,24 @@ struct Job {
present_id: u64,
pts_ns: u64,
decoded_ns: u64,
submitted_ns: u64,
}
/// The run loop's wake callback (an SDL event push), shared with the waiter thread.
type WakeSlot = Arc<Mutex<Option<Box<dyn Fn() + Send>>>>;
/// The waiter: a channel-fed thread turning (swapchain, present-id) pairs into
/// [`PresentedSample`]s. One frame in flight upstream keeps the queue depth ~1.
pub(crate) struct PresentTimer {
tx: Option<mpsc::Sender<Job>>,
/// Jobs enqueued but not yet finished — the drain barrier for swapchain teardown.
/// Jobs enqueued but not yet finished — the drain barrier for swapchain teardown,
/// and the glass gate's "undisplayed presents in flight" count.
pending: Arc<AtomicUsize>,
results: Arc<Mutex<Vec<PresentedSample>>>,
/// Called by the waiter after each completed wait (sample or not) — the run loop
/// installs an SDL wake here so a gate reopen / smoothness slot never waits out the
/// event-loop timeout.
wake: WakeSlot,
join: Option<std::thread::JoinHandle<()>>,
}
@@ -52,7 +64,8 @@ impl PresentTimer {
let (tx, rx) = mpsc::channel::<Job>();
let pending = Arc::new(AtomicUsize::new(0));
let results = Arc::new(Mutex::new(Vec::with_capacity(256)));
let (pending_t, results_t) = (pending.clone(), results.clone());
let wake: WakeSlot = Arc::new(Mutex::new(None));
let (pending_t, results_t, wake_t) = (pending.clone(), results.clone(), wake.clone());
let join = std::thread::Builder::new()
.name("pf-present-wait".into())
.spawn(move || {
@@ -69,12 +82,20 @@ impl PresentTimer {
results_t.lock().unwrap().push(PresentedSample {
pts_ns: job.pts_ns,
decoded_ns: job.decoded_ns,
submitted_ns: job.submitted_ns,
displayed_ns,
});
}
// SUBOPTIMAL/TIMEOUT/DEVICE_LOST: no sample; the frame still showed
// (or the loop is about to find out) — never poison the window.
pending_t.fetch_sub(1, Ordering::AcqRel);
// Wake the run loop AFTER the count dropped: what it observes on
// wake is the post-completion state (the gate may now be open).
// Called under the slot lock — the callback is a bare SDL event
// push and never reenters this type.
if let Some(cb) = wake_t.lock().unwrap().as_ref() {
cb();
}
}
})
.expect("spawn pf-present-wait");
@@ -82,10 +103,23 @@ impl PresentTimer {
tx: Some(tx),
pending,
results,
wake,
join: Some(join),
}
}
/// Install the run loop's wake callback (an SDL event push — thread-safe by design).
pub(crate) fn set_wake(&self, cb: Box<dyn Fn() + Send>) {
*self.wake.lock().unwrap() = Some(cb);
}
/// Presents handed to the waiter and not yet resolved to glass — the glass gate's
/// budget count. (Also counts a wait that will end SUBOPTIMAL/TIMEOUT; those resolve
/// within the 250 ms cap, far past the gate's own 100 ms stale force-open.)
pub(crate) fn outstanding(&self) -> usize {
self.pending.load(Ordering::Acquire)
}
/// Hand a successfully submitted present to the waiter.
pub(crate) fn enqueue(
&self,
@@ -93,6 +127,7 @@ impl PresentTimer {
present_id: u64,
pts_ns: u64,
decoded_ns: u64,
submitted_ns: u64,
) {
if let Some(tx) = &self.tx {
self.pending.fetch_add(1, Ordering::AcqRel);
@@ -102,6 +137,7 @@ impl PresentTimer {
present_id,
pts_ns,
decoded_ns,
submitted_ns,
})
.is_err()
{
+335 -24
View File
@@ -13,10 +13,55 @@ use ash::vk;
use ash::vk::Handle as _;
use std::ffi::{c_char, CString};
/// `VK_EXT_present_mode_fifo_latest_ready`, hand-declared: it postdates the Vulkan headers
/// ash 0.38 is generated from (1.3.281), so there is no binding for it — which is also why
/// an unenabled driver reports the mode back as the bare number `1000361000`.
///
/// The mode is FIFO's tear-free vblank pacing that presents the **latest ready** image at
/// each refresh and retires the older ones, instead of draining a queue. That is precisely
/// what [`super::super::present_pace::PresentGate`] emulates in software, done by the
/// driver — and it matters most exactly where the gate does: on a surface that offers no
/// MAILBOX, this restores newest-wins behaviour without the app holding frames back.
pub(crate) mod fifo_latest_ready {
use ash::vk;
/// `VK_EXT_present_mode_fifo_latest_ready` (extension 361).
pub(super) const NAME: &std::ffi::CStr = c"VK_EXT_present_mode_fifo_latest_ready";
/// `VK_PRESENT_MODE_FIFO_LATEST_READY_EXT`.
pub(crate) const MODE: vk::PresentModeKHR = vk::PresentModeKHR::from_raw(1000361000);
/// `VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PRESENT_MODE_FIFO_LATEST_READY_FEATURES_EXT`.
const S_TYPE: vk::StructureType = vk::StructureType::from_raw(1000361000);
/// `VkPhysicalDevicePresentModeFifoLatestReadyFeaturesEXT`. The mode is usable only
/// when this feature is enabled at device creation, so the surface advertising the
/// mode is NOT on its own permission to request it.
#[repr(C)]
#[derive(Clone, Copy)]
pub(super) struct Features {
pub s_type: vk::StructureType,
pub p_next: *mut std::ffi::c_void,
pub present_mode_fifo_latest_ready: vk::Bool32,
}
impl Default for Features {
fn default() -> Features {
Features {
s_type: S_TYPE,
p_next: std::ptr::null_mut(),
present_mode_fifo_latest_ready: vk::FALSE,
}
}
}
}
impl Presenter {
/// Bring up instance → surface → device → swapchain over an SDL window.
/// `instance_extensions` comes from `VideoSubsystem::vulkan_instance_extensions()`.
pub fn new(window: &sdl3::video::Window, instance_extensions: &[String]) -> Result<Presenter> {
pub fn new(
window: &sdl3::video::Window,
instance_extensions: &[String],
pref: PresentPref,
) -> Result<Presenter> {
// SAFETY: per the Vulkan contract above - a create/allocate call on the live device, over
// builder structs that are locals outliving the call; the handle it returns is owned by
// the value being built here.
@@ -176,6 +221,21 @@ impl Presenter {
// structs through its pNext chain, so any later use of it would pin those borrows —
// every read of a chained struct below must come after this, have_f2's last use.
let have_shader_int16 = have_f2.features.shader_int16;
// FIFO_LATEST_READY: the surface may list the mode even with the extension
// disabled, so the device feature is the real gate on using it.
let flr_ok = if has(fifo_latest_ready::NAME) {
let mut feat = fifo_latest_ready::Features::default();
let mut probe = vk::PhysicalDeviceFeatures2 {
p_next: (&mut feat) as *mut _ as *mut std::ffi::c_void,
..Default::default()
};
// SAFETY: per the Vulkan contract above - a read-only query on the live
// instance/device, filling locals returned by value; `feat` outlives the call.
unsafe { instance.get_physical_device_features2(pdev, &mut probe) };
feat.present_mode_fifo_latest_ready == vk::TRUE
} else {
false
};
let present_wait_ok = present_wait_exts
&& have_pid.present_id == vk::TRUE
&& have_pwait.present_wait == vk::TRUE;
@@ -273,6 +333,13 @@ impl Presenter {
dev_exts.push(ash::khr::present_id::NAME.as_ptr());
dev_exts.push(ash::khr::present_wait::NAME.as_ptr());
}
if flr_ok {
dev_exts.push(fifo_latest_ready::NAME.as_ptr());
}
let mut en_flr = fifo_latest_ready::Features {
present_mode_fifo_latest_ready: vk::TRUE,
..Default::default()
};
let mut en_pid = vk::PhysicalDevicePresentIdFeaturesKHR::default().present_id(true);
let mut en_pwait = vk::PhysicalDevicePresentWaitFeaturesKHR::default().present_wait(true);
@@ -295,6 +362,11 @@ impl Presenter {
if present_wait_ok {
en_f2 = en_f2.push_next(&mut en_pid).push_next(&mut en_pwait);
}
if flr_ok {
// Hand-rolled struct, so chain it by hand: splice into the pNext list head.
en_flr.p_next = en_f2.p_next;
en_f2.p_next = (&mut en_flr) as *mut _ as *mut std::ffi::c_void;
}
en_f2.features.shader_int16 = if pyrowave_ok { vk::TRUE } else { vk::FALSE };
let priorities = [1.0f32];
@@ -450,11 +522,17 @@ impl Presenter {
if let Some(v) = video_export.as_mut() {
v.d3d11_hdr10 = win_capable && import_rgb10 && hdr10_format.is_some();
}
let present_mode = pick_present_mode(&surface_i, pdev, surface)?;
let mut pref = pref;
pref.vrr_fifo_opt_in = vrr_fifo_opt_in();
pref.fifo_latest_ready = flr_ok;
let present_mode = pick_present_mode(&surface_i, pdev, surface, pref)?;
tracing::info!(
?format,
?hdr10_format,
?present_mode,
vsync = pref.vsync,
allow_vrr = pref.allow_vrr,
fifo_latest_ready = flr_ok,
hdr_metadata = has_hdr_metadata,
"swapchain config"
);
@@ -730,42 +808,275 @@ pub(super) fn pick_formats(
Ok((sdr, hdr10))
}
/// MAILBOX when the surface offers it, FIFO otherwise (`PUNKTFUNK_PRESENT_MODE=
/// fifo|mailbox|immediate|fifo_relaxed` overrides). Both defaults are tear-free, but an
/// arrival-paced presenter must not block in FIFO's present queue: when the compositor
/// holds images for a vblank pass (gamescope's composite path) or arrival cadence drifts
/// against refresh, `acquire_next_image` stalls most of a refresh — a standing 11-13 ms
/// added to every frame at 60 Hz. MAILBOX never queues more than the newest frame, so the
/// pipeline stays at decode latency and a late frame is replaced, not waited for.
/// What the user asked the presentation to be, resolved into a swapchain present mode by
/// [`present_mode_chain`] (design/desktop-presentation-rebuild.md WP3).
#[derive(Clone, Copy, Debug, Default)]
pub struct PresentPref {
/// Tear-free presentation (the `vsync` setting, default on).
pub vsync: bool,
/// Let a variable-refresh display follow the stream cadence (`allow_vrr`, default on).
pub allow_vrr: bool,
/// Opt-in for the VRR FIFO-first ladder (`PUNKTFUNK_VRR_FIFO=1`). Off by default on
/// measured evidence — see [`present_mode_chain`].
pub vrr_fifo_opt_in: bool,
/// `VK_EXT_present_mode_fifo_latest_ready` is enabled on the device, so the mode may
/// be requested. Resolved during device creation; never set by callers.
pub fifo_latest_ready: bool,
/// The session STARTED fullscreen. The mode is chosen once, at swapchain creation, so
/// this is the starting state and an F11 mid-session does not re-pick — consistent
/// with the shells' "Display changes apply from the next session" footer, and why
/// live present-mode switching is an explicit non-goal.
pub fullscreen: bool,
}
/// The preference ladder, most to least wanted. The caller takes the first entry the
/// surface actually offers; FIFO ends every chain because the spec guarantees it.
///
/// * **V-Sync off** — IMMEDIATE (tears, no wait at all), then FIFO_RELAXED (tears only on
/// a late frame), then the tear-free modes. Asking for tearing and silently getting
/// vsync is a lie the stats line now exposes, but the ladder still degrades safely.
/// * **V-Sync on + VRR allowed + fullscreen + `PUNKTFUNK_VRR_FIFO=1`** — FIFO first. On a
/// variable-refresh panel with direct scanout the FIFO present IS the flip, so the panel
/// follows the stream's cadence; MAILBOX would decouple presents from scanout and
/// re-quantize to the compositor's clock.
///
/// **Automatic where a queue-free vblank mode exists, opt-in otherwise.** The history is
/// worth keeping: this was default-on, then measured on glass (.21, GNOME/Wayland,
/// NVIDIA, *non*-VRR 60 Hz panel, 2026-08-02) to cost ~27 ms of display stage against
/// MAILBOX — `28.4 ms (pace 11.8 + latch 16.6)` versus `1.4 ms (0.2 + 1.2)` — because a
/// plain-FIFO present's on-glass confirmation lands a whole refresh later and the
/// presenter serialises behind it. It became opt-in on that evidence.
///
/// `FIFO_LATEST_READY` removes the cause rather than working around it: the driver
/// retires stale images, so the vblank-locked path measured **2.6 ms** on the same box —
/// 0.6 ms over MAILBOX instead of 27. So where the device offers it, following the panel
/// is cheap enough to be the default again; where it does not, the ladder would fall
/// back to plain FIFO and the regression returns, so it stays behind
/// `PUNKTFUNK_VRR_FIFO=1` there. The win on a genuine VRR panel is still UNMEASURED —
/// no VRR display was available — but the cost of trying is now small and bounded.
/// * **Otherwise** — MAILBOX, then FIFO: the shipped default. MAILBOX never queues more
/// than the newest frame, so an arrival-paced presenter doesn't block in the present
/// queue (a measured 11-13 ms standing wait at 60 Hz when the compositor holds images
/// for a vblank pass, or when arrival cadence drifts against refresh).
///
/// AMD's Windows driver offers no MAILBOX (NVIDIA does), so those clients land on FIFO —
/// expected, not a client misconfiguration. FIFO_RELAXED is opt-in only: it tears exactly
/// when a stream frame misses the vblank it was pacing for, which on a drifting arrival
/// cadence is often — a trade the user must choose, never a silent fallback.
/// expected, not a misconfiguration, and now visible in the `present:` stats line.
fn present_mode_chain(pref: PresentPref) -> Vec<vk::PresentModeKHR> {
use vk::PresentModeKHR as M;
let flr = pref.fifo_latest_ready.then_some(fifo_latest_ready::MODE);
let mut chain: Vec<M> = if !pref.vsync {
vec![M::IMMEDIATE, M::FIFO_RELAXED, M::MAILBOX]
} else if pref.allow_vrr && pref.fullscreen && (pref.fifo_latest_ready || pref.vrr_fifo_opt_in)
{
// The VRR ladder wants the vblank-locked family; LATEST_READY is that with the
// queue removed, so it outranks plain FIFO here too.
vec![]
.into_iter()
.chain(flr)
.chain([M::FIFO, M::MAILBOX, M::FIFO_RELAXED, M::IMMEDIATE])
.collect()
} else {
// MAILBOX first (measured good), then LATEST_READY — which is what gives a
// MAILBOX-less surface the same newest-wins behaviour, in the driver instead of
// in our glass gate.
vec![M::MAILBOX]
.into_iter()
.chain(flr)
.chain([M::FIFO_RELAXED, M::IMMEDIATE])
.collect()
};
if !pref.vsync {
chain.extend(flr);
}
// FIFO ends every chain: the spec guarantees it exists, so there is always a landing.
chain.push(M::FIFO);
chain
}
/// `PUNKTFUNK_VRR_FIFO=1` — opt into the FIFO-first ladder for variable-refresh panels.
/// See [`present_mode_chain`] for the measurement that made this opt-in rather than
/// default.
fn vrr_fifo_opt_in() -> bool {
std::env::var("PUNKTFUNK_VRR_FIFO").is_ok_and(|v| v != "0")
}
/// Resolve the present mode: `PUNKTFUNK_PRESENT_MODE` pins one outright (the debug lever,
/// unchanged), otherwise the first entry of [`present_mode_chain`] the surface offers.
fn pick_present_mode(
surface_i: &ash::khr::surface::Instance,
pdev: vk::PhysicalDevice,
surface: vk::SurfaceKHR,
pref: PresentPref,
) -> Result<vk::PresentModeKHR> {
// SAFETY: per the Vulkan contract above - a read-only query on the live instance/device,
// filling locals returned by value.
let modes = unsafe { surface_i.get_physical_device_surface_present_modes(pdev, surface) }?;
let want = match std::env::var("PUNKTFUNK_PRESENT_MODE").ok().as_deref() {
Some("fifo") => vk::PresentModeKHR::FIFO,
Some("immediate") => vk::PresentModeKHR::IMMEDIATE,
Some("fifo_relaxed") => vk::PresentModeKHR::FIFO_RELAXED,
Some("mailbox") | None => vk::PresentModeKHR::MAILBOX,
let pinned = match std::env::var("PUNKTFUNK_PRESENT_MODE").ok().as_deref() {
Some("fifo") => Some(vk::PresentModeKHR::FIFO),
Some("immediate") => Some(vk::PresentModeKHR::IMMEDIATE),
Some("fifo_relaxed") => Some(vk::PresentModeKHR::FIFO_RELAXED),
Some("mailbox") => Some(vk::PresentModeKHR::MAILBOX),
None => None,
Some(other) => {
tracing::warn!(
value = other,
"unknown PUNKTFUNK_PRESENT_MODE (expected fifo|mailbox|immediate|fifo_relaxed) — using mailbox"
"unknown PUNKTFUNK_PRESENT_MODE (expected fifo|mailbox|immediate|fifo_relaxed) — following the settings"
);
vk::PresentModeKHR::MAILBOX
None
}
};
Ok(if modes.contains(&want) {
want
} else {
vk::PresentModeKHR::FIFO // always available per spec
})
if let Some(want) = pinned {
if modes.contains(&want) {
return Ok(want);
}
tracing::warn!(
?want,
"PUNKTFUNK_PRESENT_MODE not offered by this surface — falling back"
);
}
// What the surface ACTUALLY offers, logged unconditionally. "AMD's Windows driver
// has no MAILBOX" is the premise the FIFO glass gate is built on, and it has been
// carried in comments rather than measured — present modes are a property of the
// (surface, device) pair, so they vary by platform surface, driver version and
// fullscreen state, and the only way to settle it is to read it back from real
// machines. One line here makes every field log answer the question.
tracing::info!(
available = ?modes,
"surface present modes"
);
let chain = present_mode_chain(pref);
let chosen = chain
.iter()
.copied()
.find(|m| modes.contains(m))
.unwrap_or(vk::PresentModeKHR::FIFO); // always available per spec
// The one line that answers "did V-Sync off actually take?" — a request the surface
// can't serve is a fact about the driver, and it must not look like our choice.
if chosen != chain[0] {
tracing::info!(
requested = ?chain[0],
active = ?chosen,
vsync = pref.vsync,
allow_vrr = pref.allow_vrr,
"the surface does not offer the preferred present mode"
);
}
Ok(chosen)
}
#[cfg(test)]
mod tests {
use super::*;
use vk::PresentModeKHR as M;
/// The preference ladders (WP3). Every chain must end at FIFO, which the spec
/// guarantees exists — a chain whose entries a surface all refuses would otherwise
/// have no landing.
#[test]
fn present_mode_chains_rank_by_intent() {
let pref = |vsync, allow_vrr, fullscreen| PresentPref {
vsync,
allow_vrr,
fullscreen,
vrr_fifo_opt_in: true, // the ladder under test; the DEFAULT is off (see below)
fifo_latest_ready: false,
};
let flr = fifo_latest_ready::MODE;
// V-Sync off asks to tear, hardest first, and outranks the VRR rule (tearing
// already gives a VRR-like latch, so the two never fight).
assert_eq!(present_mode_chain(pref(false, true, true))[0], M::IMMEDIATE);
assert_eq!(
present_mode_chain(pref(false, false, false))[0],
M::IMMEDIATE
);
assert_eq!(
present_mode_chain(pref(false, true, true))[1],
M::FIFO_RELAXED,
"tears only on a late frame — the gentler tearing rung"
);
// Tear-free + VRR allowed + fullscreen prefers the vblank-locked family — but
// ONLY when opted in.
assert_eq!(present_mode_chain(pref(true, true, true))[0], M::FIFO);
// Without the opt-in the shipped MAILBOX-first default stands: measured on glass
// to be ~27 ms of display stage better on a non-VRR panel.
assert_eq!(
present_mode_chain(PresentPref {
vsync: true,
allow_vrr: true,
fullscreen: true,
vrr_fifo_opt_in: false,
fifo_latest_ready: false,
})[0],
M::MAILBOX,
"without a queue-free vblank mode the VRR ladder would lead with plain FIFO, \
which measured ~27 ms worse so it stays opt-in there"
);
assert_eq!(
present_mode_chain(PresentPref {
vsync: true,
allow_vrr: true,
fullscreen: true,
vrr_fifo_opt_in: false,
fifo_latest_ready: true,
})[0],
fifo_latest_ready::MODE,
"with LATEST_READY available, following the panel costs 0.6 ms over MAILBOX \
instead of 27 cheap enough to be automatic"
);
// FIFO_LATEST_READY only appears where the device enabled it, and it outranks
// plain FIFO everywhere: it is FIFO's vblank pacing WITHOUT the queue, which is
// what a MAILBOX-less surface otherwise needs the software glass gate for.
let with_flr = |vsync, allow_vrr, fullscreen| PresentPref {
vsync,
allow_vrr,
fullscreen,
vrr_fifo_opt_in: true,
fifo_latest_ready: true,
};
for p in [
pref(true, false, false),
pref(true, true, true),
pref(false, true, true),
] {
assert!(
!present_mode_chain(p).contains(&flr),
"never requested unless the device enabled the extension"
);
}
let default_flr = present_mode_chain(with_flr(true, false, false));
assert_eq!(
default_flr[0],
M::MAILBOX,
"MAILBOX still leads by measurement"
);
assert_eq!(default_flr[1], flr, "then the driver-native newest-wins");
assert!(
default_flr.iter().position(|m| *m == flr)
< default_flr.iter().position(|m| *m == M::FIFO),
"LATEST_READY must outrank plain FIFO — it is FIFO minus the standing queue"
);
assert_eq!(
present_mode_chain(with_flr(true, true, true))[0],
flr,
"the VRR ladder takes the queue-free vblank mode first"
);
// Every ladder can land: FIFO appears in all of them.
for p in [
pref(true, true, true),
pref(true, true, false),
pref(true, false, true),
pref(false, true, true),
pref(false, false, false),
with_flr(true, false, false),
] {
assert!(
present_mode_chain(p).contains(&M::FIFO),
"FIFO is the guaranteed landing"
);
}
}
}
+15 -7
View File
@@ -407,13 +407,21 @@ pub fn open(compositor: Compositor) -> Result<Box<dyn VirtualDisplay>> {
// The pf-vdisplay all-Rust IddCx driver is the sole virtual-display backend (the legacy SudoVDA
// fallback was removed — its driver is no longer shipped). The compositor arg is moot on Windows.
let _ = compositor;
// `ensure_available` self-heals the hostless-zombie state a WUDFHost crash leaves (adapter
// devnode present, interface gone): one device cycle + re-probe before giving up.
anyhow::ensure!(
driver::ensure_available(),
"pf-vdisplay driver interface not found — the pf-vdisplay IddCx driver is not installed or \
not loaded (the host installer bundles it; reinstall or check the driver state)"
);
// `ensure_available` waits out a devnode that is merely coming up (the wake-from-sleep case:
// the adapter re-enters D0 and re-registers its interface while a reconnecting client is
// already knocking) and self-heals the hostless-zombie state a WUDFHost crash leaves (adapter
// devnode present, interface gone) by reloading the adapter.
//
// `context`, not a replacement message: it reports WHY — how long it waited, whether a reload
// ran, how many interface instances were seen and in what state. A flat "the driver is not
// installed" is what a field report carried from a box whose driver was installed, started,
// and simply mid-resume, and it pointed every reader at the wrong problem.
use anyhow::Context as _;
driver::ensure_available().context(
"pf-vdisplay driver interface not available — the pf-vdisplay IddCx driver is not \
installed, not loaded, or did not finish coming back up (the host installer bundles \
it; reinstall or check the driver state)",
)?;
Ok(Box::new(driver::PfVdisplayDisplay::new()?))
}
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
@@ -425,6 +425,20 @@ pub fn control_device_handle() -> Option<HANDLE> {
VDM.get().and_then(VirtualDisplayManager::device_handle)
}
/// Retire the cached control handle from OUTSIDE the manager, for a caller that KNOWS the device
/// died — the adapter-reload recovery in [`crate::driver`], which tears the driver stack down and
/// back up. Without it the stale handle survives into the next session's `IOCTL_ADD` and is only
/// recovered by the gone-classified retry one failed IOCTL later.
///
/// Takes the `device` mutex, so it must NOT be called from inside it (notably not from
/// `VdisplayDriver::open`, which `ensure_device` invokes while holding it). No-op before any backend
/// opened the device.
pub(crate) fn invalidate_cached_device(why: &str) {
if let Some(m) = VDM.get() {
m.invalidate_device(&anyhow::anyhow!("{why}"));
}
}
/// Re-commit the CURRENT display config under the manager `state` lock (the sole-topology-mutator
/// contract of [`force_mode_reenumeration`]). The secure-desktop guard's actuator: the OS only
/// reverts a path to its software-cursor default ON a mode commit, so standing the hardware-cursor
@@ -21,6 +21,7 @@ use std::ffi::c_void;
use std::mem::size_of;
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use windows::core::{GUID, PCWSTR};
@@ -143,31 +144,70 @@ fn reap_ghost_monitors() -> u32 {
}
}
/// Kick the pf-vdisplay ADAPTER device (disable → enable) — the in-process equivalent of
/// `reset-pf-vdisplay.ps1` step 3. A crashed/killed WUDFHost can leave the devnode "started" yet
/// HOSTLESS (PnP Status OK, no WUDFHost process, zero device-interface instances) — a zombie no
/// session can open until the stack reloads; on-glass, only a device cycle recovered it. Called by
/// [`VdisplayDriver::open`] when `open_device` finds no openable interface; the caller retries the
/// open afterwards. Best-effort + bounded (~7 s inside the script). Returns whether a punktfunk
/// adapter devnode was found (and therefore cycled) — `false` means the driver genuinely is not
/// installed and a retry is pointless.
fn restart_vdisplay_device() -> bool {
/// What an adapter-cycle attempt actually DID — deliberately NOT the devnode's PnP status afterwards.
/// The old script reported that status, and a device it had failed to touch at all still reads `OK`,
/// so a no-op cycle was indistinguishable from a real one in the log (field report 2026-08-02: a
/// woken host logged `cycled … status=OK` and then failed the session for a missing interface).
enum AdapterCycle {
/// The driver stack was genuinely reloaded. `how` names the lever that worked.
Reloaded { how: &'static str, status: String },
/// No punktfunk adapter devnode exists at all — the driver is not installed and retrying is
/// pointless.
NotInstalled,
/// A devnode exists but could not be reloaded; carries the reason (already whitespace-collapsed).
Refused(String),
}
/// Reload the pf-vdisplay ADAPTER device — the in-process equivalent of `reset-pf-vdisplay.ps1`
/// step 3. A crashed/killed WUDFHost can leave the devnode "started" yet HOSTLESS (PnP Status OK, no
/// WUDFHost process, zero device-interface instances) — a zombie no session can open until the stack
/// reloads; on-glass, only a device reload recovered it.
///
/// Two levers, in order. `Disable-PnpDevice` + `Enable-PnpDevice` is the one `reset-pf-vdisplay.ps1`
/// uses — but that script stops the host service FIRST, precisely because the host holds the driver's
/// control device open (its step 1), and a disable can be refused for a device in use. This runs
/// INSIDE the host, so it structurally cannot take that step: the retired-but-never-closed handles in
/// [`DeviceSlot`](super::manager) are still open on the very device being disabled. So a refusal is
/// the expected case here, not the exotic one, and `pnputil /restart-device` — which reloads a device
/// that is in use — is the fallback. Whichever runs, the failure paths re-enable, so a half-completed
/// cycle can never leave the adapter DISABLED.
///
/// Best-effort + bounded (~6 s inside the script).
fn reload_vdisplay_adapter() -> AdapterCycle {
// Mirrors reset-pf-vdisplay.ps1's Get-PfAdapter selector ('punktfunk Virtual Display' is the INF
// device description — locale-invariant). Same spawn shape as `reap_ghost_monitors` above.
// device description — locale-invariant). Same spawn shape as `reap_ghost_monitors` above; the
// reported tokens are ours, so parsing them is locale-invariant too.
//
// Every step that can fail is `-ErrorAction Stop` inside a `try` — the old script ran the whole
// cycle under `SilentlyContinue` and then reported `(Get-PnpDevice …).Status`, which reports the
// DEVICE, not the cycle: a disable that was refused left the device untouched, started, and
// reading `OK`, so the host logged a successful recovery it had never performed.
//
// `$LASTEXITCODE = 1` before the pnputil call for the same reason: no native command runs before
// it, so an unlaunchable pnputil would otherwise leave the variable holding whatever it held and
// let "never ran" read as "returned 0". Pre-seeding a failure means only a real exit 0 reports a
// reload. pnputil is resolved by full path — a LocalSystem service's PATH need not include
// System32.
const CYCLE_PS: &str = "$ErrorActionPreference='SilentlyContinue'; \
$ad = Get-PnpDevice -Class Display | Where-Object { $_.FriendlyName -match 'punktfunk Virtual Display' } | Select-Object -First 1; \
if ($ad) { \
Disable-PnpDevice -InstanceId $ad.InstanceId -Confirm:$false; Start-Sleep -Seconds 3; \
Enable-PnpDevice -InstanceId $ad.InstanceId -Confirm:$false; Start-Sleep -Seconds 3; \
$st = (Get-PnpDevice -InstanceId $ad.InstanceId).Status; \
if ($st -ne 'OK') { Enable-PnpDevice -InstanceId $ad.InstanceId -Confirm:$false; Start-Sleep -Seconds 2; \
$st = (Get-PnpDevice -InstanceId $ad.InstanceId).Status }; \
Write-Output $st \
} else { Write-Output 'ABSENT' }";
if (-not $ad) { Write-Output 'ABSENT'; exit }; \
$id = $ad.InstanceId; $err = ''; \
try { \
Disable-PnpDevice -InstanceId $id -Confirm:$false -ErrorAction Stop; Start-Sleep -Seconds 2; \
try { Enable-PnpDevice -InstanceId $id -Confirm:$false -ErrorAction Stop } \
catch { Start-Sleep -Seconds 2; Enable-PnpDevice -InstanceId $id -Confirm:$false -ErrorAction Stop }; \
Start-Sleep -Seconds 2; \
Write-Output ('RELOADED cycle ' + (Get-PnpDevice -InstanceId $id).Status); exit \
} catch { $err = ($_.Exception.Message -replace '\\s+', ' ') }; \
$pnp = ($env:SystemRoot + '\\System32\\pnputil.exe'); $LASTEXITCODE = 1; \
if (Test-Path $pnp) { & $pnp /restart-device $id *> $null }; \
if ($LASTEXITCODE -eq 0) { Start-Sleep -Seconds 2; \
Write-Output ('RELOADED restart ' + (Get-PnpDevice -InstanceId $id).Status) } \
else { Enable-PnpDevice -InstanceId $id -Confirm:$false; Write-Output ('REFUSED ' + $err) }";
let ps = std::env::var("SystemRoot")
.map(|r| format!(r"{r}\System32\WindowsPowerShell\v1.0\powershell.exe"))
.unwrap_or_else(|_| "powershell.exe".to_string());
match std::process::Command::new(&ps)
let out = match std::process::Command::new(&ps)
.args([
"-NoProfile",
"-NonInteractive",
@@ -178,22 +218,65 @@ fn restart_vdisplay_device() -> bool {
])
.output()
{
Ok(o) => {
let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
if status == "ABSENT" {
tracing::warn!("pf-vdisplay: no adapter devnode to cycle — driver not installed");
} else {
tracing::warn!(
%status,
"pf-vdisplay: cycled the adapter device (hostless-zombie recovery)"
);
}
status != "ABSENT"
}
Ok(o) => String::from_utf8_lossy(&o.stdout).trim().to_string(),
Err(e) => {
tracing::warn!(error = %e, "pf-vdisplay: adapter cycle could not spawn powershell");
false
tracing::warn!(error = %e, "pf-vdisplay: adapter reload could not spawn powershell");
return AdapterCycle::Refused(format!("could not spawn powershell: {e}"));
}
};
let outcome = classify_reload_output(&out);
match &outcome {
AdapterCycle::NotInstalled => {
tracing::warn!("pf-vdisplay: no adapter devnode to reload — driver not installed");
}
AdapterCycle::Reloaded { how, status } => tracing::warn!(
how,
%status,
"pf-vdisplay: reloaded the adapter device (hostless-zombie recovery)"
),
AdapterCycle::Refused(why) => tracing::warn!(
reason = %why,
"pf-vdisplay: the adapter devnode exists but could NOT be reloaded — a session cannot \
recover from this without a host-service restart or a reboot"
),
}
outcome
}
/// Parse [`reload_vdisplay_adapter`]'s script output. Split out to be testable without a box: the
/// bug this whole change answers was a recovery that MISreported its own outcome, so the decoding of
/// that outcome is worth pinning down.
fn classify_reload_output(out: &str) -> AdapterCycle {
let out = out.trim();
let (verb, rest) = out.split_once(char::is_whitespace).unwrap_or((out, ""));
match verb {
"ABSENT" => AdapterCycle::NotInstalled,
"RELOADED" => {
let (how, status) = rest
.trim()
.split_once(char::is_whitespace)
.unwrap_or((rest.trim(), ""));
// Held as `&'static str` so the two levers stay distinguishable in a field report:
// `restart` means the disable was refused, i.e. something still holds the device open —
// worth knowing when a reload does not fix the box.
let how: &'static str = if how == "restart" {
"pnputil /restart-device"
} else {
"disable+enable"
};
AdapterCycle::Reloaded {
how,
status: status.trim().to_string(),
}
}
// Covers `REFUSED <reason>` and anything unrecognised, including an empty stdout (powershell
// died before writing). All of them mean an un-reloaded devnode, which is the only thing
// callers act on; the text rides along for the log.
_ => AdapterCycle::Refused(if rest.trim().is_empty() {
format!("unexpected adapter-reload output: {out:?}")
} else {
rest.trim().to_string()
}),
}
}
@@ -325,6 +408,55 @@ impl Drop for DevInfoList {
}
}
/// What a device-interface enumeration found. The counts are what let [`ensure_available`] tell a
/// devnode that is MID-TRANSITION (present, interface registered, not started yet — resuming from
/// sleep, restarting, reloading) apart from one that is genuinely gone. Only the second is worth
/// answering with device surgery; cycling the first only lengthens the outage it is waiting out.
struct Probe {
/// The control handle, if any interface instance opened.
handle: Option<OwnedHandle>,
/// Instances seen with `SPINT_ACTIVE` set — the owning device is started.
active: u32,
/// Instances seen with `SPINT_ACTIVE` clear — registered, but the owning device is not started.
inactive: u32,
/// The last enumeration/open failure, kept for the diagnostic.
last_err: Option<anyhow::Error>,
}
impl Probe {
/// No interface instance of ANY kind. With an adapter devnode present this is the hostless-zombie
/// state a WUDFHost crash leaves; with none, the driver is not installed. Either way, waiting
/// alone will not fix it.
fn is_absent(&self) -> bool {
self.handle.is_none() && self.active == 0 && self.inactive == 0
}
/// Why no handle came back, NAMING what was seen — "0 interfaces" and "1 inactive interface" are
/// completely different diagnoses (not installed vs. still coming up), and the old message
/// collapsed both into "is the driver installed?". Call only on a miss; a hit reports as much.
fn into_error(self) -> anyhow::Error {
let seen = format!("{} active, {} inactive", self.active, self.inactive);
if self.handle.is_some() {
return anyhow::anyhow!("pf-vdisplay device interface opened ({seen})");
}
match self.last_err {
Some(e) => e.context(format!("no openable pf-vdisplay device interface ({seen})")),
None => anyhow::anyhow!(
"no pf-vdisplay device interface found ({seen}) — is the pf-vdisplay driver \
installed and its device started?"
),
}
}
/// Consume into the [`open_device`] result.
fn into_result(mut self) -> Result<OwnedHandle> {
match self.handle.take() {
Some(h) => Ok(h),
None => Err(self.into_error()),
}
}
}
/// Open the pf-vdisplay control device.
///
/// SAFE, and owning. It has no caller obligation — it takes no arguments and every precondition is
@@ -333,26 +465,40 @@ impl Drop for DevInfoList {
/// this file has already leaked from once (see the wrap-IMMEDIATELY comment in `open`). Returning an
/// `OwnedHandle` makes the close a `Drop`, so there is exactly one way to get it wrong: not at all.
fn open_device() -> Result<OwnedHandle> {
probe_device().into_result()
}
/// [`open_device`], reporting WHAT it found rather than only whether it succeeded.
fn probe_device() -> Probe {
let mut probe = Probe {
handle: None,
active: 0,
inactive: 0,
last_err: None,
};
// SAFETY: plain SetupAPI enumeration call; the returned list is solely owned by the RAII wrapper.
let hdev = DevInfoList(
unsafe {
SetupDiGetClassDevsW(
Some(&PF_VDISPLAY_INTERFACE),
PCWSTR::null(),
None,
DIGCF_DEVICEINTERFACE | DIGCF_PRESENT,
)
let hdev = match unsafe {
SetupDiGetClassDevsW(
Some(&PF_VDISPLAY_INTERFACE),
PCWSTR::null(),
None,
DIGCF_DEVICEINTERFACE | DIGCF_PRESENT,
)
}
.context("SetupDiGetClassDevsW(pf-vdisplay) — is the pf-vdisplay driver installed?")
{
Ok(h) => DevInfoList(h),
Err(e) => {
probe.last_err = Some(e);
return probe;
}
.context("SetupDiGetClassDevsW(pf-vdisplay) — is the pf-vdisplay driver installed?")?,
);
};
// Enumerate EVERY interface instance, not just index 0: after a driver upgrade a present-but-
// failed devnode (Code 10) can hold index 0 while the LIVE node's interface sits at a later
// index — the old single-index read then failed every session with "driver not installed"
// even though a working interface existed. `SPINT_ACTIVE` filters dead interfaces (an interface
// is active only while its owning device is started); the first active + openable one wins.
let mut inactive = 0u32;
let mut last_err: Option<anyhow::Error> = None;
for index in 0..64u32 {
let mut idata = SP_DEVICE_INTERFACE_DATA {
cbSize: size_of::<SP_DEVICE_INTERFACE_DATA>() as u32,
@@ -367,9 +513,10 @@ fn open_device() -> Result<OwnedHandle> {
break; // ERROR_NO_MORE_ITEMS — no further candidates
}
if idata.Flags & SPINT_ACTIVE == 0 {
inactive += 1;
probe.inactive += 1;
continue;
}
probe.active += 1;
let mut required = 0u32;
// SAFETY: sizing call — null buffer plus a valid `required` out-param; the expected
// ERROR_INSUFFICIENT_BUFFER "failure" is ignored and only `required` is consumed.
@@ -409,20 +556,18 @@ fn open_device() -> Result<OwnedHandle> {
})
};
match opened {
// SAFETY: `h` is the handle `CreateFileW` just returned to THIS call and nothing else
// holds it, so transferring it into the `OwnedHandle` gives it a single owner that
// closes it exactly once on drop.
Ok(h) => return Ok(unsafe { OwnedHandle::from_raw_handle(h.0 as _) }),
Ok(h) => {
// SAFETY: `h` is the handle `CreateFileW` just returned to THIS call and nothing
// else holds it, so transferring it into the `OwnedHandle` gives it a single owner
// that closes it exactly once on drop.
probe.handle = Some(unsafe { OwnedHandle::from_raw_handle(h.0 as _) });
return probe;
}
// A raced-away or wedged device — remember the error, try the next interface.
Err(e) => last_err = Some(e),
Err(e) => probe.last_err = Some(e),
}
}
Err(last_err.unwrap_or_else(|| {
anyhow::anyhow!(
"no ACTIVE pf-vdisplay device interface found ({inactive} inactive) — is the \
pf-vdisplay driver installed and its device started?"
)
}))
probe
}
/// The pf-vdisplay IOCTL surface behind the shared [`VirtualDisplayManager`](super::manager::VirtualDisplayManager)
@@ -435,29 +580,14 @@ impl VdisplayDriver for PfVdisplayDriver {
}
unsafe fn open(&self, reap_orphans: bool) -> Result<(OwnedHandle, u32, u32)> {
let device = match open_device() {
Ok(d) => d,
Err(first) => {
// No openable interface. If a WUDFHost crash left the devnode a hostless zombie
// (validated on-glass: PnP Status OK, zero interface instances), a device cycle
// reloads the stack — kick it once and retry the open over a short arrival window.
if !restart_vdisplay_device() {
return Err(first); // no adapter devnode at all — genuinely not installed
}
let mut reopened = Err(first);
for _ in 0..8 {
std::thread::sleep(std::time::Duration::from_millis(500));
match open_device() {
Ok(d) => {
reopened = Ok(d);
break;
}
Err(e) => reopened = Err(e),
}
}
reopened.context("pf-vdisplay interface still absent after an adapter cycle")?
}
};
// A short re-probe, and deliberately NO adapter reload — this replaces the second, impatient
// copy of the recovery that used to live here. Session bring-up already ran the full
// `ensure_available` before constructing the backend, so anything left for this open to
// absorb is a race, not a wedge. `hw_cursor_capable` also lands here, mid client handshake,
// where a reload's tens of seconds would be entirely the wrong trade for one capability bool
// — and where reloading would deadlock besides, since `ensure_device` calls us holding the
// manager's `device` mutex (see the `RECOVERY` ordering contract).
let device = wait_for_interface(BRIEF_RETRY, false).0?;
// `open_device` hands back an `OwnedHandle`, so every `?` below closes the device exactly
// once by construction — the shape this used to reach by wrapping the raw handle here, and
// which leaked whenever GET_INFO itself failed before that wrap was moved up.
@@ -879,25 +1009,159 @@ pub fn is_available() -> bool {
open_device().is_ok()
}
/// [`is_available`], with self-heal: an interface-less driver whose adapter devnode EXISTS is the
/// hostless-zombie state a WUDFHost crash leaves behind (validated on-glass — PnP reports Status OK
/// with no WUDFHost process and zero interface instances, and every session fails at this gate until
/// the device reloads). Cycle the adapter once and re-probe over a short arrival window. A genuinely
/// uninstalled driver (no adapter devnode) fails fast without the wait.
pub fn ensure_available() -> bool {
if is_available() {
return true;
/// How often the interface is re-probed while waiting.
const PROBE_INTERVAL: Duration = Duration::from_millis(500);
/// How long a devnode whose interface exists but is NOT-READY (no active instance, or `CreateFileW`
/// refused) is given to come up on its own before the adapter is reloaded.
///
/// This is the wake-from-sleep window. Resuming re-enters D0 and re-registers the interface while
/// the rest of the resume storm is still running, and a client reconnecting a second after wake
/// arrives inside that gap — which the old code, probing exactly ONCE, answered by disabling and
/// re-enabling a display adapter that was seconds from being ready anyway.
const NOT_READY_GRACE: Duration = Duration::from_secs(15);
/// How long a fully ABSENT interface is given before the adapter is reloaded. Short — a hostless
/// devnode does not heal itself, and that is the case this recovery exists for — but non-zero, so a
/// resume that briefly de-registers the interface is not met with device surgery either.
const ABSENT_SETTLE: Duration = Duration::from_secs(3);
/// How long the interface is given to ARRIVE after a reload.
///
/// Was 4 s, which a quiet box meets and a box still finishing a resume does not: PnP is contended
/// right after wake. Field report 2026-08-02 — a woken host logged a successful adapter cycle and
/// then failed the session 4 s later for a missing interface, and the client could not connect.
const ARRIVAL_AFTER_RELOAD: Duration = Duration::from_secs(15);
/// Hard ceiling on the whole wait, so display prep can never block for an unbounded sum of the
/// windows above. Without it a devnode wedged NOT-READY costs the full grace, then the reload, then
/// the full arrival window before failing — the pathological case paying nearly a minute per session.
/// Patience for a device that is coming back is the point; patience for one that never will is not.
const TOTAL_BUDGET: Duration = Duration::from_secs(30);
/// The budget a caller that must NOT stall gives the interface: no adapter reload, just a short
/// re-probe to ride out a race. [`VdisplayDriver::open`] uses it — by the time the manager opens,
/// session bring-up has already run the full [`ensure_available`] above, and the OTHER path that
/// reaches it (`manager::hw_cursor_capable`, a best-effort capability answer during the client
/// handshake) must never hold the Welcome for tens of seconds to decide one bool.
const BRIEF_RETRY: Duration = Duration::from_secs(3);
/// Serializes the recovery so N sessions racing in after a wake perform ONE adapter reload between
/// them rather than N interleaved ones — each of which tears down the stack the others are waiting
/// on. The second caller through typically finds the interface already up and returns at once.
///
/// Taken ONLY by [`ensure_available`], which holds no manager lock, and released before the retire
/// hook below takes the manager's `device` mutex. That is what keeps the lock order one-way:
/// [`VdisplayDriver::open`] runs *inside* that same `device` mutex, so if it could also take this
/// lock the two orders would invert and deadlock. It cannot — it never reloads.
static RECOVERY: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// [`is_available`], with self-heal — and with PATIENCE, which is the part that matters after a
/// wake from sleep.
///
/// Returns the reason on failure instead of a bare `false`: the caller used to replace it with a
/// flat "the driver is not installed", which is what a field report showed on a box whose driver was
/// installed, started, and merely mid-resume.
pub fn ensure_available() -> Result<()> {
// Poisoning carries no meaning here — the guard protects a `()`, not state a panic could leave
// inconsistent — so a previous panic must not wedge every later session out of recovery.
let (result, reloaded) = {
let _serialize = RECOVERY.lock().unwrap_or_else(|e| e.into_inner());
wait_for_interface(NOT_READY_GRACE, true)
};
// OUTSIDE the recovery lock, by the ordering contract on `RECOVERY`. A reload tore the driver
// stack down and back up, so any control handle a previous session cached is dead by
// construction — retire it while we know that for certain, rather than leaving the next session
// to discover it by having an IOCTL fail. No-op before any backend opened the device.
if reloaded {
super::manager::invalidate_cached_device(
"the pf-vdisplay adapter was reloaded (hostless-zombie recovery)",
);
}
if !restart_vdisplay_device() {
return false;
}
for _ in 0..8 {
std::thread::sleep(std::time::Duration::from_millis(500));
if is_available() {
return true;
result.map(|_| ())
}
/// Wait for an openable control interface, reloading the adapter if `reload` and the devnode looks
/// genuinely hostless. Returns the handle (so the manager's own open can keep it) alongside whether
/// a reload ran.
///
/// Two distinguishable states hide behind "cannot open the interface", and they want opposite
/// treatment:
///
/// * **Not ready** — instances are registered but none is active (or the open is refused). The
/// devnode is THERE and coming up: resuming from sleep, restarting, reloading. It heals itself;
/// reloading the adapter underneath it only lengthens the outage.
/// * **Absent** — no instance at all. With an adapter devnode present this is the hostless-zombie
/// state a WUDFHost crash leaves (validated on-glass: PnP Status OK, no WUDFHost process, zero
/// interface instances). Only a reload clears it.
///
/// So: probe, wait out a not-ready device, reload an absent one after a short settle, and give the
/// interface a real arrival window afterwards. A reload is still attempted once at the end of
/// `not_ready_grace`, so a devnode wedged not-ready (a failed start) recovers exactly as it did
/// before. A genuinely uninstalled driver — no adapter devnode — still fails FAST, with no wait.
fn wait_for_interface(not_ready_grace: Duration, reload: bool) -> (Result<OwnedHandle>, bool) {
let started = Instant::now();
let mut deadline = started + not_ready_grace;
let mut absent_since: Option<Instant> = None;
let mut reloaded = false;
loop {
let mut probe = probe_device();
if let Some(h) = probe.handle.take() {
if reloaded || started.elapsed() > PROBE_INTERVAL {
tracing::info!(
waited_ms = started.elapsed().as_millis() as u64,
reloaded,
"pf-vdisplay: control interface available"
);
}
return (Ok(h), reloaded);
}
// Track how long we have seen NOTHING. Reset by any sighting, so a device that flickers
// between absent and not-ready is treated as the transition it is.
if probe.is_absent() {
absent_since.get_or_insert_with(Instant::now);
} else {
absent_since = None;
}
let absent_long_enough = absent_since.is_some_and(|t| t.elapsed() >= ABSENT_SETTLE);
if reload && !reloaded && (absent_long_enough || Instant::now() >= deadline) {
match reload_vdisplay_adapter() {
// No devnode at all — waiting cannot conjure a driver. Fail immediately rather than
// burning the arrival window on a box that simply does not have it installed.
AdapterCycle::NotInstalled => {
let e = Err(probe.into_error()).context(
"no punktfunk virtual-display adapter devnode exists — the driver is not \
installed",
);
return (e, reloaded);
}
AdapterCycle::Refused(why) => {
let e = Err(probe.into_error()).context(format!(
"the pf-vdisplay adapter devnode could not be reloaded ({why})"
));
return (e, reloaded);
}
AdapterCycle::Reloaded { .. } => {
reloaded = true;
absent_since = None;
deadline = (Instant::now() + ARRIVAL_AFTER_RELOAD).min(started + TOTAL_BUDGET);
}
}
}
if Instant::now() >= deadline {
let e = Err(probe.into_error()).context(format!(
"the pf-vdisplay control interface did not appear within {:?}{}",
started.elapsed(),
if reloaded {
" (including an adapter reload)"
} else {
""
}
));
return (e, reloaded);
}
std::thread::sleep(PROBE_INTERVAL);
}
false
}
#[cfg(test)]
@@ -906,6 +1170,96 @@ mod tests {
use std::thread;
use std::time::Duration;
/// The recovery must not be able to claim success it did not achieve. This is the whole bug:
/// the old script ran the cycle under `SilentlyContinue` and reported `(Get-PnpDevice).Status`,
/// so a device whose disable had been REFUSED — untouched, still started — reported `OK`, and
/// the host logged `cycled the adapter device … status=OK` while nothing had been cycled at all
/// (field report 2026-08-02). A refusal must decode as a refusal, carrying its reason.
#[test]
fn a_refused_reload_is_not_reported_as_a_reload() {
let refused =
classify_reload_output("REFUSED This device cannot be disabled because it is in use.");
match refused {
AdapterCycle::Refused(why) => {
assert!(why.contains("in use"), "the reason must survive: {why:?}")
}
other => panic!("a refused reload decoded as {}", variant(&other)),
}
// A bare device status — what the OLD script emitted on every path — must NEVER decode as a
// successful reload now, however healthy it looks.
for stale in ["OK", "Error", "Unknown"] {
assert!(
matches!(classify_reload_output(stale), AdapterCycle::Refused(_)),
"{stale:?} is a device status, not a reload outcome"
);
}
}
/// The outcomes callers branch on: `NotInstalled` fails a session fast, `Reloaded` earns the
/// arrival window, and the lever that worked stays visible in the log (`restart` means the
/// disable was refused and something still holds the device open).
#[test]
fn reload_outcomes_decode() {
assert!(matches!(
classify_reload_output("ABSENT"),
AdapterCycle::NotInstalled
));
match classify_reload_output("RELOADED cycle OK") {
AdapterCycle::Reloaded { how, status } => {
assert_eq!(how, "disable+enable");
assert_eq!(status, "OK");
}
other => panic!("expected Reloaded, got {}", variant(&other)),
}
match classify_reload_output("RELOADED restart OK\r\n") {
AdapterCycle::Reloaded { how, status } => {
assert_eq!(how, "pnputil /restart-device");
assert_eq!(status, "OK");
}
other => panic!("expected Reloaded, got {}", variant(&other)),
}
// powershell died before writing anything — an un-reloaded devnode, so `Refused`, not a
// silent success.
assert!(matches!(
classify_reload_output(" "),
AdapterCycle::Refused(_)
));
}
/// `is_absent` is what decides between WAITING and performing device surgery, so the two states
/// it separates are pinned here. An interface that is registered but not yet ACTIVE is a devnode
/// mid-transition — the wake-from-sleep case — and reloading the adapter under it only lengthens
/// the outage it is already recovering from.
#[test]
fn only_a_total_absence_counts_as_absent() {
let probe = |active, inactive| Probe {
handle: None,
active,
inactive,
last_err: None,
};
assert!(probe(0, 0).is_absent(), "no instances at all = absent");
assert!(
!probe(0, 1).is_absent(),
"a registered-but-inactive instance is a device coming up, not a missing one"
);
assert!(
!probe(1, 0).is_absent(),
"an active instance we merely failed to open is not a missing device"
);
// And the diagnostic names what was seen — the old message collapsed every one of these
// into "is the driver installed?", which sent a field report down the wrong path.
assert!(probe(0, 2).into_error().to_string().contains("2 inactive"));
}
fn variant(c: &AdapterCycle) -> &'static str {
match c {
AdapterCycle::Reloaded { .. } => "Reloaded",
AdapterCycle::NotInstalled => "NotInstalled",
AdapterCycle::Refused(_) => "Refused",
}
}
/// Live hardware round trip — `#[ignore]`d (needs the pf-vdisplay driver installed); run with
/// `cargo test -p pf-vdisplay -- --ignored live_create_drop`. Exercises the real trait path: open -> create -> hold -> drop (REMOVE).
#[test]
+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.
File diff suppressed because it is too large Load Diff
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.
+27 -12
View File
@@ -232,7 +232,7 @@ impl DataPump {
last_late = st.fec_late_shards;
last_received = st.packets_received;
last_dropped = st.frames_dropped;
last_bytes = st.bytes_received;
last_bytes = st.media_bytes_received;
last_report = Instant::now();
discard_abr_window = true;
flush_in_window = false;
@@ -317,11 +317,12 @@ impl DataPump {
"adaptive bitrate: capacity probe declined — keeping negotiated ceiling"
);
}
// The probe's FLAG_PROBE filler landed in `bytes_received` but never reached
// the decoder — rebase the ABR window's byte counter past it, or the next
// window's "actual throughput" reads as the burst rate and poisons the
// controller's proven-throughput high-water mark with the LINK rate.
last_bytes = st.bytes_received;
// Rebase the ABR window's byte anchor past the burst. (Probe filler is
// routed out of `media_bytes_received` at the reassembler, so it can no
// longer read as the burst rate on its own — but the anchor still has to
// skip the video that landed around the burst under a suppressed report
// tick, which would otherwise divide a long span's bytes by one window.)
last_bytes = st.media_bytes_received;
} else if Instant::now() >= deadline {
// The host never answered (a build that ignores ProbeRequest): clear the
// stuck-active state so LossReports resume, keep the negotiated ceiling.
@@ -454,11 +455,17 @@ impl DataPump {
// the next one.
let recovery_kf_reqs = pump_recovery_kf.swap(0, Ordering::Relaxed);
// The window's ACTUAL delivered throughput — what the pipeline really carried, vs
// the target it was allowed. Wire bytes (headers + FEC) slightly overstate the
// media rate the decoder ingests; acceptable for the climb gate / proven-mark
// semantics (both compare against targets with their own headroom).
// the target it was allowed. MEDIA bytes (data-shard payload: no headers, no FEC
// parity, no probe filler, no audio), because both consumers compare it against
// the ENCODER's target: the utilization gate asks "was the target genuinely
// tested?" and the proven mark bounds every later climb. Wire bytes answered a
// different question — they rise with the redundancy the host adds in answer to
// loss, so the gate read ~25 % high precisely on the links it exists for.
let window_ms = last_report.elapsed().as_millis().max(1) as u64;
let actual_kbps = (st.bytes_received.wrapping_sub(last_bytes).saturating_mul(8)
let actual_kbps = (st
.media_bytes_received
.wrapping_sub(last_bytes)
.saturating_mul(8)
/ window_ms) as u32;
// A discard window feeds the controller NOTHING — its signals are probe-tail
// residue, and one "congestion" verdict here ends slow start for good.
@@ -492,7 +499,15 @@ impl DataPump {
recovery_kf = recovery_kf_reqs,
"adaptive bitrate: requesting encoder re-target"
);
let _ = ctrl_tx.try_send(CtrlRequest::SetBitrate(kbps));
if ctrl_tx.try_send(CtrlRequest::SetBitrate(kbps)).is_err() {
// Never reached the control task — tell the controller, or three of
// these retire it for the session as "the host never acked".
abr.on_request_dropped();
tracing::warn!(
kbps,
"adaptive bitrate: control queue full — re-target dropped"
);
}
}
flush_in_window = false;
last_report = Instant::now();
@@ -500,7 +515,7 @@ impl DataPump {
last_late = st.fec_late_shards;
last_received = st.packets_received;
last_dropped = st.frames_dropped;
last_bytes = st.bytes_received;
last_bytes = st.media_bytes_received;
if pump_perf_on {
if let Some(p) = session.take_pump_perf() {
let per_pkt_ns = |ns: u64| ns.checked_div(p.packets).unwrap_or(0);

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