6267dcdcd3a368b1ec2027ebb77a301f9e991d8a
658
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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 |
||
|
|
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 |
||
|
|
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. |
||
|
|
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. |
||
|
|
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 |
||
|
|
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. |
||
|
|
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). |
||
|
|
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. |
||
|
|
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 |
||
|
|
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> |
||
|
|
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. |
||
|
|
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> |
||
|
|
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> |
||
|
|
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
|
||
|
|
f71bee917b |
Merge origin/main; the presenter rebuild's four settings join the sidebar
`#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> |
||
|
|
6de78213ee |
feat(decky): the settings tab covers the whole store, as a SteamOS-style sidebar
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
8d7e273a96 |
feat(client/present): PUNKTFUNK_PRESENT_MODE gains explicit mailbox and fifo_relaxed arms, and the docs stop guessing
The env knob silently folded 'mailbox' and every typo into the default arm, FIFO_RELAXED was not reachable at all, and clients/session/README.md claimed the default is FIFO (it is MAILBOX with a FIFO fallback). An AMD-on-Windows client always lands on FIFO because that driver offers no MAILBOX — now documented at the picker and in the docs-site client table, next to the ABR probe/ceiling knobs a field report went looking for and couldn't find. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
43e3c7b69f |
feat(ci/android): play-upload can attach release notes and promote a build
Two things it could not do, both needed now that a tag ships to production. Release notes: it never sent `releaseNotes`, so Play's "What's new" was whatever the previous release said. It now takes --release-notes-file, and refuses text over Play's 500-char-per-language cap with the actual count — that check has to happen before the upload, because the API only rejects it at commit, by which point the AAB is already on Play. Promotion: --promote assigns a versionCode that is already on Play instead of uploading, so what reaches production is the byte-identical artifact the testers ran. Rebuilding would mint a fresh versionCode from possibly-newer sources and ship something nobody tested. --promote-from asserts the code really is on that track (a typo'd versionCode now fails before it touches production) and clears that track in the SAME edit, so the build is never active on both at once. --user-fraction comes along because --status inProgress is an API error without it; it is validated as strictly between 0 and 1 rather than left to Google. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
2d3f9f8690 |
Merge remote-tracking branch 'origin/main' into audio/mic-latency-echo
ci / docs-site (push) Successful in 1m28s
ci / web (push) Successful in 2m36s
ci / rust (push) Failing after 2m43s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 8s
deb / build-publish (push) Successful in 3m30s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 56s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
ci / rust-arm64 (push) Successful in 4m10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m35s
deb / build-publish-client-arm64 (push) Successful in 3m38s
docker / builders-arm64cross (push) Successful in 5s
docker / deploy-docs (push) Successful in 30s
arch / build-publish (push) Successful in 7m0s
android / android (push) Canceled after 7m27s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 5m44s
release / apple (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 3m12s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 3m11s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 1m40s
windows-host / package (push) Canceled after 7m13s
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-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
|
||
|
|
951bcec650 | Merge remote-tracking branch 'origin/main' into audio/mic-latency-echo | ||
|
|
badda070ef |
docs(android): the stats-array KDoc counts the doubles it actually returns
ci / rust-arm64 (push) Successful in 1m25s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
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 5s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 17s
ci / web (push) Successful in 2m12s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 17s
ci / docs-site (push) Successful in 2m1s
docker / builders-arm64cross (push) Successful in 8s
docker / deploy-docs (push) Successful in 31s
android / android (push) Canceled after 6m2s
ci / rust (push) Canceled after 5m55s
nativeVideoStats grew to 33 with the decode split and the overflow counter, but its own KDoc still promised 30 and StatsOverlay still said 26 — a count that was already two extensions stale before this one. Both now list the full index set, with the JNI KDoc named as the authoritative one so the next extension has a single place to update. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
f9c56eaf5c |
feat(android): Automatic prefers AV1 where the silicon says it should
ci / docs-site (push) Successful in 2m12s
ci / web (push) Successful in 2m36s
ci / rust-arm64 (push) Successful in 3m20s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 6s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 6s
android / android (push) Successful in 6m13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 42s
ci / rust (push) Canceled after 6m49s
docker / builders-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Successful in 3m42s
arch / build-publish (push) Successful in 7m46s
deb / build-publish (push) Successful in 5m58s
deb / build-publish-host (push) Successful in 5m58s
apple / swift (push) Successful in 6m9s
apple / screenshots (push) Canceled after 3m57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 6m53s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 6m21s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
The P3 format A/B (NP3 ↔ RTX 4090, identical conditions) measured AV1 ~1.2 ms faster end-to-end than HEVC with slightly better codec-pure decode time. Under "Automatic" the client now sends AV1 as its soft preference when this device hardware-decodes it (the advertised AV1 bit is already gated on a real, non-blocked hardware decoder) AND it lacks FEATURE_PartialFrame — a partial-frame device keeps HEVC, whose slice-progressive overlap AV1 cannot ride (no slices, the chunked poll never arms). The host honors the preference only inside its probed shared codec set, so an AV1-less encoder still resolves HEVC, and an explicit user choice wins unchanged. The codec picker caption mirrors the same rule so "Automatic" says what it does on this device. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
b69ef02f4d |
fix(android): a cold-start connect no longer loses HDR or the native mode
A punktfunk:// deep link can reach the connect before the activity is attached to its display; context.display then throws and the display probes silently fell to their worst answers — displaySupportsHdr advertised SDR (the whole session pinned to 8-bit BT.709) and nativeDisplayMode fell back to 1080p60. Seen live on the NP3: one cold connect advertised hdr=false, the warm retry true, nothing in the log either way. Both probes now share probeDisplay: the context display when attached, else DisplayManager DEFAULT_DISPLAY — which IS the panel on phones and TVs; the activity-display distinction only matters on multi-display setups, where the attached path still wins whenever available. Each fallback leg logs itself, so a downgraded session can never again be silent about why. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
849baea881 |
feat(android): the stream re-votes its refresh rate and touches keep their curvature
surfaceChanged re-asserts the frame-rate vote (FIXED_SOURCE; ALWAYS only on the TV low-latency path, mirroring the native hint) — a buffer-geometry change on some OEM builds silently drops the 120 Hz pin mid-stream. Touch passthrough and direct-pointer moves forward the MotionEvent historical samples before the current point, so a fast swipe lands with its real shape; the trackpad path keeps summed deltas on purpose — its acceleration curve is tuned for per-frame dt and historicals would change the feel, not the sum. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
c767a904d2 |
feat(android): the decode stage answers where its time goes, HUD on or off
P3 decode science: every AU is stamped as its last piece enters the codec, so the decode stage splits into feed (received→queued: hand-off + input-slot wait) and codec (queued→decoded: the decoder alone — a slice head start would show here). The split + an always-on capture→decoded e2e ride the 1 Hz pf-present line, so a wireless HUD-off A/B reads everything from logcat; the HUD equation gains the split (indices 30/31), the skipped counter tells benign newest-wins pacing from parked-AU overflow (32), and a −2-refresh Apple-HUD-equivalent twin makes iPhone comparisons honest (Apple shaves its OS floor; Android shows raw). Connect now logs the per-mime decoder picks + FEATURE_PartialFrame verdicts (tag pf.caps) — on the NP3 all three c2.qti low-latency decoders say no, so parts delivery never arms and P2d is inert there; a debug.punktfunk.force_parts sysprop overrides the probe for the on-glass question the API cannot answer. Forced on glass: c2.qti accepts PARTIAL_FRAME pieces without erroring but only assembles them — codec time unchanged, so the overlap is dead on SM8735 either way. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
0985726415 |
fix(host,web): an empty update channel stops looking like a broken host
android / android (push) Canceled after 36s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 37s
ci / rust (push) Canceled after 37s
ci / rust-arm64 (push) Canceled after 36s
ci / web (push) Canceled after 34s
ci / docs-site (push) Canceled after 34s
deb / build-publish (push) Canceled after 19s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-host / package (push) Canceled after 33s
windows-host / winget-source (push) Canceled after 0s
flatpak / build-publish (push) Successful in 5m43s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 6m59s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m9s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 2m19s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
A channel nobody has published to answers `manifest.json` with a 404, and the check reported that the same way it reports a dead registry or a bad signature: "Last check failed: feed returned HTTP 404". Every host on the stable channel shows it today, because the stable manifest only publishes when someone dispatches `announce` for a release tag — so the first thing an operator sees from the new Updates card is a red failure caused by nothing being wrong. The shared checker now distinguishes the two. `feed::fetch_manifest_blocking` returns a typed `FeedError` instead of a string, and only a 404 on the manifest ITSELF becomes `NotPublished` — a 404 on the detached signature still fails loudly, because that is the half-published pair the manifest-then-signature upload order can produce, and it must stay fail-closed. The host carries that through as `UpdateStatus.not_published`, mutually exclusive with `last_error`. It is benign only while no manifest has ever been seen for the channel: once a check has succeeded, the same 404 means the feed LOST a document it used to serve, which stays an error. The console then shows a plain sentence naming the channel instead of the failure banner, and "None published yet" rather than "Not checked yet". The Linux client makes the same distinction but deliberately NOT the same choice: `--check-update` keeps exiting 1 and keeps `error` set, because its consumer is a shell script and an empty channel is the absence of evidence that this build is current — not a confirmation that it is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a7143a6510 |
Merge origin/main into audio/mic-latency-echo
Main moved through the same surfaces while the audio work was in flight, so three files needed hand-resolution: - clients/windows/src/app/settings.rs — main gave Windows its speaker and microphone endpoint pickers, the gap this branch could only report. Both keep their rows: the pickers, then Echo cancellation, and the microphone description keeps the sentence naming the mute chord. - crates/pf-console-ui/src/screens/settings.rs — both sides grew the couch row list. Main's seven new rows and Echo cancellation are all reachable in Gaming Mode; the count is 22 and the rationale comment names echo cancellation among the fields that would otherwise be unreachable there. - crates/pf-presenter/src/run.rs — both sides added a stats test at the same line. Both are kept. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
43a631ea9c |
fix(clients): a host that re-keys stops locking the client out for good
ci / web (push) Successful in 1m2s
ci / docs-site (push) Successful in 1m21s
ci / rust-arm64 (push) Successful in 2m48s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 17s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 13s
deb / build-publish-client-arm64 (push) Successful in 2m25s
docker / deploy-docs (push) Canceled after 25s
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 (push) Successful in 3m52s
apple / swift (push) Successful in 5m20s
arch / build-publish (push) Successful in 6m52s
docker / builders-arm64cross (push) Successful in 8s
android / android (push) Canceled after 7m17s
deb / build-publish-host (push) Successful in 5m45s
apple / screenshots (push) Canceled after 1m5s
ci / rust (push) Canceled after 7m24s
flatpak / build-publish (push) Canceled after 3m32s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m58s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 3m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 2m47s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 11s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Reinstall a host, wipe its ProgramData, or otherwise regenerate its identity, and the desktop clients refused it forever: "Host identity rejected — wrong fingerprint, or the host requires pairing", including immediately after a successful re-pair. There was no way out of it from the UI — the host list showed two cards for one address and forgetting the wrong one was a guess. `KnownHosts::upsert` matches on the FINGERPRINT, which is what lets a host that moved address keep its record and everything the user set on it. A host that changed identity matched nothing, so pairing appended a SECOND record for an address that already had one, and `find_by_addr` returned whichever came first in the file — the dead one, every time. Trust decisions (PIN ceremony, delegated approval, TOFU accept, headless pair — all funnelled through `persist_host`, plus the Windows shell's two direct upserts) now go through `upsert_trusted`, which retires any OTHER record for that address. Retired means DELETED, not demoted: a record whose certificate the host no longer holds cannot connect, so keeping it only reproduces the two- cards-one-address confusion this fixes. What described the box rather than the identity — its MAC, its OS chain, the bound profile, the pinned cards, when it was last used — moves onto the record that survives, so a reinstall doesn't quietly cost the user their setup. What described the dead identity does not: `paired` and `clipboard_sync` are decisions about one specific certificate and have to be made again for a new one, and the retired record's stable id stays retired (a deep link written from it falls through to the `host=` recovery the link grammar already specifies). Only trust decisions may retire a record. The wake path's address re-key and every learn-from-advert path stay on plain `upsert`: those are driven by unauthenticated mDNS, and letting an advert delete a saved host by claiming its address would trade this bug for a much worse one. A plain reconnect still fails closed on a pin mismatch — nothing here changes what the pin is checked against. Stores that already hold the duplicate recover on the next connect, not at load: which of two records is live isn't knowable at load time and guessing wrong would throw away the good one. Instead `find_by_addr` stops being positional — a real fingerprint beats a placeholder, and among real ones the newest trust decision wins, since records are only ever appended by one. The next successful pair then cleans the store up for good. Every lookup that picks a pin or a per-host decision for a connect now goes through it (the session's pin and clipboard read, the deep-link resolver, orchestrate's plan, both speed tests, the CLI's --wake and --library, which had also been ignoring the port), and an advert's learned MAC/OS lands on the record it identified rather than on a stale namesake that merely came first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8e877ad25f |
fix(android): a mute is a pause, not a hole the host tries to conceal
The Android uplink kept advancing `seq` while muted, so the first frame after an unmute looked to the host like loss the width of the mute. The de-jitter reads that as a gap: up to five concealment frames of stale voice, and a seq gap counted in the uplink-health line. Past 600 ms the pump's stale flush resets the chain first and hides it, which is why the usual long mute looks fine — a quick toggle does not. Freeze `seq` while muted, as the desktop uplink already does, so the frame after an unmute continues the chain. `reset_stream` says it plainly: a pause is not loss, and must not conceal or count a gap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
0c4a543831 | Merge branch 'audio/desktop-mute-and-aec-toggle' into audio/mic-latency-echo | ||
|
|
4c98e2f788 | Merge branch 'audio/android-mic-mute' into audio/mic-latency-echo | ||
|
|
7ec3107b4a |
feat(client): the mic has an off switch, and echo cancellation has a switch too
Ctrl+Alt+Shift+V mutes and unmutes the microphone mid-stream — V for
voice, since M and S were taken. The uplink keeps running while muted:
`MicStreamer::spawn` takes a shared AtomicBool the capture callback reads
every quantum, and a muted callback drains whole frames and sends
nothing. Stopping the stream instead would have re-primed the device
buffers and, on Linux, re-run source selection on every unmute — a
second of glitch for a key people press mid-sentence. The sequence
counter deliberately does NOT advance while muted, so the host sees one
continuous sequence with a pause rather than a gap the size of the mute,
which its de-jitter would try to conceal frame by frame (its 600 ms
stale-flush covers the rest).
The mute lives on SessionHandle as a MicControl with two flags, not one:
`live` is raised by the pump only once the uplink is actually running, so
a session with the mic off in Settings — or whose capture device wouldn't
open — reports "nothing to mute", the chord says so in the log, and no
indicator appears. Per session, never persisted.
Muted state draws as a persistent "Microphone muted" badge in the stream's
top-right corner, off `FrameCtx::mic_muted` rather than the stats text: it
has to be there with the stats overlay Off, which is where most people
leave it. The Detailed mic line still reads throughput, so it simply falls
to zero — the badge is what answers "am I muted".
Echo cancellation stops being an env-only lever. `Settings::echo_cancel`
(default on, `#[serde(default)]` so every stored file loads with it on)
now gates the same hooks PUNKTFUNK_NO_AEC gated: the echo-cancelled
PipeWire source preference and WASAPI's Communications stream category.
The env var still wins, one-way — it can only turn AEC off, never back on
— and both `aec_enabled` helpers say so. The row ships in the GTK, WinUI
and console settings, under the microphone toggle and greyed out while it
is off, matching what Apple and Android shipped in wave 1.
SettingsOverlay grows `echo_cancel` as a first-class field — apply,
absorb, clear, is_empty — instead of riding the `extra` passthrough, where
`clear_override("echo_cancel")` answered false. The JSON key is the one
Apple and Android already write, so one catalog round-trips through all
three.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|