Compare commits

..
Author SHA1 Message Date
enricobuehler 4ee095220f The in-flight budget tests still assumed buffer-only accounting (#254)
android / android (push) In progress
arch / build-publish (push) In progress
ci / rust (push) In progress
ci / web (push) In progress
deb / build-publish-gamescope (push) Waiting to run
deb / build-publish-client-arm64 (push) Waiting to run
deb / smoke-install (push) Blocked by required conditions
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Waiting to run
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Waiting to run
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Waiting to run
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Waiting to run
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Waiting to run
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Waiting to run
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Waiting to run
docker / builders-arm64cross (push) Blocked by required conditions
docker / apps (., web/Dockerfile, punktfunk-web) (push) Waiting to run
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Waiting to run
docker / deploy-docs (push) Blocked by required conditions
flatpak / build-publish (push) Waiting to run
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Waiting to run
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Waiting to run
windows-host / package (push) Waiting to run
windows-host / canary-manifest (push) Blocked by required conditions
windows-host / winget-source (push) Blocked by required conditions
ci / docs-site (push) In progress
ci / bun-nix (push) Successful in 25s
deb / build-publish (push) In progress
ci / rust-arm64 (push) Successful in 1m29s
deb / build-publish-host (push) In progress
apple / swift (push) Successful in 2m8s
apple / screenshots (push) Waiting to run
apple / distribute (push) In progress
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m4s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) In progress
2026-08-15 16:41:33 +00:00
enricobuehler d9d877f985 Merge pull request 'The shared clipboard now works on iPhone and iPad' (#250) from worktree-worktree-ios-clipboard into main
apple / swift (push) Successful in 1m55s
ci / bun-nix (push) Successful in 28s
ci / web (push) Successful in 1m16s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
ci / docs-site (push) Successful in 1m28s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 21s
docker / builders-arm64cross (push) Successful in 7s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m11s
docker / deploy-docs (push) Successful in 34s
apple / distribute (push) Successful in 10m53s
ci / rust-arm64 (push) Successful in 12m27s
apple / screenshots (push) Successful in 9m34s
ci / rust (push) Canceled after 17m50s
Reviewed-on: #250
2026-08-15 16:15:12 +00:00
enricobuehler 8c4a41913f The pad sink wore a profile name GE-Proton matches nothing on, so neither haptics nor speaker could route (#252)
ci / rust-arm64 (push) Successful in 2m1s
ci / bun-nix (push) Successful in 2m38s
ci / rust (push) Canceled after 3m21s
ci / web (push) Canceled after 3m6s
ci / docs-site (push) Canceled after 3m6s
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/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
deb / build-publish-gamescope (push) Successful in 28s
deb / build-publish (push) Successful in 3m54s
deb / build-publish-client-arm64 (push) Successful in 3m8s
android / android (push) Successful in 7m50s
arch / build-publish (push) Successful in 11m51s
deb / build-publish-host (push) Successful in 8m53s
deb / smoke-install (push) Successful in 2m35s
windows-host / package (push) Successful in 16m8s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 16s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 13m5s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 12m21s
Merged with ci/rust red. That check fails identically on unmodified main at the base commit f04da20f (run 18516) and reproduces locally with --test-threads=1, in punktfunk-core::packet::tests — a crate this branch does not touch. Pre-existing breakage from 59d8b8a6, tracked separately.
2026-08-15 16:11:45 +00:00
enricobuehler a7da3e4cb0 Merge pull request 'The host knew about every one of these faults and had no way to say so' (#253) from worktree-console-diagnostics into main
ci / rust-arm64 (push) Successful in 2m26s
ci / bun-nix (push) Successful in 55s
deb / build-publish (push) Successful in 4m33s
ci / web (push) Successful in 8m6s
ci / rust (push) Failing after 8m55s
deb / build-publish-gamescope (push) Successful in 1m58s
ci / docs-site (push) Successful in 8m31s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 49s
arch / build-publish (push) Successful in 11m38s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 1m26s
deb / build-publish-client-arm64 (push) Successful in 3m37s
deb / build-publish-host (push) Successful in 5m8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 50s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 1m40s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 14s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 34s
docker / builders-arm64cross (push) Successful in 18s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m8s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m17s
android / android (push) Canceled after 15m26s
docker / deploy-docs (push) Successful in 31s
deb / smoke-install (push) Canceled after 1m52s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 2m1s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 2m0s
windows-host / package (push) Canceled after 15m33s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
Web-console diagnostics v1 (design/web-console-diagnostics.md WP1–WP3).

Merged with `ci / rust` red, deliberately: that job's Test step fails on two
punktfunk-core tests — packet::tests::in_flight_buffer_budget_bounds_allocation and
packet::tests::streamed_open_commits_its_own_extent_and_stays_bounded — which this
branch cannot affect (it touches zero files under crates/punktfunk-core).

The same two fail on main's own push run (18516, sha f04da20f) and reproduce locally
on a clean checkout. Root cause: 59d8b8a6 "meter per-block reassembly state against
the in-flight budget" changed reassemble.rs without updating packet/tests.rs, so both
tests still assert the pre-change budget arithmetic. Fixing those expectations is a
separate, focused change against that commit's intent — not something to guess at from
inside a console feature.

Every other gate is green on this branch: fmt, unsafe-hygiene, clippy -D warnings (both
the default and native-only trees), build, rust-arm64, web, docs-site, bun-nix.
2026-08-15 15:56:19 +00:00
enricobuehler 998e5d3379 docs: answer the "set the controller audio to Pro Audio" question our sink cannot offer a switch for
android / android (pull_request) Successful in 4m38s
ci / web (pull_request) Successful in 2m3s
ci / rust-arm64 (pull_request) Successful in 2m6s
ci / docs-site (pull_request) Successful in 1m50s
ci / bun-nix (pull_request) Successful in 4m0s
ci / rust (pull_request) Failing after 15m36s
The community fix for DualSense haptics on Linux is to open sound settings and flip the pad's
card profile to Pro Audio, and a field report compared that against our minted sink: theirs has a
Profile dropdown reading "Pro Audio", ours has no dropdown at all. That reads like something is
missing, and it is worth saying plainly that it is not — a real pad is a USB sound card and gets
a profile selector, ours is a software device with no card, and the layout the selector exists to
reach is the one we are already minted in.

New page covers the whole feature end to end: what a DualSense's speaker and voice coils actually
are (a four-channel audio stream, not rumble), the USB-only requirement on the client, GE-Proton
11-5 on the host, why every other card profile folds the coil channels away, the three host log
lines that say how far it got, and the per-game Proton launch options that force GE onto its most
direct route. That last one now has teeth: with the sink advertising a split parent, GE's
`PROTON_DUALSENSE_HAPTICS_PREFER_NON_EVENT=1` opens our node by name and writes the four channels
in with no remix anywhere in between.

Known limits are stated rather than left to be rediscovered: Bluetooth pads have no audio
interface, container-ID matching cannot work behind a uhid pad with no USB device to derive one
from, and a real pad plugged into the host itself can win the match against the one you are
streaming to.
2026-08-15 17:33:44 +02:00
enricobuehler bec2d193c4 fix(audio): the pad sink wore a profile name GE-Proton matches nothing on, so neither haptics nor speaker could route
Everything a title needs to find the DualSense's audio device on a Linux host is a substring of
the sink's name or a key in its proplist, and ours were chosen against an older reading of
GE-Proton. Three of them were wrong, and each one cost a different piece of the feature.

**The profile suffix.** We minted `-00.analog-surround-40`. GE's `is_dualsense_speaker_sink()` is
a substring test for `Speaker__sink`, and three things hang off it: the pad-SPEAKER (mono
controller-effect) streams only bind and retarget to a sink it accepts, its
`apply_windows_sony_audio_format()` forces the wine endpoint to the Windows 4x48 kHz
`KSAUDIO_SPEAKER_QUAD` layout DS5 titles probe for, and the endpoint lands on the identity
Spider-Man's working path used. `analog-surround-40` matched none of it — the speaker half of
this feature had nothing to attach to at all. Now `-00.HiFi__Speaker__sink`.

The channels stay Pro Audio's four raw AUX, which is deliberately not the same real-pad profile
the name comes from. Since alsa-ucm-conf gained `USB-Audio/Sony/DualSense-PS5` a real pad's
profiles are UCM SplitPCM views of one 4-channel PCM — a mono Speaker, a stereo Headphones, a
4-channel Direct "for wine compatibility" — and all of them are POSITIONED. GE renders haptics as
an `AUX0..AUX3` stream, so on every one of those the graph re-mixes and the voice-coil pair is
folded away; only Pro Audio's raw AUX node passes it through by index. That is the whole content
of the field advice "you only need the controller audio set to Pro Audio". No single real profile
satisfies both halves, so we take the name from one and the layout from the other.

**`api.alsa.split.name` was absent.** GE reads it off the sink it is about to render haptics into
and opens THAT node through its bundled pipewire-alsa plugin as `pipewire:NODE=<name>` with
`aux_channels=1`. Without the key `get_dualsense_haptic_target()` returns NULL and the leg cannot
engage — including for the titles GE auto-switches into "Windows Sony audio mode" after eight
format probes. We have no split, so the honest value is our own node name;
`PUNKTFUNK_PAD_SINK_SPLIT_NAME` drops or overrides it in the field. Carrying `Speaker__sink` and
a split parent at once is a real pad's shape rather than a contrivance: GE's
`is_dualsense_endpoint_speaker_sink` notes that "Edge speaker sinks may also carry raw haptic
metadata" and handles the pair by keeping them as routing targets while withholding the shared
mono endpoint id.

**The node name broke its own matcher.** It carried an invented `DualSense_` infix, but a plain
DualSense's USB iProduct string is just "Wireless Controller" — only the Edge has a model word —
and that infix split the contiguous `Sony_Interactive_Entertainment_Wireless_Controller` substring
the community WirePlumber rule and GE's own name-only fallback key on.

The proplist now also states the shape (`audio.position`, `audio.channels`) and the ALSA card
names; pipewire-pulse hands every one of these to a Proton client verbatim. Mint-time logging and
the `pad-sink-test` devtest print the strings a title has to match, plus the channel-map a
hand-driven test file needs to reach the coils at all.
2026-08-15 17:33:39 +02:00
enricobuehler 6776bff82d feat(host,console): the host knew about every one of these faults and had no way to say so
ci / web (pull_request) Successful in 1m8s
ci / docs-site (pull_request) Successful in 1m18s
ci / rust (pull_request) Failing after 4m6s
ci / bun-nix (pull_request) Successful in 4m20s
android / android (pull_request) Successful in 13m55s
ci / rust-arm64 (pull_request) Successful in 6m14s
Implements design/web-console-diagnostics.md, WP1-WP3 (the v1). WP4 (live checks + SSE)
and WP5 (instant helpers) stay deferred, as that plan sequences them.

The `.181` incident is the type specimen. `preflight_takeover_privilege()` is the most
careful probe in the repo — four applicability gates, and it even separates user-database
membership from the running process's supplementary groups — and it spends all of that
care on ONE WARN log line. A console-driven update never shows scriptlet stderr, so the
operator's only symptom was a black screen on every connect. The class of bug is "the host
knows, and the person operating it has no way to find out".

Host
----
`diagnostics.rs` holds the `HostCheck` model and a process-global registry (probes in,
cached verdicts out; `POST /refresh` re-runs them). `inapplicable` is a first-class status
rather than an absent row, so the page can answer "why isn't this check relevant here?"
instead of silently hiding it. `diagnostics/catalog.rs` maps verdicts to wire checks and
owns every user-visible string.

`GET /api/v1/diagnostics` + `POST /api/v1/diagnostics/refresh` are **admin lane only**.
Neither allowlist in `auth.rs` is touched: both are opt-in, and these verdicts carry the
host user's name, its group layout and device-node state. The route-classification test
gets both rows so that stays a reviewed decision rather than a default.

Probes stay in their owning crates and export plain verdict enums; the host does the
mapping. No reverse dependency — `pf-inject`/`pf-vdisplay` never learn about host types.

* `pf-vdisplay`: `preflight_takeover_privilege()` now logs FROM
  `takeover_privilege_verdict()`. The WARN line is unchanged, deliberately: headless
  operators read logs, not consoles, and this moves where the verdict GOES, not what it
  says.
* `pf-inject`: `uinput_probe()` keeps the errno that `pen_supported()` throws away, so
  "you are not in the input group" (EACCES) and "the module was never installed" (ENOENT)
  stop looking identical — they need opposite remedies. `vhci_probe()` reports device
  facts only (module present, node writable by this process); who to blame is the host's
  question, because only the user database can answer it.

Two distinctions the catalog refuses to collapse:

1. **User-database membership vs this process's groups.** `usermod -aG` satisfies the
   first immediately and the second not until the next login. Collapsing them produces
   the single most maddening support state there is — "I already added myself!" — which
   nothing in the logs distinguishes today. It now gets its own remedy: log out, no
   command to run.
2. **`usermod` does not stick on an atomic OS.** On the Universal Blue images the remedy
   is `ujust add-user-to-input-group`. Matched on the OS chain's LEAF, never on the
   `fedora` family token: plain Fedora Workstation is mutable and does want `usermod`.

Console
-------
The dashboard gets an `AttentionCard` that renders nothing at all on a healthy host
(`ConflictsCard`'s rule), shows at most the 3 worst checks, and links onward rather than
explaining — a dashboard that starts teaching remedies stops being a dashboard.

Its badge says the **severity**, not the status. The badge's colour already encodes
severity, so a badge reading "Failing" on both a red and an amber row leaves the
difference between them carried by colour alone — which is the thing the `pin_pending`
precedent exists to prevent. Caught on glass, not in the diff.

The Logs page becomes Troubleshooting: checks above the log stream, because when the
checks are green and something is still broken the log is the natural next step. The
ROUTE stays `/logs` — bookmarks and deep links outlive a label. `LogsCard` grows a
heading, since the page title no longer names it.

A check id this console has never heard of still renders, from the host's English
`summary`/`impact`/`remedy.text`. That is what makes console N paired with host N+1
survivable, and it is enforced as a test rather than left as a convention. The situational
prose is deliberately NOT duplicated into the message catalogues: one check has many
shapes (the vhci one alone has four distinct causes), and copying ~20 sentences into a
package that versions independently of the one that generates them is the very drift this
design set out to avoid. The console localizes the check NAMES and all chrome; when the
host sends a shape discriminator alongside `id`, localized prose can key off it.

Verification
------------
`cargo clippy -D warnings` and `cargo fmt --all --check` clean on Linux; 12 diagnostics
unit tests and 48 mgmt handler tests green, including the openapi drift test —
`api/openapi.json` and the ungated `docs-site/public/openapi.json` copy are both
regenerated. Web: build, lint, `bun test server/`, storybook, and the new stories shot in
both themes with the theme flip verified rather than assumed.

Not yet on glass: the real `.181` box (all three vhci states), `.138` for the
unpackaged-helper path, and the old-console/new-host pairing legs.
2026-08-15 17:30:13 +02:00
enricobuehler 37d39295aa test(core): the in-flight budget tests still assumed buffer-only accounting
apple / swift (pull_request) Successful in 1m59s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m41s
ci / web (pull_request) Successful in 1m26s
android / android (pull_request) Successful in 5m19s
ci / bun-nix (pull_request) Successful in 22s
ci / docs-site (pull_request) Successful in 1m25s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m9s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m33s
ci / rust (pull_request) Successful in 20m48s
`59d8b8a6` (security-review 2026-08-15 finding 11) started metering BlockState against the
in-flight budget, because both its vectors are sized from attacker-declared header fields and a
slice-streamed frame could otherwise mint thousands of unmetered blocks. That fix is right and
stays. What it missed is that two tests encode the OLD cost model as arithmetic in their
comments — "exactly 32 such frames fit; the 33rd must be refused", "four fit the budget, the
fifth must be refused" — so a stricter, more correct budget reads as a failure:

  in_flight_buffer_budget_bounds_allocation          7 drops, expected 1
  streamed_open_commits_its_own_extent_and_stays_bounded   2 drops, expected 1

Both numbers are exactly what the new metering predicts (a 512 B buffer + 104 B of block state
takes 26 of the 16384 B budget, not 32; a ceiling-claiming 4096 B open takes 3, not 4), so the
tests were measuring the hole rather than the firewall. main has been red on `ci / rust` since.

Derive the refusal boundary from the cost model instead of baking in a frame count: the tests now
ask block_state_bytes() how much a frame commits, push exactly one frame past what fits, and
assert the same property as before — everything under the budget accepted, the one past it
dropped. A future field on BlockState moves the boundary and the tests follow it, rather than
failing with an arithmetic puzzle that invites re-hardcoding whatever number CI last printed.

Also assert the invariant the counts were only ever a proxy for: `in_flight() <= budget` at the
end of each. That one catches a release site forgetting half the cost — the accounting-drift
failure `in_flight`'s own doc comment warns about, which surfaces in the field as a permanent
loss storm once the budget wedges.

IN_FLIGHT_BUF_FACTOR and block_state_bytes become pub(super) (the LOSS_WINDOW_NS precedent);
no production behaviour changes.

Verified: punktfunk-core 414/414 with --all-features (the superset of CI's failing target),
`cargo fmt --all --check` clean, clippy -D warnings clean. Mutation-checked: inflating
IN_FLIGHT_BUF_FACTOR 1000x makes both tests fail with 0 drops, so neither went vacuous.
2026-08-15 17:01:45 +02:00
enricobuehler f04da20f0d A Bluetooth Steam Controller 2 was invisible — nothing ever asked for BLUETOOTH_CONNECT (#251)
ci / bun-nix (push) Successful in 19s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 19s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 18s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 30s
docker / builders-arm64cross (push) Successful in 31s
ci / rust (push) Failing after 5m41s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m52s
docker / deploy-docs (push) Successful in 32s
ci / rust-arm64 (push) Successful in 7m20s
ci / web (push) Successful in 7m21s
ci / docs-site (push) Successful in 7m30s
android / android (push) Successful in 14m34s
2026-08-15 14:25:29 +00:00
enricobuehler 8740f48c92 feat(apple): the shared clipboard now works on iPhone and iPad
ci / web (pull_request) Successful in 1m11s
ci / docs-site (pull_request) Successful in 1m16s
apple / swift (pull_request) Successful in 2m2s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / bun-nix (pull_request) Successful in 4m29s
ci / rust-arm64 (pull_request) Successful in 5m55s
ci / rust (pull_request) Failing after 14m20s
The clipboard bridge was written against NSPasteboard and gated `#if os(macOS)`,
so the iOS half of the same universal app had a per-host toggle it could not
show and a wire plane it never opened — even though the core's clipboard ABI is
in every slice of the framework and nothing below the pasteboard was
platform-specific.

Rather than keep a second copy of the subtle half (one drain thread, offer
sequence numbers, the pending fetches a blocked paste waits on, echo
suppression), that logic moves into a `ClipboardPasteboard` seam and stays
shared. What genuinely differs is small and lives in two adapters: AppKit
fulfils a paste by blocking a provider thread, UIKit by answering an
NSItemProvider load handler; AppKit transcodes images through NSImage, UIKit
through UIImage. macOS behaviour is unchanged — same poll interval, same
timeouts, same lock discipline.

Two things the iOS side needs that the Mac does not.

Backgrounding the app ends the session, so a lazy promise on the pasteboard
would outlive anything able to answer it and "copy on the host, switch to
Safari, paste" would hand Safari nothing. So a host offer still unpasted when
the sync tears down is pulled across then — bounded to 8 MiB and 3 seconds,
skipped entirely if it was already pasted. Everything else stays lazy: copy on
the host, stay in the app, paste nothing, and no clipboard bytes move.

And since iOS 14 reading pasteboard *contents* is a privacy event the user
sees, while reading its change count and type list is not. That maps onto the
lazy design exactly, so the announce poll stays silent however long a session
runs; the one real read happens when someone on the host pastes. It now runs
off the drain thread, because on iOS 16+ that read can sit waiting for the
user's answer, and the drain thread is what would deliver the host's cancel.

One bug fixed while moving the code: ownership of the pasteboard was recorded
from a write's resulting change count without checking the write had landed. A
dropped write would have made the sync read the user's own next copy as its own
echo and never announce it again.

tvOS has no pasteboard, so the guards became `#if !os(tvOS)` rather than
widening to every platform.

Verified: macOS `swift build` + 319 tests green (15 new), iOS and tvOS
typechecks via the `--triple` recipe, and `xcodebuild` of the shipping
Punktfunk-iOS scheme.
2026-08-15 15:56:44 +02:00
enricobuehler 60b652d5e2 fix(android): a Bluetooth Steam Controller 2 was invisible — nothing ever asked for BLUETOOTH_CONNECT
ci / bun-nix (pull_request) Successful in 3m33s
ci / rust-arm64 (pull_request) Successful in 3m51s
ci / web (pull_request) Successful in 5m19s
ci / docs-site (pull_request) Successful in 5m45s
ci / rust (pull_request) Failing after 6m19s
android / android (pull_request) Successful in 6m50s
Reported from the field: an SC2 wired or on its Puck dongle changes the console UI to the
controller layout, but the same pad paired over Bluetooth "doesn't come up as a controller at
all".

The manifest declared BLUETOOTH_CONNECT and all three BLE gates tested for it — MainActivity's
menu capture, StreamScreen's stream capture, and the Controllers screen — but no code path in
the client ever REQUESTED it. It is a runtime permission from API 31, so the checks answered
DENIED forever and the BLE transport was unreachable in every shipped build. The failure was
silent by construction: `bondedDevices` behind a missing permission throws, and the enumeration
caught that into an empty list, which reads exactly like "no controller is paired".

- Ask for it, but only of people it helps: an uncaptured SC2 sits in lizard mode as a
  keyboard/mouse InputDevice, which needs no permission to enumerate, so `sc2InputDevicePresent`
  is the evidence that justifies the prompt. MainActivity asks once per process on that signal
  and engages the capture on the grant.
- The Controllers screen offers the grant outright, since the probe reads a USB identity we
  cannot assume a BLE stack reports. `sc2BluetoothGrantOffered` keeps that offer to the cases
  where it changes something — pure, and table-tested.
- Declare the legacy BLUETOOTH permission below API 31. BLUETOOTH_CONNECT does not imply it, so
  the bonded list and connectGatt would have thrown SecurityException on Android 11 and older
  even once the runtime half worked.
- Gate the link on the permission itself and log when it is missing, so the next occurrence of
  this says so in logcat instead of looking like an absent controller.

Verified: :kit:testDebugUnitTest + :app:testDebugUnitTest green (5 new), manifest merge carries
both permissions. On-glass with a BLE-paired SC2 is owed.
2026-08-15 15:55:56 +02:00
enricobuehler fb61e69a3e Merge pull request 'Host-source stutter: both stutters fixed at the source, and frames finally play on the host's cadence' (#249) from worktree-host-source-stutter-fixes into main
ci / rust-arm64 (push) Successful in 7m17s
apple / swift (push) Successful in 2m2s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m23s
android / android (push) Successful in 7m53s
deb / build-publish-gamescope (push) Successful in 25s
deb / build-publish (push) Successful in 3m40s
arch / build-publish (push) Successful in 8m44s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 21s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 24s
deb / build-publish-client-arm64 (push) Successful in 1m5s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 14s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 15s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 18s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 17s
docker / builders-arm64cross (push) Successful in 11s
apple / distribute (push) Successful in 11m2s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m15s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 6m55s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8m24s
deb / build-publish-host (push) Successful in 14m24s
docker / deploy-docs (push) Successful in 58s
apple / screenshots (push) Successful in 9m45s
flatpak / build-publish (push) Successful in 10m21s
deb / smoke-install (push) Successful in 5m41s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m39s
windows-host / package (push) Successful in 14m1s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 26s
ci / web (push) Successful in 1m7s
ci / docs-site (push) Successful in 10m43s
ci / bun-nix (push) Successful in 36s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m33s
ci / rust (push) Failing after 3m31s
Reviewed-on: #249
2026-08-15 13:13:06 +00:00
enricobuehler 0e8149fa9c Merge pull request 'Our virtual DualSense wakes Bazzite's ds_inhibit into an SELinux audit storm that freezes the stream' (#248) from worktree-ds-inhibit-storm into main
android / android (push) Canceled after 4m25s
arch / build-publish (push) Canceled after 5m3s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
deb / build-publish (push) Canceled after 30s
deb / build-publish-host (push) Canceled after 30s
deb / build-publish-gamescope (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
deb / smoke-install (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / deploy-docs (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
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 9s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 9s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
2026-08-15 13:08:01 +00:00
enricobuehler 3fd0a94528 feat(desktop): present on the host's cadence under smoothness, and let VRR finally be worth something
android / android (pull_request) Failing after 1m47s
apple / swift (pull_request) Successful in 2m7s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m48s
ci / bun-nix (pull_request) Successful in 26s
ci / web (pull_request) Successful in 6m47s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m38s
ci / rust-arm64 (pull_request) Successful in 7m12s
ci / docs-site (pull_request) Successful in 4m12s
ci / rust (pull_request) Failing after 10m0s
WP5 of design/presenter-cadence-rework-implementation-plan.md — the desktop
binding of the CadenceClock landed in e207d0d7.

`target != last_target_ns` is gone, and `last_target_ns` with it. That equality
was defect D3 verbatim: one present per latch slot, decided by slot identity
rather than by when the frame was actually due. In its place a frame is served
when its own due time says so — under snapping, `due < next_slot_after(now +
margin)`. That `<` is derived rather than picked: `next_slot_after` is monotone,
so "this frame's target slot is no later than the earliest slot still reachable"
reduces to exactly it.

Desktop is §5's simplest row and the code says so — `decoded_ns` and
`session::now_ns()` are both CLOCK_REALTIME, so `ready_ns` goes in raw and no
conversion appears anywhere in the path. The domain check is still asserted, with
source stamps 56 years away from the present clock.

**VRR stops being merely not-worse.** Where the presenter has MEASURED variable
refresh — the existing `CadenceProbe` verdict, not a capability bit — the snap is
skipped and the frame is presented at its due time under `free_running()` tuning.
`Unknown` reverts to snapping: the absence of a measurement is not a measurement.

Two consequences worth carrying into WP8 rather than discovering there. The
preroll gate STAYS on desktop (WP4 retires Android's; the `smooth_buffer`
reinterpretation is WP7's), so preroll now stacks on top of the cushion and
criterion 3's e2e cost is cushion + up to half a refresh + preroll. And
one-present-per-slot is now `PresentGate`'s job: where present timing is live
that is strictly better, because the gate spaces presents by real glass
completions — but where it is unavailable the gate is inert and a faster-than-
panel stream can submit two presents into one vblank, throttled only by the
blocking acquire. That is already the `latency` intent's behaviour on those
boxes, but it is new for `smooth`, and `.221` Intel Arc / AMD are where to watch.

`wake_timeout` is rewritten as the exact mirror of the decision, including the
branch that matters: before the first glass stamp the clock is unanchored and
`next_slot_after` answers "one period from the query" rather than from a grid, so
the servable instant moves with `now`. Missing that would have parked a session's
opening frames for a refresh they never owed.

The cushion ceiling is one SOURCE frame, from the negotiated stream
`Mode.refresh_hz` — never the panel period. The measured fps was rejected
deliberately: it sags exactly when the transport is struggling, which is when a
ceiling derived from it would start licensing a bigger hold.

⚠ `note_off_cadence` is unused here. Nothing on this path can identify a repeat
or a host-anchored implausible stamp without S1's wire bit, so the plan's R2
stays open for the desktop leg — recorded rather than worked around.

Verified: clippy `--all-targets -D warnings` exit 0 with `Checking pf-presenter`
confirmed present (a warm shared target dir can otherwise print Finished having
compiled nothing), 53 tests pass, and the same clean under `--no-default-features`
because the intake edit sits beside the cfg-gated PyroWave collapse.
2026-08-15 14:24:27 +02:00
enricobuehler 2e753fd84d fix(gamepad): our virtual DualSense wakes Bazzite's ds_inhibit into an SELinux audit storm that freezes the stream
ci / rust-arm64 (pull_request) Successful in 1m23s
ci / bun-nix (pull_request) Successful in 1m26s
ci / web (pull_request) Successful in 4m22s
ci / docs-site (pull_request) Successful in 4m32s
ci / rust (pull_request) Failing after 4m57s
android / android (pull_request) Successful in 13m27s
Field-diagnosed on Bazzite 43 (2026-08-15): the virtual DualSense/DualShock 4
binds hid-playstation, and Valve's ds_inhibit (steamos-manager) reacts to every
open/close of any such hidraw by walking /proc/*/fd — it has no VID/PID or
virtual filtering. SELinux denies steamos_manager_t that walk (sys_ptrace,
dac_read_search, dac_override) at ~324 AVCs/sec, and setroubleshootd amplifies
the flood into a box-wide fork storm (267+ procs/sec, a core burned, RSS
climbing for 15+ min AFTER the denials stop) that starves the stream: gamescope
0 fps, encode submit ~150 ms/frame, tx 300 -> 1 Mbps, session death. punktfunk
is the trigger, not the defect — but we ship the trigger.

- packaging/bazzite/punktfunk-ds-inhibit.cil: a dontaudit drop-in (dontaudit,
  not allow — granting another vendor's daemon sys_ptrace/dac_* is not ours to
  do; the scan keeps failing quietly and ds_inhibit leaves the pad
  uninhibited, which is what we want anyway). The RPM ships the source under
  /usr/share/punktfunk/selinux/ (the policy STORE is host state, so a sysext
  image can only carry source); inserted idempotently by punktfunk-sysext
  post_merge / reapply and best-effort by the RPM %post, both keyed on the
  steamos-manager binary and on the module name — rename the .cil if its rules
  ever change, or existing installs never converge.
- native/gamepad.rs: warn_if_ds_inhibit_storm in the resolve_gamepad funnel —
  one-shot, warn-only (a per-pad degrade has no wire channel back to the
  client and would strip the DS5 feature set exactly where users want it).
  Fires on steamos-manager running + SELinux enforcing, and puts the cause in
  OUR logs: the AVC lines read comm="tokio-rt-worker" and look like us.
- packaging/bazzite/README.md: the failure chain, both diagnosis traps, and
  the setroubleshootd mask as general hardening (any AVC burst reproduces the
  amplifier; nothing depends on that daemon).

Not pursued: suppressing the touchpad mouse node to duck ds_inhibit's
selection — hid-playstation registers the touchpad from hardcoded driver code
(ps_touchpad_create in dualsense_create/dualshock4_create), not from our HID
descriptor, so no descriptor shaping can remove it.

Verified: gamepad tests incl. the new detection test pass on linux-gnu
(punktfunk-rust-ci container); clippy --all-targets -D warnings clean; the CIL
compiles under secilc against a stub base (planted-error control caught);
shellcheck clean on punktfunk-sysext.sh.
2026-08-15 14:23:07 +02:00
enricobuehler 08a06ff295 feat(apple): present on the host's cadence under smoothness, and prove the port agrees with the original
WP6 of design/presenter-cadence-rework-implementation-plan.md — the Apple
binding of the CadenceClock landed in e207d0d7.

Apple's video pipeline is Swift and does not link the Rust core, so this is a
hand-written twin the way AudioRing is of JitterPolicy. That kind of port is only
as good as its evidence of agreement, so beyond the ten mirrored unit tests the
two implementations were run against each other numerically: disposable
harnesses on both sides, importing the REAL Rust type, printing eight vectors —
LCG output, the 400-frame settle state, head and tail due times, the ramp's
type-2 versus type-1 error, a lumpy source's dues, cushion and jitter under wide
jitter, and an outlier + gap + regression + off-cadence event sequence. All eight
lines diffed identical, down to `offset=-1784999999987888109 skew=946
jitter=514645`. Both scaffolds are deleted; the doc comments name the Rust type
and those vectors as the contract.

The clock-domain trap is the whole risk here and it is worth stating plainly:
`presentAtMediaTime` consumes `CACurrentMediaTime`, while `decodedNs` is
CLOCK_REALTIME, and §2.2's rule is one domain in, same domain out. So
`mediaTimeNs(forRealtimeNs:)` is written as the exact inverse of the existing
media→realtime bridge, reading the two clocks in the same order so the sub-µs
skew between them cancels on a round trip. The conversion happens ONCE, on the
way in; the due time comes back in media time and is consumed by `nextVsync` and
the store predicate with no second conversion — which is precisely what
`domainOffsetIsAbsorbed` licenses. It is per frame rather than per session
because the two clocks diverge across device sleep, the one case where the offset
is not constant.

The deadline presenter (iPhone/iPad) is included even though WP6 names only
`presentAt`, which that loop has no equivalent of: its link vend IS the grid
snap, so the due-gated `take` alone makes it cadence-driven. Leaving it out would
have made WP8's "Mac + iPhone" leg test nothing on half its hardware.

Preroll retires only on the due-gated path; plain `take()` and its tests are
untouched. On the cadence path `underflows` is deliberately not counted — an
empty store is the normal steady state once frames are held until due, and
`CadenceHealth.late` is the honest starvation signal instead.

Smoothness only: the clock exists iff the store policy is FIFO, so under
`latency` `takeReady` is the old `ring.take()`, `dueMediaTime` is nil and
`max(now, now)` is the old expression, and the stats line has no cadence segment.

Verified: `swift test` 319 passing, 0 failures, including the ten new ones.
⚠ iOS/tvOS could not be typechecked — the checked-in xcframework's Info.plist
declares only `macos-arm64`, which is pre-existing and unrelated. Confirmed
instead that no added line sits inside any `#if os(...)`, so the macOS build
typechecks the entire diff.
2026-08-15 14:19:15 +02:00
enricobuehler a7ef62241b feat(android): present on the host's cadence under smoothness, not on the frame's arrival
WP4 of design/presenter-cadence-rework-implementation-plan.md — the Android
binding of the CadenceClock landed in e207d0d7.

The presenter aimed every frame at `next_target(now)`, so a frame's place on the
glass was decided by when it happened to finish decoding. On a host whose
compositor delivers raggedly that is the defect verbatim: the source's own
spacing is discarded and replaced with the transport's.

Three things had to be true for the clock to work here. Its `ready_ns` must be in
the domain `releaseOutputBufferAtTime` consumes, which is CLOCK_MONOTONIC — so a
monotonic twin is now stamped beside the existing realtime `decoded_ns`, at the
same instant on the codec's looper thread, and `decoded_ns` keeps its realtime
meaning for the latency stats. The cushion's ceiling is one SOURCE frame, so it
comes from the negotiated `mode.refresh_hz`, not the panel's. And the loop must
re-anchor on the discontinuities the client already knows about — the re-anchor
gate's arm count is that seam, and it subsumes every abandoned-AU and codec-error
site the plan listed. (The codec is never rebuilt in place here: a rebuild tears
the loop down and builds a fresh Presenter, so a reset there would be dead code.)

**The preroll gate is retired, and keeping it was never the conservative option.**
`prerolled` completes only when the store reaches capacity, but under cadence
targeting a frame sits in the store only between its decode and its due time, so
with a cushion under one frame interval the depth is about one. Preroll would
re-arm on nearly every pass and a buffer=2 session would present only during
bursts. `head_is_releasable` replaces both it and the per-vsync drain: a frame
leaves once the grid point it aims at is the next one this pump could still
submit for.

`next_target` now takes a composed `not_before_ns` instead of a separate
`margin_ns`. Passing `max(now, due)` while the margin stayed inside made the
function require `G > due + margin`, which pushes a frame whose due time sits
just under a grid point onto the next one for some phases and not others — that
is judder, not latency, and it only appears once the adaptive margin widens off
zero. For the latency path the new expression is arithmetically identical to what
the old body computed.

Smoothness only: under `latency` the clock is `None`, so it is not consulted
rather than consulted and ignored, and a test drives 600 frames through the due
path asserting every answer is `None` and the target floor is still exactly
`now + margin`.

⚠ `qDry` changes meaning and says so on the field: it now counts vsync ticks that
found the store empty, with no preroll precondition. Under cadence targeting an
empty store is the ordinary steady state — it reads as supply depth, not as an
alarm.

Verified: aarch64 AND armv7 clippy `--all-targets -D warnings` both exit 0 (the
armv7 leg because `vsync.rs`'s 32-bit `timespec` casts are target-dependent and
must not be "cleaned up"). No device was attached, so the unit tests were
type-checked but not run; their exact synthetic trace was instead replayed
through the REAL imported clock, giving frames=600 reanchors=1 late=17 and a
worst steady-state due-spacing error of 116 µs against a source grid whose raw
arrivals are off by 4 ms.
2026-08-15 14:18:14 +02:00
enricobuehler ff513a18e1 fix(apple): the fourth client had no way to reach the one decoder that could conceal a drought
Completes WP-C1 of design/host-source-stutter-fixes.md. The three Rust clients
conceal a packet drought on their decode thread; Apple could not, and the reason
is structural rather than an oversight: its playout ring is Swift and its Opus
decoder lives behind the C ABI, so the drain thread had nothing to call. Standing
up a second decoder Swift-side would not have worked either — PLC extrapolates
from the LAST DECODED FRAME, so a fresh decoder conceals from empty state.

So the ABI grows one function. `punktfunk_connection_audio_plc` synthesizes a
single frame from the connection's own live decoder, returning no-frame when
nothing has decoded yet (there is no state to extrapolate from) and when libopus
declines to interpolate — a timeout's answer, not an error. Frames it returns
carry seq and pts of zero, because concealed audio was never on the wire and must
not reach an A/V-sync observation; the drain loop skips `av.observe` for them.

The double-conceal defence had to move. On the Rust clients the decode loop
subtracts frames it already concealed from what AudioGapTracker then asks for;
Swift cannot, because the tracker is behind the ABI. So `AudioPcmState` counts
its own concealment and `decode_packet` does the subtraction — same invariant,
enforced one layer lower, with a Rust test standing in for the Swift half.

Swift gets the policy port beside `AudioRing`'s existing one, in the same shape
and for the same reason: one budget, `deprimeMS * 2`, denominated in time and not
in callbacks. `plc_ms=` joins the 10 s line as it did on the other three.

ABI_VERSION 22 → 23. ⚠ That number is the one thing here worth a second look: 21
was claimed twice by concurrent worktrees once already, and 23 is correct only
against what is visible from this branch.
2026-08-15 14:08:31 +02:00
enricobuehler e207d0d73e feat(client/present): a clock that plays frames out on the source's cadence, not on their arrival
WP3 of design/presenter-cadence-rework-implementation-plan.md — the shared core,
no client touched yet.

Every client presents a frame the moment it is decoded, so the transport's jitter
lands on the glass 1:1. On a host whose compositor delivers raggedly that is the
whole defect: the 2026-08-15 Skynet log has KWin's screencast arriving 0.11-8.22 ms
off its own grid — up to a full 120 Hz period — for 24 minutes straight, on a
session with the bitrate pinned and zero packet loss.

CadenceClock estimates the offset between the source clock and the present clock
and hands back a due time on the source's own timeline plus a cushion sized to
the measured jitter. It is type-2 (offset AND per-frame rate) because two
free-running crystals produce a ramp and a proportional-only loop lags a ramp
forever; `tracks_a_clock_ramp` asserts that against its own type-1 twin rather
than against a threshold I picked. Fixed-point i64 throughout, so it runs
identically on every client and in the offline harness.

Three properties matter more than the loop itself:

It smooths the OFFSET, never the timestamps. Due is `src_pts + offset + cushion`,
so genuine variation in the source's own cadence — a variable-rate renderer, an
irregular capture tick — passes straight through, and only the transport's
contribution to `ready − pts` is filtered. Anything that made due times more
evenly spaced than the source would be a bug; `preserves_source_cadence` is the
test that says so, and the sim's second case refuses to reward flattening.

It is domain-agnostic by construction. A constant offset between clock domains is
absorbed by the offset estimator, so each client feeds `ready_ns` and reads
`due_ns` in ONE domain with no conversion anywhere in the path —
`domain_offset_is_absorbed` shifts a whole trace by 987 seconds and asserts every
due time moves by exactly that and the spacings not at all. Suspend/resume breaks
the constant, which is what `reset()` is for.

A late frame's due time is returned in the PAST, unclamped. Clamping it to
`ready_ns` would quietly turn every late frame into a fresh anchor — which is
precisely arrival-driven presentation, the thing this exists to stop being.

The offline sim (§2.4) imports the real type rather than paraphrasing it, which
is R7 and the recorded phase-lock v3 lesson: a Python cross-check there inherited
a mis-derived constant and duly "confirmed" a non-bug. Replaying ±6 ms of arrival
jitter — the field shape — through both rules onto a synthetic 120 Hz panel:
**294‰ judder arrival-driven, 24‰ cadence-driven.**

⚠ The tuning constants are PROVISIONAL and labelled as such. The plan asks for
them to be fitted to recorded traces (its spike S2); S2 was never run, and the
2026-08-05 baseline records that omission itself. These are derived from first
principles and the first real trace should replace them.

`note_off_cadence` is in the API from day one (risk R2) for the frames whose
stamps do not lie on the source timeline — a repeat the host anchored at submit,
or one its plausibility gate replaced with "now". Folding those in would drag the
estimate toward "now" exactly when the stream is idle and the estimate matters
most.
2026-08-15 13:58:19 +02:00
enricobuehler 7d9d7c3814 fix(client): a packet drought drained the ring into a de-prime, because nothing arrived to reveal it
WP-C1 of design/host-source-stutter-fixes.md, for the three Rust playback sites
(Linux/PipeWire, Windows/WASAPI, Android/AAudio).

The decode path already concealed a SEQ GAP: AudioGapTracker reports the packets
missing before the one that arrived and libopus synthesizes each from the
decoder's own state. But that only fires when a LATER packet arrives to reveal
the gap. When the wire simply goes quiet — a delivery stall on a bunching Wi-Fi
link, or a host whose capture stalled — nothing arrives to reveal anything: the
ring drains to empty, the callback runs short, and the de-jitter policy de-primes
and then re-primes a whole target's worth of fresh silence. The artifact is far
longer than the audio actually missing.

**The plan's premise for this WP does not hold, and the design changed because of
it.** It specified concealment "on a pull that finds the ring short", i.e. in the
audio callback. The decoder is not reachable from there at ANY of the four sites:
decode runs on its own thread and reaches the callback only through a PCM channel
plus AudioSyncCell. Nor is a second decoder instance an answer — PLC extrapolates
from the last decoded frame, so a fresh one would conceal from empty state. So
concealment lives on the decode thread, which owns exactly the state it needs,
and the shape ends up mirroring the host's silence infill: wait one frame rather
than blocking, and on a timeout with a draining ring synthesize one frame.

DroughtConceal is the shared policy, bounded by JitterTuning::plc_max_ms() —
twice the preset's own de-prime fuse, derived rather than a fifth field so it
cannot drift from the thing it protects, and per-platform for free. Denominated
in TIME, never in frames or callbacks: that is the recorded lesson from this very
fuse, where a count gave an iPad a third of a Mac's slack. Concealment is gated
on the ring actually running out — a drought a deep ring covers is inaudible, and
synthesizing over it would insert audio the late packets are about to duplicate,
pushing the stream permanently later for the drift shed to cut back out audibly.

Two subtleties worth the reader's time. Frames concealed for a drought are
subtracted from the loss concealment the seq path then asks for, or a packet
genuinely lost INSIDE a covered drought is concealed twice. And the wait only
shortens to one frame once something has decoded: before that there is no state
to extrapolate from and no continuity to hold, so a session whose host never
sends audio keeps the old long timeout instead of waking 200 times a second.

`plc_ms=` joins the 10 s playback line at all three sites, riding AudioSyncCell
from the decode thread to the two callbacks that emit it. Concealment that
nobody can see is concealment that hides the bug it is covering: a healthy
`underruns` bought with a climbing `plc_ms` is a link in trouble, not a link
that is fine.

Apple's leg is NOT included. Its ring is the Swift AudioRing and its decoder sits
behind the C ABI, so the same fix needs a new punktfunk_connection entry point
plus AudioRing/Stats changes — a separate piece of work, declared here rather
than left to be discovered.
2026-08-15 13:44:36 +02:00
enricobuehler 424621f1ce fix(audio): what the Linux gate caught in the three preceding commits
Corrections to A1/A2/A3/B1 after running clippy `-D warnings` + the full test
suites under the amd64 CI image. Kept separate from the client work rather than
folded in, because two of them are worth reading.

**The infill path was dead code.** `sent_any` gates synthesis on something having
been sent — there is no continuity to protect and no wire clock to continue from
before the first frame — and nothing ever set it. So WP-B1 would have compiled,
shipped, and concealed exactly nothing. Found by re-reading the loop; clippy's
`unused_mut` flagged the same thing from the other direction, which is the only
reason it is a footnote rather than a field report. The loop has no thread-level
test by design ("the thread stays plumbing"), so this is the class of bug that
costs a release.

**`max_gap_ms` meant two different things on the two platforms.** Linux was
reporting the callback-to-callback DELTA; Windows sizes its holes from the WASAPI
device position, so it reports MISSING AUDIO by construction. One number in one
field name has to mean one thing, so Linux now subtracts the quantum it was
legitimately handed. (The plan's own two statements about this were inconsistent
— a >10 ms gap floor cannot score the "8 ms hole" its example asks for. Missing
audio is the reading that makes both true.)

The rest: a `&mut Vec` that wanted a slice, two `as i64` casts on a `tv_sec` that
already is one, `extend(drain(..))` where `append` says it better — and two of my
own tests asserting the wrong thing (the 8 ms hole above, and a replay that
expected re-engagement without sitting out the backoff the replay itself had just
armed, which is precisely the delay the hysteresis is supposed to impose).
2026-08-15 13:44:34 +02:00
enricobuehler 925b07ec10 fix(capture): the wire pts was stamped downstream of the compositor's own delivery jitter
WP-A3 and B3 of design/host-source-stutter-fixes.md. All three Linux publish
sites stamped pts_ns with SystemTime::now() inside OUR PipeWire process callback
— the instant the buffer was DELIVERED to us, not the instant the compositor
produced it. On a host whose screencast delivery is jittery that difference IS
the jitter, and it was baked into the timestamps the client plays back from.

This is what makes the client-side cure a non-cure on its own: the CadenceClock
design's §2.2 invariant is that the loop smooths the offset and never the
timestamps, so source-timestamp playout would faithfully REPRODUCE this jitter
rather than absorb it. Fixing the host stamp is that work's prerequisite, not an
alternative to it.

The compositor's spa_meta_header is already located per buffer for the CORRUPTED
skip, and only .flags was ever read. Its .pts is the producer's own instant,
upstream of the delivery. Whether that stamp is actually cleaner is a question
about a specific compositor, not something to assume, so both halves ship: a
30 s provenance line reports each clock's interval MAD about its own median (a
shared centre would fold the period-estimation error and any genuine rate
difference into a number meant to be about jitter), the empirical period, the
sample count behind it, and the header-minus-delivery p50 as a domain check. If
the compositor's number is materially tighter its stamp is worth shipping; if
both are equally ragged the producer composes irregularly and no choice of stamp
can help — which is a real possible outcome the line will state rather than hide.

The stamp itself is now taken once, at the top of consume_frame, and used by all
three publish paths: they each took their own reading at whatever point they
reached the publish, so a CPU de-pad's milliseconds landed inside the timestamp
and the paths could drift apart silently. The compositor's monotonic stamp is
rebased into the wire's realtime domain from a clock pair re-sampled each window,
and a per-frame plausibility gate falls back to the delivery stamp — counted, not
silent — for anything more than 50 ms out, so a producer that never fills the
header in, or fills it with the wrong clock, is exactly as it was.
PUNKTFUNK_CAPTURE_HDR_PTS=0 puts the whole stream back on delivery stamps.

Note for the release: stamping at arrival instead of at publish makes host_us
and e2e read HIGHER by the delivery delay we previously failed to count. The
numbers move because they get truer.
2026-08-15 13:25:15 +02:00
enricobuehler 5e77ec42c6 fix(audio): a capture hole cost more than itself, and delivered_pct could not say how many there were
WP-A2, B1 and B2 of design/host-source-stutter-fixes.md, from the same 24-minute
Skynet log. Capture delivered_pct sat at 84-97 — never 100 — through continuous
loud gameplay with dropped_chunks=0: 3-16 % of wall-clock where our sink node's
process callback simply never ran.

Three things were wrong with that, and they compound.

The measurement could not distinguish one 2 s hole from three hundred 8 ms ones,
which are a device fault and a scheduling fault and want different answers. So
CaptureStats gains gaps/max_gap_ms/missed_dequeues: the Linux callback scores
its own arrival cadence against the NEGOTIATED quantum (a graph clamped to 1024
frames is slow, not gapping) and drops its stamp across a state transition, so a
deliberate Paused span is not one enormous hole. The four silent early-returns
in that callback — no buffer, no datas, no mapped memory — are counted instead
of vanishing. Windows reaches the same numbers by a different road: it is a
polling loop over a tap that stops delivering entirely while the endpoint idles,
so a cadence metric would score every quiet moment; it reads WASAPI's own
DATA_DISCONTINUITY flag and sizes the hole from the device position, ignoring
the flag on the first packet after a packet-less second (that is the tap waking
up, not a hole).

A hole then cost far more than the audio it swallowed, because audio_thread
blocked in next_chunk for its whole duration and NOTHING left the host: the
client's de-jitter ring drained, underran, de-primed and had to re-prime, so a
30 ms hole became a much longer artifact. The loop is now deadline-driven — the
capturers gained a bounded next_chunk_within — and covers a hole with silence
frames on the existing pacer schedule, continuous in seq and pts, for up to
500 ms. Past that the host is not glitching, it is quiet, and the wire stops
exactly as it did before. A partial frame straddling a hole is padded out and
sent first rather than completed by post-gap samples, which would splice audio
from both sides into one frame; the post-gap re-anchor is clamped so wire pts
can never step backwards over the frames infill already sent; and a hole the
wire could NOT cover clears the redundancy predecessor, because nothing before
it may be spliced onto what follows. The capture counters deliberately measure
upstream of all this, so infill can never make that line look healthy.

Some of those holes we were inflicting on ourselves: the log shows three
"audio format negotiated" lines in minute 1, each wrapped in a Paused-Streaming
flap. Wine churns its audio device at launch, the sink briefly goes unused,
WirePlumber suspends it on its idle timeout and the next app resumes it. The
minted sink now sets session.suspend-timeout-seconds=0 — deliberately not
node.always-process, which would keep the node scheduled with nothing connected
and run this callback 200 times a second on a host sitting between sessions —
and a renegotiation resolving to the format we already had says so at DEBUG
instead of reading like a third format change.
2026-08-15 13:24:56 +02:00
enricobuehler 704defe0a7 fix(capture): the phase lock re-engaged on one good report, so a marginal host flapped 41 times in 24 minutes
WP-A1 of design/host-source-stutter-fixes.md. The 2026-08-15 Skynet log is a
24-minute session with the bitrate pinned, loss_ppm=0 throughout and zero client
recoveries — a fully exonerated transport — and 41 phase-lock engage/disengage
cycles, each disengage logging an arrival phase 0.11-8.22 ms off (up to a full
120 Hz period).

The controller was manufacturing a good part of that itself. Engagement needed
ONE coherent report, and the incoherent disengage asked for NO re-engage backoff
(the travel-budget path asks for 10 ticks), so a host whose coherence oscillates
around COHERENCE_FLOOR_MILLI re-engaged within a second of every disengage. Each
cycle is a timing step in both directions: engaging starts holding submits on a
grid by up to a period, disengaging drops the offset to zero and the next frames
leave that much earlier. Since every client presents on arrival, all 82 of those
steps reached the glass.

So engagement now needs five consecutive coherent reports, each incoherent cycle
waits longer than the last (10 ticks doubling to 320), and a host that has torn
down an engaged grid eight times parks the lock for the session — permanently
disengaged is today's default and strictly better than another cycle of steps.
A lock that holds for a minute forgives the escalation, and only a disengage
that tore down an ENGAGED grid counts toward the fuse: otherwise a launch-time
shader storm, which is minutes of genuinely incoherent arrival before the
controller ever locks, would fuse a host that then locks perfectly for hours.
The disengage line gains coherence_milli, which is the number that says whether
a host is marginal or hopeless and was the one thing the log could not show.
2026-08-15 13:24:35 +02:00
enricobuehler 42a257e013 Merge pull request 'Send client logs to the host — the log-escape hatch for locked-down platforms' (#247) from worktree-client-log-upload into main
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m3s
ci / rust (push) Failing after 7m7s
ci / docs-site (push) Successful in 1m34s
android / android (push) Successful in 10m4s
arch / build-publish (push) Successful in 10m46s
deb / build-publish-gamescope (push) Successful in 31s
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 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
ci / bun-nix (push) Successful in 3m32s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 9s
ci / web (push) Successful in 5m4s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
ci / rust-arm64 (push) Successful in 12m11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 18s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 6m57s
deb / build-publish (push) Successful in 4m21s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m13s
docker / builders-arm64cross (push) Successful in 7s
deb / build-publish-host (push) Successful in 4m18s
deb / build-publish-client-arm64 (push) Successful in 3m41s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 3m49s
deb / smoke-install (push) Successful in 2m22s
docker / deploy-docs (push) Successful in 2m25s
flatpak / build-publish (push) Successful in 15m39s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m3s
windows-host / package (push) Successful in 17m37s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 29s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 22m3s
Reviewed-on: #247
2026-08-15 10:57:17 +00:00
enricobuehler 04b6f84fc3 Merge pull request 'An in-flight TV restore raced a reconnect's takeover and resurrected sddm under the mask' (#246) from worktree-dm-restore-reconnect-race into main
android / android (push) Canceled after 11s
arch / build-publish (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-gamescope (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
deb / smoke-install (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 1s
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/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
Reviewed-on: #246
2026-08-15 10:57:04 +00:00
enricobuehler 054bf26393 fix(vdisplay): an in-flight TV restore raced a reconnect's takeover and resurrected sddm under the mask
ci / docs-site (pull_request) Successful in 1m18s
ci / bun-nix (pull_request) Successful in 1m31s
ci / web (pull_request) Successful in 5m11s
android / android (pull_request) Successful in 5m18s
ci / rust-arm64 (pull_request) Successful in 7m34s
ci / rust (pull_request) Failing after 18m40s
Under keep_alive=off the restore debounce is 0 s, so the worker pops the
deadline before a reconnect's cancel arrives — clearing PENDING_RESTORE
then cancels nothing, and the restore runs concurrently with the new
takeover. Field trace (.41, 2026-08-15): the new takeover listed the
autologin unit inactive (the restore hadn't restarted it yet), so
dm_plan correctly refused to stop the DM for a dead unit and went
mask-only — and the in-flight restore then restarted sddm underneath
that mask at 10:36:39.638. SDDM's helper execs the session script
directly, so the mask stops only the final unit start, never the retry:
1272 relogins in ~4 minutes, a fresh Steam launch each round, load 7+,
until a manual 'systemctl stop sddm'.

RESTORE_FLIGHT now serializes the two: the worker pops the deadline and
runs do_restore_tv_session under it, and cancel_pending_restore (which
create_managed_session now routes through instead of clearing
PENDING_RESTORE bare) blocks until an in-flight restore completes. Either
the cancel wins — no restore starts, warm reuse as before — or the
restore wins and the connect takes over a fully restored box, where the
autologin unit is live again and the DM stop engages through the
ordinary plan. restore_takeover_now (host shutdown) takes the same lock
so the two restore entry points can't interleave either.

Gate: xcheck linux check+clippy clean; pf-vdisplay 237/237 in the
rust:1.96 container, including the new regression test.
2026-08-15 12:43:26 +02:00
enricobuehler 5ae08ffaa5 feat: send client logs to the host — the log-escape hatch for locked-down platforms
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m15s
android / android (pull_request) Successful in 5m38s
ci / rust (pull_request) Failing after 5m11s
ci / bun-nix (pull_request) Successful in 43s
ci / docs-site (pull_request) Successful in 2m8s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m39s
ci / rust-arm64 (pull_request) Successful in 12m57s
ci / web (pull_request) Successful in 10m37s
On a Steam Deck in Gaming Mode, a tvOS box or a webOS TV the user has no
realistic way to get the client's log off the device, so field reports
arrive host-log-only and the client half of every stutter story is
invisible (today's Skynet analysis was the third such report in two
days). This inverts the collection: an explicit action on the client
posts its recent log to the PAIRED host, and the host's web console
lists the bundle next to the host's own log export — both halves of a
report in one place, from the one surface reporters already use.

Host: crate::client_logs is a bounded file store under
<config-dir>/client-logs (traversal-proof ids per the stats_recorder
pattern, newest 5 bundles per device, 1 MiB cap), NOT the log ring — a
multi-thousand-line bundle would evict the host's own log there. POST
/api/v1/client-logs is the cert lane's first and only WRITE: paired
mTLS devices may upload (write-only — no read of anything, not even
their own bundle), while list/fetch/delete stay on the loopback bearer
lane; the lane matrix rows pin all four. Per-client access (#245)
integration: the upload gate uses effective() — an expired guest's
upload is refused (403) while any live-authorized device, including
view-only guests, may send; no input-grant bit is required since
uploading one's own diagnostics is not an input capability.

Client: pf_client_core::logring keeps the newest 4096 lines / 768 KiB
(dependency-free ring; each shell installs a thin tracing layer — the
session binary's ring_layer captures DEBUG+ regardless of RUST_LOG,
wall-clock stamped so bundles correlate with the host log). send_to_host
reuses the library fetch's mTLS agent + host pin and the same error
classification. The gamepad console gains "Send logs to host" in the
per-host menu (paired + online rows only), a ConsoleCmd worker thread,
and a shared-model notice channel so the result lands as a toast.

Web console: a Client logs card on the Logs page (list/download/delete,
hidden while empty), en+de messages, api/openapi.json regenerated.

Apple (tvOS/iOS/macOS), Android and webOS legs are follow-ups: each
already holds a paired identity and the mgmt port from the Welcome, so
they only need a ring + one authenticated POST.

Gates: mgmt+client_logs 57/57 and full host suite green on Ubuntu 26.04
(the one failure, hooks::prep_runs_do_in_order_and_undo_in_reverse,
fails identically on pristine origin/main — pre-existing); clippy clean
across punktfunk-host, pf-client-core, pf-console-ui; session binary
checks; web build + tsc + i18n clean; pf-client-core logring test run
in the amd64 container.
2026-08-15 12:32:20 +02:00
enricobuehler a17571c6bf Merge pull request 'Per-client access control: grants + temporary guest access, host-enforced end to end (WP1–WP13)' (#245) from worktree-per-client-access-a0 into main
apple / swift (push) Successful in 2m2s
ci / rust-arm64 (push) Successful in 3m57s
ci / web (push) Successful in 1m16s
ci / docs-site (push) Successful in 1m22s
ci / bun-nix (push) Successful in 18s
android / android (push) Successful in 8m2s
arch / build-publish (push) Successful in 10m39s
deb / build-publish-gamescope (push) Successful in 29s
deb / build-publish (push) Successful in 4m21s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 28s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 18s
deb / build-publish-client-arm64 (push) Successful in 1m16s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
deb / build-publish-host (push) Successful in 4m58s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 11s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
apple / distribute (push) Successful in 10m56s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 46s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m8s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m26s
docker / builders-arm64cross (push) Successful in 1m22s
ci / rust (push) Failing after 20m5s
docker / deploy-docs (push) Successful in 1m53s
apple / screenshots (push) Successful in 9m44s
flatpak / build-publish (push) Successful in 11m4s
deb / smoke-install (push) Successful in 8m3s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 11m47s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m13s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m52s
windows-host / package (push) Successful in 20m52s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 1m49s
Reviewed-on: #245
2026-08-15 09:51:19 +00:00
enricobuehler 97f81a6ea6 Merge origin/main — the security sweep meets the access branch: ABI re-bumped to 22 (ex10 took 21), the peer gate and the grant mask compose, and the approve dialog states which knock it is
ci / bun-nix (pull_request) Successful in 58s
ci / web (pull_request) Successful in 1m20s
ci / docs-site (pull_request) Successful in 1m39s
apple / swift (pull_request) Successful in 1m58s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m7s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 14m14s
android / android (pull_request) Successful in 19m8s
ci / rust-arm64 (pull_request) Successful in 20m19s
ci / rust (pull_request) Failing after 20m24s
2026-08-15 11:50:01 +02:00
enricobuehler b8ec8ea260 Merge pull request 'security: fix 13 findings from the 2026-08-15 whole-project sweep' (#244) from worktree-security-review-2026-08-15-fixes into main
apple / swift (push) Successful in 1m58s
ci / web (push) Successful in 1m26s
ci / docs-site (push) Successful in 1m16s
ci / bun-nix (push) Successful in 34s
android / android (push) Successful in 7m53s
deb / build-publish-gamescope (push) Successful in 1m6s
deb / build-publish-client-arm64 (push) Successful in 1m1s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 10s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 6s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (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 8s
deb / build-publish (push) Successful in 5m22s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 47s
deb / build-publish-host (push) Successful in 5m31s
apple / distribute (push) Canceled after 10m30s
apple / screenshots (push) Canceled after 0s
ci / rust-arm64 (push) Successful in 11m10s
arch / build-publish (push) Canceled after 11m26s
ci / rust (push) Canceled after 11m29s
deb / smoke-install (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 11s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 1m7s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 1m3s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 1m4s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 18s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 1m28s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
2026-08-15 09:37:08 +00:00
enricobuehler 4aeec6051d harden: HTTP Content-Length overflow trap, and forward-slash UNC art paths
apple / swift (pull_request) Successful in 2m3s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / bun-nix (pull_request) Successful in 3m11s
ci / web (pull_request) Successful in 3m11s
ci / docs-site (pull_request) Successful in 3m25s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 4m34s
android / android (pull_request) Successful in 5m0s
ci / rust (pull_request) Failing after 9m57s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 8m53s
ci / rust-arm64 (pull_request) Successful in 14m37s
security-review 2026-08-15, two low/informational findings.

- clients/apple HTTPResponse: a malicious host sending Content-Length = Int.max
  made bodyStart + length overflow, and Swift integer overflow TRAPS (an
  uncatchable crash) rather than throwing. Use addingReportingOverflow and reject.
  Verified in the Apple build.
- host library/art.rs: art_path_is_confined's UNC guard was a leading double-
  backslash string test, so forward-slash (//server/share) and mixed UNC forms
  slipped past it, and canonicalize() itself would then coerce the SYSTEM host
  into outbound SMB auth. Reject ANY two leading path separators before touching
  the filesystem.
2026-08-15 11:15:54 +02:00
enricobuehler da13d14159 fix(ci): verify the pinned bun windows zip by sha256 before staging it
security-review 2026-08-15 finding 12 (windows). bun-windows-x64.zip was
downloaded and Expand-Archived with no integrity check, then Authenticode-signed
into the installer and its hash published in the Ed25519 update manifest — our
signature vouching for bytes we never verified (GitHub release assets are mutable
at a fixed URL). Pin and verify the sha256. The Linux curl|bash sites
(arch/rpm/deb + builder Dockerfiles) still need version+hash pinning — tracked.
2026-08-15 11:15:29 +02:00
enricobuehler bb781ca694 fix(gamestream): refuse an ambiguous pairing-PIN submit
security-review 2026-08-15 finding 7. The PIN is a single global slot with no
binding to a specific handshake, so with N parked getservercert waiters whichever
polls first takes it — an attacker who floods the parking slots while the
operator pairs could take the operator's PIN and pin its own certificate. Real
pairing is one client at a time, so PinGate::submit now refuses (returns false)
when more than one handshake is parked, and POST /pair/pin answers 409. This
narrows the window to a tight post-submit timing race; the full fix keys the gate
by uniqueid (mgmt API + console change, tracked separately). Compiles on .133.
2026-08-15 11:15:27 +02:00
enricobuehler 662df795b2 fix(host/windows): distrust a non-admin-owned host.env / web-password
security-review 2026-08-15 findings 3c and 4. %ProgramData% lets BUILTIN\Users
pre-create the punktfunk dir and plant host.env / web-password before a
privileged install runs; the bytes were then adopted verbatim (SYSTEM service
environment + command line; the console password), with the plant's owner erased
by the dir re-own that runs first.

Add install::is_admin_owned() (reads the file owner SID via GetNamedSecurityInfoW,
reusing privileged_sids()) and consult it BEFORE create_private_dir re-owns the
file:
- ensure_default_host_env: a non-admin-owned host.env is renamed aside and the
  default written over it (the !planted skip forces the overwrite even if the
  rename fails).
- set_web_password: a non-admin-owned password file is rotated to a fresh random
  instead of kept as an 'upgrade'.

A file from a prior privileged install is Administrators-owned and is kept.
Compiles clean on the windows-amd64 box (.133). The installer-side .iss freshness
signal (which also gates the password page) is a separate follow-up.
2026-08-15 11:15:13 +02:00
enricobuehler 9a05750376 fix(apple): require a pinned host identity before browsing its library
security-review 2026-08-15 finding 8. MgmtTransport's verify block replaces
system trust wholesale (the host cert is self-signed, no SAN) and, for a host
with no pinnedSHA256, accepted ANY certificate trust-on-first-use with no prompt
or log. A host can be saved yet pin-less (manual add, deep link, abandoned
pairing, or after Forget Identity), so a LAN MITM could serve a forged catalog
and harvest the device's mTLS pairing identity.

Gate the library entry points on host.pinnedSHA256 != nil — HomeView's browse
action, GamepadHomeView's hasLibrary tile flag, and a load() guard in LibraryView
(covering the deep-link path) — mirroring how the stream path already refuses an
unpinned connect. The transport's silent-accept is left for a follow-up (it is
also reached pre-pairing, so tightening it needs the QUIC path's approval flow).

NOTE: not compiled locally (no Xcode on the build host); verify on the Apple CI.
2026-08-15 10:52:54 +02:00
enricobuehler 6cd25b4829 fix(host/windows): allow-list host.env keys loaded into the SYSTEM service
security-review 2026-08-15 finding 3 (part 1). load_host_env imported EVERY key
of %ProgramData%\punktfunk\host.env into the LocalSystem service's own
environment. Since %ProgramData% lets BUILTIN\Users pre-create the dir, an
unprivileged user could plant host.env before install; a planted SystemRoot then
redirected the absolute icacls.exe / powershell.exe paths pf-paths and the
network-profile warner build from it — code execution as SYSTEM. Import only the
PUNKTFUNK_* / RUST_LOG keys the child already allow-lists at the spawn boundary,
closing the SystemRoot/PATH class of sinks.

Residual (planted PUNKTFUNK_HOST_CMD / PUNKTFUNK_CONFIG_DIR, which are legitimate
installer knobs) needs distrusting a non-admin-owned host.env — findings 3c/4,
which share an installer provisioning-signal decision and Windows build
verification; tracked, not yet fixed here.
2026-08-15 10:50:24 +02:00
enricobuehler 4369e3b2ec fix(ci): namespace the fork-PR cargo cache, log out the docs-deploy PAT
security-review 2026-08-15 findings 5 and 14.

- ci.yml: the cargo-home cache shared its unnamespaced key with the signed
  release builds (deb.yml / android.yml). registry/src holds already-extracted
  crate sources cargo compiles without re-checksumming, so a fork PR could poison
  a release artifact through the shared pool. Namespace ci.yml's key to
  cargo-home-ci- so its (untrusted) caches never reach the release pool. The
  fork-approval gate remains the definitive operator-side control.
- docker.yml: the deploy-docs SSH step left a write:package PAT base64-encoded in
  ~/.docker/config.json on the long-lived internet-facing docs VM. Add a
  trap ... EXIT docker logout so it is cleared on every exit path, matching the
  ephemeral LAN-registry jobs.
2026-08-15 10:48:26 +02:00
enricobuehler ff33eb872e fix(web): gate PUT /api/v1/library/provider/{p} behind the console password
security-review 2026-08-15 finding 6. confirmIfCommandExecution was wired into
only the two custom-entry routes; the provider reconcile route had no BFF handler
and fell through to the /api/** catch-all, which injects the full admin bearer —
so a bare session cookie could plant a persistent prep/launch.kind:command entry
without the password. Add the missing handler so it runs the same
command-execution gate before forwarding (an ordinary catalog reconcile is
untouched).
2026-08-15 10:48:17 +02:00
enricobuehler 6ada74066d fix(pyrowave): reject a sub-header packet so the decode cursor always advances
security-review 2026-08-15 finding 9. push_packet advanced by
payload_words*4; a 12-bit payload_words of 0 passed the length guard, and
decode_packet's duplicate-block early return fired before its own minimum-size
check — so a duplicate block_index with payload_words==0 spun the client decode
thread at 100% CPU forever (no allocation, no timeout, inside FFI). Hoist the
minimum-size check into push_packet before decode_packet is consulted. Carried
as vendored patch 0008.
2026-08-15 10:48:10 +02:00
enricobuehler 59d8b8a677 fix(core): meter per-block reassembly state against the in-flight budget
security-review 2026-08-15 finding 11. The reassembler's memory firewall counted
only FrameBuf::buf bytes; BlockState (have_data + recovery vectors, both sized
from attacker-declared header fields) was allocated unmetered. A slice-streamed
frame can mint thousands of distinct-index blocks while keeping the metered
buffer pinned near zero, committing multiple GB against a ~13 MB accounted
figure — a deterministic remote client OOM from a hostile/compromised host.

Add block_state_bytes()/frame_cost(); gate each new block on the same
IN_FLIGHT_BUF_FACTOR x max_frame_bytes budget as the frame buffer, and release
the full frame cost (buffer + block state) at every removal site.
2026-08-15 10:48:03 +02:00
enricobuehler 6b3e793b39 fix(gamestream): bind the UDP/ENet media plane to the launch owner
security-review 2026-08-15 findings 1, 2, 13. The Moonlight-compat plane bound
its UDP video/audio endpoints to the first datagram from anyone and let any ENet
peer keep a connection (pinning per-peer reassembly memory) — the peer_ip the
RTSP/launch planes already enforce was never threaded to the media/control
sockets.

- stream.rs/audio.rs: the video/audio endpoint learn now discards datagrams whose
  source IP is not the launch owner's until the 10s budget is spent, so an
  off-path LAN peer can no longer win the endpoint race and be handed the
  (plaintext) video stream.
- control.rs: an OwnerFilteredSocket drops non-owner datagrams before ENet
  allocates any per-peer state (closes the ~32 MiB x peer_limit pin and the
  source-spoof injection variant), and the Event::Receive arm now honors only the
  tracked session peer's input as defense-in-depth.

GameStream is runtime opt-in and off in the shipped unit, so this is deferrable
but the code's own comments claimed a peer bind already protected these paths.
2026-08-15 10:47:55 +02:00
enricobuehler ab55dd4e39 Merge pull request 'Every device pending approval was called "This device" — the C ABI connect had no name parameter' (#243) from worktree-pairing-friendly-device-name into main
apple / swift (push) Successful in 1m58s
android / android (push) Successful in 7m57s
ci / docs-site (push) Successful in 1m19s
ci / bun-nix (push) Successful in 20s
ci / rust-arm64 (push) Successful in 7m38s
ci / web (push) Successful in 7m49s
deb / build-publish-gamescope (push) Successful in 39s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 40s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m13s
deb / build-publish (push) Successful in 5m4s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
deb / build-publish-host (push) Successful in 5m20s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 18s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 18s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 1m17s
ci / rust (push) Successful in 12m39s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
deb / build-publish-client-arm64 (push) Successful in 2m42s
apple / distribute (push) Successful in 10m51s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 58s
docker / builders-arm64cross (push) Successful in 1m22s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m57s
docker / deploy-docs (push) Successful in 43s
deb / smoke-install (push) Successful in 4m26s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 7m16s
arch / build-publish (push) Successful in 19m39s
apple / screenshots (push) Successful in 9m41s
flatpak / build-publish (push) Successful in 10m42s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m30s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 23m18s
windows-host / package (push) Successful in 19m49s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 22s
2026-08-15 08:42:53 +00:00
enricobuehler ab8fa46b66 feat(web): access in one dialog — approve, arm, and the column that counts down (per-client access WP7+WP8)
android / android (pull_request) Canceled after 0s
apple / swift (pull_request) Canceled after 0s
apple / distribute (pull_request) Canceled after 0s
apple / screenshots (pull_request) Canceled after 0s
ci / rust (pull_request) Canceled after 0s
ci / rust-arm64 (pull_request) Canceled after 0s
ci / web (pull_request) Canceled after 0s
ci / docs-site (pull_request) Canceled after 0s
ci / bun-nix (pull_request) Canceled after 0s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Canceled after 0s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Canceled after 0s
One component family (sections/Pairing/access.tsx) serves all three grant
moments: the approve dialog (Full + Forever defaults per D1, one-click
'Approve as guest' = Controller only + 4 h per D2/D4, stored-access pre-fill
on the expired-guest re-knock), the arm card, and the paired-row edit sheet
(partial PATCH: extend / expire now / make permanent / remove).

The Access column derives its chip + countdown client-side from expires_unix
on ONE shared interval (no refetch storms), keeps expired rows listed as
'Expired' (D3), gives Moonlight rows an honest 'Full (ungoverned)' chip and
no editor, and renders '—' against hosts older than the fields — access_level
is the presence sentinel, and every new field is read defensively.

35 new strings in en + de; stories for both dialogs, the column matrix
(incl. old-host and Expired), and the edit sheet, on a fixed fixture clock.
2026-08-15 10:16:15 +02:00
enricobuehler c32fad8aee feat(gamestream): the Moonlight plane honors grants — launch, input, and the clock (per-client access WP13)
The grants registry serves both paired stores, keyed on fingerprint hex
(design §8): a Moonlight fingerprint with NO record is ungoverned — an
existing pairing keeps full control (back-compat) — while a record that
exists (created via the console) governs exactly as on the native plane,
via the new NativePairing::moonlight_effective (one store snapshot, so a
deletion can't race into reading expired).

nvhttp: /launch and /resume check LAUNCH + expiry beside peer_is_paired
(an expired record fails closed exactly like unpaired); /cancel gates on
expiry only — it is Moonlight's Quit App, owner-restricted already, and
denying a downgraded owner its own quit would only wedge the session.

Control thread: the session's owner_fp resolves to the same mask, folded
per 2 ms tick from the fingerprint's watch channel; every decoded event
passes one mask test against the exhaustive classifier before injection
(deny-at-setup for pads — no GAMEPAD, no uinput node), with per-class
counters, one warn per class, totals at session end. The deadline check
rides the same tick and ends the session through quit_session — the
host-side-ended arm's TERMINATION + disconnect is the whole message,
since GameStream has no AccessUpdate vocabulary (silent enforcement,
accepted by the design).
2026-08-15 10:02:42 +02:00
enricobuehler 6753641c5e feat(mgmt): access on the wire of record — grants in the payloads, and PATCH to change them
The management API is where grants become operable (WP6): the paired-client and
pending-device payloads carry grants/expiry/grant-time plus a derived
access_level preset name, the approve and arm requests take an optional access
choice (expiry RELATIVE in the API, stored absolute), and a new
PATCH /native/clients/{fingerprint} does partial access edits — omitted halves
keep their current value, clear_expiry makes access permanent. Reserved grant
bits are a 400, never silently cleared.
2026-08-15 10:00:21 +02:00
enricobuehler 19411d8d6d feat(host): sessions now carry their grants — admission, expiry, and the input plane enforce them (per-client access WP3–WP5)
Admission consults effective() (an expired record knocks into the pending
list; re-approval is the re-grant), the Welcome advertises the real mask +
remaining lifetime, and a per-session lifecycle task owns the deadline:
wall-clock re-evaluated every lap, AccessUpdate warnings at T−5m/T−1m,
console edits folded into the live Arc<AtomicU32> within one watch event,
and the typed 0x69 close on expiry / expire-now / unpair.

The datagram dispatch classifies every plane against that one atomic before
offer() (one relaxed load per event; per-class counters, one warn per class),
the input thread re-guards the pad-creating arms (deny-at-setup: no GAMEPAD,
no uinput/pad-audio), launch without LAUNCH is a typed 0x6A refusal before
the handshake, and clipboard ANDs the grant into the operator policy — new
CLIP_REASON_NOT_PERMITTED (5), coordinator never starts ungranted.

Events: access.granted / access.changed / access.expired from the facade
choke points and the deadline fire.
2026-08-15 09:42:26 +02:00
enricobuehler b2e6debb22 feat(client): the desktop session wears its access level — live grants on the connector, capture that asks first, the chip, and honest endings (per-client access WP9)
The connector now carries the session's LIVE access truth: the Welcome advert
seeds NativeClient::access_grants / access_deadline_unix (client-anchored, so
skew never moves the countdown), the control task folds every MSG_ACCESS_UPDATE
in latest-wins before waking next_access_update, and a typed mid-session close
latches as end_reject — an access expiry now ends as "your access to this host
has expired", not "the host ended the session with an error".

pf-client-core surfaces it as SessionEvent::Access { SessionAccess, notice }
(module `access`: derived preset labels, chip text, toast wording — the rules
the Apple/Android ports mirror), gates the mic uplink and clipboard bridge at
spawn on their grants (deny-at-setup, client half), and follows a live MIC edit
by stopping/starting the uplink without a reconnect.

The presenter gates capture on the mask (§7 "not capture what can't land"): no
pointer lock without POINTER, no keyboard grab without KEYBOARD, engage refuses
outright when neither is granted (the hint pill stays down), every wire send
funnels through the host's own classify(), and a live edit flushes what a
removed class still held. The overlay wears the chip — "Controller only · ends
in 1 h 58 m", top-right beside the mic badge, at every stats tier — and the
T−5 m / T−1 m warnings ride the pill slot as toasts. Full-control permanent
(every old host) renders exactly today's look.
2026-08-15 09:30:59 +02:00
enricobuehler 1bed82423d feat(android): the stream knows its access level — router gating, the chip, and expiry toasts
The Android leg of per-client-access.md §7 (WP11). The bridge grows one
poll shim, nativeAccessState -> [grants, remainingSecs, updateSeq]: the
connector already folds every AccessUpdate latest-wins into its live
grants/deadline slots, so Kotlin polls the fold ~1 Hz alongside its
session-ended watchdog instead of holding a blocking event thread; the
seq counter is only how a fresh update (the host's T-5m/T-1m warnings)
is told apart from state the poll would re-read anyway. The countdown is
clamped to >= 1 once a deadline exists — 0 stays the permanent sentinel.

Kotlin gates what can't land rather than capturing it: GamepadRouter's
wire sends fold the GAMEPAD grant into the existing forwarding gate
(slots and the exit/mic/stats chords stay alive — they are local
controls that happen to sit on pad buttons, and the phone-gyro mirror
stands down through the same sendsEnabled read); without POINTER the
touch/stylus gesture layer is never installed, the mouse forwarder goes
inert and never grabs the pointer, and the TV remote can't enter pointer
mode; without KEYBOARD the VK path consumes without sending and the IME
summon (gesture and remote toggle) declines; without MIC no capture
opens — the recording indicator must not announce a mic nobody can hear
— and a mid-session revocation stops a running one; without CLIPBOARD
the sync never starts.

StreamScreen carries the Access chip top-end in the shared pill family
("Controller only · 1 h 58 m left"), composed only when there is
something to say — a full-control permanent session, which is every
session against an old host, looks exactly like today. The expiry
warnings surface as toasts, and a session that dies inside the final
countdown is worded with the shared rejection sentence ("Your access to
this host has expired") — recognized off the countdown because the
generic end-reason byte predates the typed close. ConnectErrors learns
the two new reject tokens (access-expired, launch-not-permitted).

Verified: cargo clippy -D warnings + fmt (host target), gradle
:kit:/:app:compileDebugKotlin, :kit:+:app: unit tests (new
SessionAccessTest pins the bit mirror and the preset labels), and the
release cargo-ndk cross-build of all three ABIs. The gradle
cargoNdkClippy leg could not complete on this machine — the shared disk
filled mid-run (environment, not code; the Rust delta is
target-independent and is covered by the host clippy + the ABI builds).
2026-08-15 01:55:29 +02:00
enricobuehler ee6dff116c feat(apple): the session wears its access level — chip, countdown, and capture that asks first
ABI v21 (per-client access WP10): punktfunk_connection_grants and
punktfunk_connection_access_expires_in read the session's LIVE access
state (Welcome seed, latest-wins over every mid-session AccessUpdate),
and punktfunk_connection_end_reject surfaces the typed rejection a
mid-session close carried, so an access expiry renders its real
sentence instead of the generic host-error one. NEW symbols only; the
Rust-side live slots they read landed with the pf-client-core work.

Swift: PunktfunkConnection wraps the three (grant bits, the derived
AccessLevel labels, and class-gated send funnels — key/pointer/pad/pen/
mic events the grants exclude never leave the device); SessionModel
polls at the 1 Hz stats tick for the chip ("Controller only · ends in
1 h 58 m"), the T−5 m / T−1 m warning toasts, mic + clipboard hiding,
and the live release of an engaged capture on revoke; macOS gates
engage + the cursor grab and the iPad gates pointer lock on the bits;
tvOS states the level as a stats-overlay line instead of a chip. A
full-and-permanent session — every old host — renders exactly today's
UI.
2026-08-15 01:53:48 +02:00
enricobuehler 25487a8bd4 feat(host): grants on the trust record — and re-pairing can no longer escalate (per-client access WP2)
PairedClient grows grants/expires_unix/granted_unix (serde-defaulted; an
absent field means full/permanent, so pre-grants stores decode unchanged).
effective(fp, now) is the new authorization verb — None when unpaired OR
expired, reserved bits masked on read — while is_paired() stays the
expiry-blind listing verb (both documented in the facade header).

The security-critical change: add() is now name-only for an existing
fingerprint. It used to replace the record, so a guest limited to
Controller · tonight could re-run the pairing ceremony and silently walk
back to full control forever. The authorized grant paths take an explicit
Access (grants + absolute expiry): add_with_access, set_access, the
approve dialog via approve_pending(.., access), and the armed PIN window
via arm_for(.., access) — the ceremony reads armed_access() before the
single-use consume wipes it. A test now fails if add() ever escalates
again (plan §8 risk table).

NativePairing also gains the access watch registry: one watch channel per
fingerprint carrying (masked grants, raw deadline, revoked). Every
mutation — pair, edit, unpair — publishes through it, so a console edit
or unpair reaches every live session within one event (design §5.6);
sessions subscribe() at admission (WP3). Existing call sites pass None
everywhere: enforcement wiring is WP3, the mgmt/console fields are WP6/7.
2026-08-15 01:46:59 +02:00
enricobuehler 97fe3a0ff5 feat(core): the grant vocabulary — six bits, one exhaustive classifier, and the Welcome advert (per-client access WP1)
The wire layer of design/per-client-access.md: quic/access.rs carries the
GRANT_* bits (u32, reserved-must-be-zero), the three presets, and the
exhaustive InputKind -> GrantClass classifier whose wildcard-free match is
the default-deny mechanism — a new input kind now fails to compile until
someone decides its grant class.

AccessUpdate { grants, remaining_secs } rides the control stream as 0x58
(verified free; both peers' dispatch loops drop unknown ids with a warn,
so old clients just miss the courtesy). reject.rs grows the 0x69/0x6A
close codes -> RejectReason::{AccessExpired, LaunchNotPermitted}, mirrored
into PunktfunkStatus -30/-31 and the shared client-facing sentences.

The Welcome advertises grants + expires_in_secs as trailing fields one
link past mgmt_port, with the same placeholder discipline: emitting the
advert forces the cipher byte and the mgmt port so the two u32s land at a
deterministic offset, while a full-control permanent session stays
byte-identical to the pre-grants wire form. Absent fields decode to
GRANT_ALL / permanent — exactly what an old host enforces. No
WIRE_VERSION bump (trailing fields, per the v20 mgmt_port precedent), no
ABI bump (no new C symbols; the header gains prefixed defines and two
appended status values only). The host sends GRANT_ALL until WP2/WP3
wire the trust store in.
2026-08-15 01:23:36 +02:00
enricobuehler 00f9c1f4d3 Merge pull request '0.29.0' (#242) from worktree-release-0290 into main
audit / bun-audit (sdk) (push) Successful in 26s
audit / cargo-audit (push) Successful in 38s
audit / pnpm-audit (push) Successful in 12s
audit / bun-audit (plugin-kit) (push) Successful in 1m40s
audit / docs-site-audit (push) Successful in 1m39s
audit / bun-audit (web) (push) Successful in 1m39s
audit / license-gate (push) Successful in 4m34s
audit / miri (push) Successful in 5m4s
ci / web (push) Successful in 1m2s
ci / bun-nix (push) Successful in 16s
ci / docs-site (push) Successful in 1m13s
audit / c-abi-asan (push) Successful in 11m33s
ci / rust-arm64 (push) Successful in 12m19s
nix / flake (push) Successful in 15m45s
ci / rust (push) Successful in 31m23s
deb / build-publish-gamescope (push) Successful in 36s
decky / build-publish (push) Successful in 34s
deb / build-publish-client-arm64 (push) Successful in 1m19s
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 8s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
apple / swift (push) Successful in 2m12s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 55s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m20s
deb / build-publish-host (push) Successful in 4m13s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 5m26s
linux-client-screenshots / screenshots (push) Successful in 3m14s
android-screenshots / screenshots (push) Successful in 7m2s
sbom / sbom (push) Successful in 42s
deb / build-publish (push) Successful in 9m22s
docker / builders-arm64cross (push) Successful in 23s
docker / deploy-docs (push) Successful in 59s
arch / build-publish (push) Successful in 12m48s
web-screenshots / screenshots (push) Successful in 6m8s
android / android (push) Successful in 14m5s
flatpak / build-publish (push) Successful in 11m30s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 9m5s
apple / distribute (push) Successful in 13m54s
deb / smoke-install (push) Successful in 6m26s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m33s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m52s
apple / screenshots (push) Successful in 9m36s
windows-host / package (push) Successful in 19m10s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Successful in 20s
2026-08-14 23:18:48 +00:00
enricobuehler 18f595698c docs(site): access levels — the three presets, expiry, and what they honestly do not cover
The new owner-facing Access levels page documents the Full/Controller/View
presets, the six Advanced toggles, wall-clock expiry with T-5m/T-1m warnings
and one-click re-grant, and the three honest limits: shared-desktop
visibility is not isolated, Moonlight rows are ungoverned until the
GameStream phase, and older clients are enforced without the chrome. The
pairing page gains the one-dialog approval flow (access level + expiry,
'Approve as guest') and the Access column note.
2026-08-15 01:12:54 +02:00
enricobuehler 8ae524d801 fix(pairing): every device pending approval was called "This device"
ci / bun-nix (pull_request) Successful in 1m32s
ci / rust-arm64 (pull_request) Successful in 1m49s
apple / swift (pull_request) Successful in 2m8s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m29s
ci / web (pull_request) Successful in 4m1s
ci / docs-site (pull_request) Successful in 4m6s
ci / rust (pull_request) Successful in 7m8s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m44s
android / android (pull_request) Successful in 15m39s
The web console shows exactly what the host was told, and the host was told
"This device" by every Apple client — so the outstanding-pairings view and the
approve dialog listed identical rows for an iPad, an Apple TV and a Mac.

The name rides `Hello::name`, which embedders fill from `client::device_name()`.
That resolves `COMPUTERNAME` (Windows-only) then `HOSTNAME` (a shell variable
never exported into a launchd-started process), and its last resort was the
literal "This device". No Apple GUI app has either variable, and the C ABI had
no device-name parameter for one to pass a better answer through, so every
Apple device fell through to the placeholder. Linux (/etc/hostname) and Windows
were unaffected; Android sent `Build.MODEL`, which names the product rather than
the unit — two of the same tablet were still indistinguishable.

- core: `punktfunk_connect_ex10` = `ex9` + `device_name` (C ABI v21, no wire
  change — `ex9` keeps its signature and passes a null name for the old
  default). Truncated to `HELLO_NAME_MAX` on a character boundary, since
  slicing a multi-byte name mid-scalar panics.
- core: `device_name()` falls back to `gethostname()` before the placeholder,
  so an embedder that passes nothing still gets a real name.
- apple: `DeviceName.current` (`Host.localizedName` / `UIDevice.current.name`,
  falling back to the hostname when 16+ answers with the bare model) is sent on
  connect, and the two pairing sheets plus the ceremony now read that one source
  instead of three separate literals.
- android: `Settings.Global.DEVICE_NAME` — the name the user typed in Settings —
  ahead of `Build.MODEL`. The "approve this device" prompt quotes the same
  string the connect knocks with, so it can't send the user looking for a row
  the console does not show.
- web: the approve dialog names the device and its fingerprint. A pre-filled
  field is editable text, not a statement of which knock is being approved.
2026-08-15 01:05:15 +02:00
enricobuehler 78efedc0d8 release: 0.29.0 — version bump, notes, CHANGELOG, Play notes
apple / swift (pull_request) Successful in 2m3s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m52s
android / android (pull_request) Successful in 7m14s
ci / web (pull_request) Successful in 1m23s
ci / bun-nix (pull_request) Successful in 17s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m27s
ci / docs-site (pull_request) Successful in 1m27s
ci / rust-arm64 (pull_request) Successful in 15m57s
nix / flake (pull_request) Successful in 13m40s
ci / rust (pull_request) Successful in 31m1s
53 commits since v0.28.1 (36 non-merge). Cut from origin/main 8c6099da.

THE NUMBER: 0.29.0 is forced, not chosen. The C ABI moved 19 -> 20 (#230
added punktfunk_connection_mgmt_port for the in-band mgmt-port advert),
and the Windows MSIX package identity changed with the Azure signing
move (#228) — either alone rules out a patch. scripts/ci/pf-version.sh
derives the canary base as latest-stable + one minor, so canaries move
from 0.29.x to 0.30.x after the tag; no collision either way.

Version table measured, not copied forward: wire stays 2 (Welcome grew
a trailing u16 older peers never read, with an explicit cipher byte
whenever a port rides along so offset 68 keeps meaning cipher), driver
protocol 6/min 3 (pf-driver-proto has no diff against the v0.28.1 tag),
gamepad channel 3, plugin index schema 1, edition 2024, MSRV 1.85, 27
crate dirs, gamescope +pfhdr7 (patch series untouched), SDK 0.1.4,
plugin-kit 0.4.1. api/openapi.json stays stamped 0.28.0 — the mgmt API
surface did not change this cycle — and docs-site/public/openapi.json
is byte-identical to it, so no re-sync is owed for once.

Re-synced once as main moved (b5cace3a -> 8c6099da, PRs #237–#241):
the Hyprland six-fix arc and the Windows mgmt-port completion joined
the notes and CHANGELOG; contract surfaces (include/, pf-driver-proto,
sdk, plugin-kit, api/) show no diff from the extra commits, so every
version-table row survived the re-sync unchanged.

Gates run on this tree: cargo fmt --all --check clean; cargo metadata
--locked ok; Cargo.lock diff versions-only (36/36 lines); cargo test
-p punktfunk-core green including the c_abi harness (the header with
the v20 symbol compiles and round-trips); Play whatsnew 444/500 chars
(counted as characters, not bytes) and not byte-identical to any prior
release's; notes voice scan finds internal names only in the For
developers section.
2026-08-15 00:26:56 +02:00
enricobuehler 8c6099da2a Merge pull request 'The mgmt-port learn landed without its Windows half — restore the three missing pieces' (#241) from worktree-windows-client-mgmt-port-fix into main
ci / docs-site (push) Successful in 1m22s
ci / bun-nix (push) Successful in 24s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m4s
android / android (push) Successful in 8m32s
deb / build-publish-gamescope (push) Successful in 29s
deb / build-publish-client-arm64 (push) Successful in 3m24s
deb / build-publish (push) Successful in 8m24s
ci / rust-arm64 (push) Successful in 11m45s
deb / build-publish-host (push) Successful in 5m6s
ci / web (push) Successful in 11m58s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 6m40s
arch / build-publish (push) Successful in 17m15s
flatpak / build-publish (push) Successful in 8m43s
deb / smoke-install (push) Successful in 3m45s
ci / rust (push) Successful in 26m55s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m50s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 9s
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
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (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 / builders-arm64cross (push) Successful in 8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 49s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m18s
docker / deploy-docs (push) Successful in 1m9s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 29m36s
Reviewed-on: #241
2026-08-14 22:23:57 +00:00
enricobuehler 2b066b3e11 fix(clients): the mgmt-port learn landed without its Windows half — restore the three missing pieces
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m50s
ci / rust-arm64 (pull_request) Successful in 2m40s
ci / bun-nix (pull_request) Successful in 3m13s
ci / docs-site (pull_request) Successful in 4m3s
ci / web (pull_request) Successful in 4m5s
android / android (pull_request) Successful in 5m48s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m21s
ci / rust (pull_request) Successful in 11m34s
99eb679c wired the learned management port through every client, and its own commit message
records the hole: 'NOT verified: the Windows client (192.168.1.133 unreachable)'. The Windows
shell's usage sites landed, but the three definitions they lean on did not exist anywhere, so
main's windows-client build has been red since the merge of #230:

* clients/windows/src/trust.rs re-exports core's trust surface — learn_mgmt_port was added to
  core but never joined the re-export list (hosts.rs:729, E0425).
* The Windows shell's own mDNS browser (discovery.rs, 'ported verbatim from the GTK client')
  never learned the mgmt TXT that pf_client_core::discovery already parses — DiscoveredHost
  gains the field, parsed the same way (hosts.rs:725/727/1062, E0609).
* HostTarget ('the host a plan dials') never carried the port, so the Target the shell builds
  from a ConnectPlan had nothing to read (mod.rs:414, E0609). Wired From<&KnownHost> like mac;
  the two spawn-path literals stay None — a spawn plan never fetches the library, and each
  shell resolves the port itself at its fetch site.

cargo fmt --all --check clean. pf-client-core/linux/session compile via the rust CI job; the
windows-client job on this PR is the verification 99eb679c could not run.
2026-08-15 00:13:43 +02:00
enricobuehler c3c24b5855 Merge pull request 'Hyprland streaming worked for exactly one session, and the cursor was never on the streamed head — six bugs, all ours' (#240) from worktree-capture-bgra-dmabuf-pod into main
ci / rust-arm64 (push) Failing after 2m40s
android / android (push) Successful in 8m39s
ci / bun-nix (push) Successful in 18s
arch / build-publish (push) Successful in 9m22s
windows-host / package (push) Successful in 12m24s
windows-host / winget-source (push) Skipped
deb / build-publish (push) Successful in 3m58s
ci / web (push) Successful in 5m46s
deb / build-publish-gamescope (push) Successful in 29s
ci / rust (push) Canceled after 8m19s
ci / docs-site (push) Canceled after 5m6s
deb / build-publish-host (push) Canceled after 3m11s
deb / build-publish-client-arm64 (push) Canceled after 26s
deb / smoke-install (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 17s
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/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-host / canary-manifest (push) Successful in 2m8s
Reviewed-on: #240
2026-08-14 22:07:17 +00:00
enricobuehler 0d4f878f32 Merge pull request 'The store's library shelves carry real cover art on every platform' (#239) from worktree-shot-cover-art into main
android / android (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
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/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
apple / swift (push) Successful in 2m5s
apple / distribute (push) Successful in 11m8s
apple / screenshots (push) Successful in 9m30s
Reviewed-on: #239
2026-08-14 22:06:30 +00:00
enricobuehler d886cd0124 Merge pull request 'The Windows signing docs still told users to import a certificate we no longer publish' (#238) from worktree-docs-azure-signing-cert-import into main
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
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/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Failing after 1m48s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 1m56s
Reviewed-on: #238
2026-08-14 22:05:53 +00:00
enricobuehler 2832b5d0f6 fix(host): the park schedule read a missing cursor overlay as a lost pointer, but an Embedded portal never sends one
ci / rust-arm64 (pull_request) Successful in 3m19s
ci / web (pull_request) Successful in 2m19s
ci / bun-nix (pull_request) Successful in 1m8s
ci / rust (pull_request) Successful in 9m12s
ci / docs-site (pull_request) Successful in 8m38s
android / android (pull_request) Successful in 20m26s
Field report, working Hyprland stream: the pointer jumps to the screen
centre once a second for ~10 s at the start of every session, fighting
every mouse movement, then settles.

That is `park_pointer`'s schedule running its full cap. Parking exists
for a good reason — a pointer-locked client sends only RELATIVE deltas,
so nothing would ever move the seat pointer onto a freshly created
virtual output — and past its two unconditional attempts it keeps going
only while a host-composite session STILL has no live cursor overlay.
"No overlay ⇒ the pointer has not reached the streamed output" is sound
on Mutter, which suppresses `SPA_META_Cursor` while the pointer is off
the recorded view. It is meaningless on the whole wlr family: xdph and
xdpw advertise `AvailableCursorModes = 3` (Hidden|Embedded), so a session
that asks for metadata is served EMBEDDED — the compositor paints the
pointer into the frames and sends no cursor metadata, ever, wherever the
pointer is. The heuristic was reading noise and warping the user's
pointer over it.

Distinct from — and complementary to — 5a5397ca, which fixed WHERE the
warp landed (the wlr virtual pointer was bound to the operator's head,
so the park drove a screen nobody was streaming). That one makes the
park work; this one stops it repeating on evidence that does not exist.
Both are needed: with only 5a5397ca the pointer would be re-centred on
the *streamed* output once a second instead, which is the field report's
symptom exactly.

The same fact broke a second thing next to it. `metadata_composite` had
the host plan a metadata cursor composite on a backend that can never
deliver metadata: the stream logged "host-composite active but the
capture has no live cursor overlay" for its whole life and drew no host
pointer, which is why an earlier session on this box looked cursorless.
Under Embedded the compositor's burnt-in pointer IS the cursor, and the
host must not plan a composite at all.

So surface what the portal actually negotiated instead of inferring it:
`portal_cursor::negotiate` now returns our own `Mode` (re-exported as
`pf_vdisplay::PortalCursorMode`), the hyprland/wlroots portal threads
carry it back beside the fd and node id — alongside, not instead of, the
`closed_tx` teardown handshake and inside the same `HANDSHAKE_BUDGET`
bound — and the backends, plus the monitor mirror that delegates to
them, report it per session as `VirtualDisplay::last_portal_cursor_mode`.
`None` is the default and what every non-portal backend reports (KWin
`zkde_screencast`, Mutter `RecordVirtual`, gamescope, Windows all get
the mode they ask for), so nothing about the GNOME behaviour this was
built for changes.

The host settles both consequences from that one fact in
`settle_portal_cursor`, at bring-up and again after every capture-loss
rebuild (the retarget arm has to recompute `metadata_composite` from the
compositor alone, because it runs before the rebuild to set `hw_cursor`).
`plan.cursor_blend` is deliberately left alone: it is resolved before any
display exists, and pre-judging it would mean re-asserting what the wlr
portals advertise — the exact hardcode `portal_cursor` exists to have
deleted. It costs a colour conversion, not correctness.

Also cuts the park RETRY for a client that steers the seat pointer
itself. The doc claimed a desktop-model client "overrides it with its
first absolute move, so the jump is invisible in practice" — one park at
bring-up is, a repeat is not: such a client sends absolute positions,
the very same event the park synthesizes, only aimed where the user is
actually pointing. It keeps the single bring-up park, so the session's
first click cannot land on whatever monitor the seat pointer was left
on, and loses the retry that fights the user. A cold EIS connection
swallows the client's own moves too, and those keep coming.

On the reported session this is 2 parks in the first second instead of
11 over ten, and no phantom composite.

Verified: `cargo fmt --all --check`; `scripts/xcheck.sh linux clippy`
and `windows clippy`; `cargo clippy -p punktfunk-host -p pf-vdisplay
-p pf-inject --all-targets --locked -- -D warnings` and `cargo test` for
the three, run for Linux in the ci/rust-ci.Dockerfile image (this crate
does not build on macOS at all — opus, zerocopy and the Linux-only
vdisplay entry points are cfg'd out there, so the container is the only
way to compile it). pf-vdisplay 231 tests, pf-inject 130+7, and both new
tests pass. punktfunk-host's suite has two pre-existing failures under
that emulated container — `gamestream::stream::tests::sender_delivers_
batches` (EINTR on a socket recv) and one of the two `mgmt` local-summary
tests, which share process-global session state — and the SAME two fail
on this branch's parent without this commit; each passes in isolation.
Not verified on glass: the .138 Hyprland box is read-only and in use.
2026-08-14 23:56:36 +02:00
enricobuehler 6863f8141a fix(input): the wlr injector aimed absolute motion at the operator's head, never the streamed one
`WlrootsInjector::open` created its virtual pointer with `globals.output` — whatever
`wl_output` the registry roundtrip had bound, which was `state.output.is_none()`, i.e. the
FIRST one advertised. Registry globals arrive in creation order, so "first" is the
compositor's oldest output: the operator's physical head, never the per-session headless one
the client is looking at. The wlr protocol maps `motion_absolute` onto the output the pointer
was CREATED with ("if the output argument is set, the compositor should map the input device
to the requested output"), so every absolute sample from every session drove a screen nobody
was streaming. On the EXTEND backends — Hyprland and wlroots/sway, where the streamed head
sits beside the operator's — that is the field report "no cursor was visible in the session",
and it is also why `park_pointer`'s opening warp put the seat cursor on the operator's
desktop once a second instead of on the stream.

Not a startup race, though it looks like one. The host journal on the Hyprland box shows
BOTH orderings across sessions of the same build — the injector opening 3.7 s before the
headless output in one, 24 ms after it in another — and the bug in both, because
"first advertised" is the oldest global either way. `hyprctl monitors` on that box:
`HDMI-A-1` (ID 0) at +0+0, `PF-87756-3` (ID 1) at +1920+0. ID 0 is always first.

Three parts.

1. pf-vdisplay carries the head's compositor name out on `VirtualOutput::output_name`, the
   Linux counterpart of what `win_capture` already carries on Windows. Set by hyprland and
   wlroots (the two EXTEND backends) and by the monitor mirror; `None` on KWin/Mutter (they
   inject through libei, which selects by region) and gamescope (it owns its whole seat).
   Threaded through the registry pool so a keep-alive reuse answers with the same name a
   fresh create would — no poolable backend sets it today, and this is so that stops being a
   silent trap the day one does.

2. The host publishes it at capture bring-up, `pf_inject::set_stream_output`, in the same
   place and shape as the Windows arm's existing `set_stream_target`.

3. The wlr injector binds EVERY `wl_output` at v4 (for the `name` event), matches the
   published name, and re-creates its virtual pointer bound to that output whenever the
   target changes — releasing any held button on the old device first, because nothing else
   would and a virtual pointer destroyed mid-press leaves the host with a stuck button.

Matching is by NAME, with NO fallback, and the absence of the fallback is the fix: the old
"first output" behaviour WAS the fallback. Size could not stand in for it either —
`MouseMoveAbs`'s extent is the client's letterboxed video rect in its own window, not the
streamed mode, so no size ladder can identify the head. An unresolved target binds NO output,
which maps absolute coordinates over the whole layout: on a single-output compositor that is
identical to binding that output, and on a multi-head one it at least keeps the streamed head
reachable, unlike a pin to the wrong one.

`inject` now also READS the Wayland socket. It only ever called `dispatch_pending`, which is
documented to "not perform reads on the Wayland socket", so the queue held nothing but what
`open`'s roundtrips put there. Without this the retarget above would have been dead in
exactly the sessions that need it most — the injector could never learn about a `wl_output`
created after it opened — and, separately, everything the compositor sent had been piling up
unread in the socket buffer for the host's lifetime, including the protocol errors the
comment there claimed to be surfacing.

Concurrency, stated plainly: ONE slot per process. The injector is host-lifetime (in fact
there are two `InjectorService`s — the native plane's and one per GameStream control
listener) and `InputEvent` is an 18-byte `#[repr(C)]` ABI struct with no session field, so
with parallel sessions (up to `max_concurrent`, default 4) the LAST capture bring-up wins for
everyone's absolute input. That is the same trade `stream_target` already documents on
Windows, and it is strictly better than what it replaces, where every session aimed at a head
NO session was streaming. Making injection genuinely session-aware is the real fix and a much
larger one — it needs source-tagged input events through both control planes.
`set_absolute_anchor`'s warning is amended rather than quietly violated: it still must not be
called from a session path, and it now says which mechanism took the per-session trade, why
that is a separate slot (this one is the operator's host-wide capture pin, recomputed from
policy whenever the console writes it — which would wipe a per-session value), and where the
trade is written down.

Gates: `cargo clippy --all-targets -p pf-inject -- -D warnings`, `cargo build -p pf-inject`
and `cargo test -p pf-inject` (137 tests, incl. 3 new) on x86_64-unknown-linux-gnu in
`punktfunk-rust-ci`; `cargo check -p punktfunk-host` likewise; `scripts/xcheck.sh linux
clippy` plus 230 `pf-vdisplay` tests; `cargo fmt --all --check`. The two new `wlr` tests pin
the regression directly — an unknown target must bind NOTHING rather than fall back to the
first advertised output.

Not verified here: no on-glass run. The box at .138 is in live use and read-only to me, so
the change is unproven against a real Hyprland seat. Two smaller things also rest on reading
rather than observation — that Hyprland's `hyprctl` monitor name is byte-identical to its
`wl_output.name` (the protocol says the name is "the same for all clients", and xdph already
resolves our `hyprctl`-minted name to the same output for screencast, which is field-proven),
and the exact on-screen arithmetic of the "moves to mid-screen then jumps back" symptom,
which does not follow from the protocol's normalize-by-extent mapping and would need
Hyprland's own source to pin down.
2026-08-14 23:56:36 +02:00
enricobuehler cf4c12ea52 fix(vdisplay): a per-cast tokio runtime orphaned ashpd's process-global connection after one cast
THE reason the first stream of a host process worked and every later one was
black. Not xdph, not the compositor, not the formats — ours, and a lifetime
mistake.

ashpd caches its D-Bus connection process-globally:

    static SESSION: OnceLock<zbus::Connection>     // ashpd 0.13.13, src/proxy.rs:27

The first `Screencast::new()` in the process creates that connection, and zbus
spawns its background reader as a task on whichever tokio runtime is current at
that moment. Both wlr backends built their OWN multi-thread runtime per cast and
dropped it at teardown — so the first cast created the cached connection on a
runtime that was then destroyed with it, and the OnceLock went on handing the
same executor-less connection to every later `Screencast::new()`, which awaited
a reply nothing was alive to read.

Measured 2026-08-14 (Hyprland 0.55.4, xdph 1.3.12): first cast of a host process
streamed, every cast after it hung, and the surviving cast thread sat in
futex_do_wait inside runtime shutdown. The discriminator that pins it on us: a
freshly spawned process completed the identical handshake against the identical
xdph, repeatedly, while the long-lived host completed none — with xdph itself
idle at 28 ms of CPU, so it was never the one wedged. Teardown was already
correct by then: the log shows `hyprland headless output removed` in the right
order.

One shared runtime (`portal_rt`), built once, never dropped, `block_on(&self)`
from every cast thread. It outlives the cached connection because it must.

Also bound `Screencast::new()` itself, not just the handshake after it: with the
connection orphaned that call is exactly where the thread hung, so the earlier
bound started one step too late and the failure still surfaced as the caller's
generic 20 s timeout.

Verified: cargo fmt --all --check, scripts/xcheck.sh linux clippy (-D warnings),
cargo test -p pf-vdisplay 122 passed. Not verified: on-glass.
2026-08-14 23:56:36 +02:00
enricobuehler 5e5d6904d3 fix(vdisplay): a hung portal handshake leaked its thread, and one leak poisoned every later cast
MEASURED 2026-08-14 on the Hyprland box, with the ordered-teardown fix already
in place. The first cast of a host process streamed:

    hyprland headless output ready … output=PF-44694-1 w=5120 h=1440 hz=240
    pipewire stream state old=Paused new=Streaming

and every cast after it timed out in select_and_cast. The host had NINE live
`punktfunk-hypr-cast` threads and 28 tokio workers at that point.

`select_sources`/`start` await a D-Bus reply that a wedged portal never sends.
That await cannot be cancelled by the `stop` flag, because the flag is only
read by the park loop further down — a thread stuck in the handshake never
reaches it. So every timed-out attempt left a thread parked forever on a
half-created portal session, holding this process's shared D-Bus connection,
and from the first hang onwards every later request from the SAME process hung
too.

The discriminator that proves it is the process, not the portal: a freshly
spawned process (`punktfunk-host spike --source portal`, driven through the
same custom picker) completed the identical handshake against the very same
xdph — repeatedly — while the long-lived host could not complete any. xdph
itself was idle, 28 ms of CPU since start, so it was not spinning.

Bound the handshake at 15 s, under select_and_cast's 20 s wait so the failure
is reported by the thread that owns it, with a reason, and — the point — so
that thread EXITS instead of leaking. Same change in the wlroots/sway backend:
xdpw carries the identical unbounded node-id spin (screencast.c), so it can
wedge the same way.

Verified: cargo fmt --all --check, scripts/xcheck.sh linux clippy (-D warnings),
cargo test -p pf-vdisplay 122 passed. Not verified: on-glass — needs the box.
2026-08-14 23:56:36 +02:00
enricobuehler 9ce347e4c0 fix(vdisplay): we removed the captured output before closing the cast, and xdph spun on the wreckage
Only the FIRST stream after a portal start ever worked on Hyprland; every
one after it died in `select_and_cast` with

    create virtual output: timed out waiting for the ScreenCast portal on PF-…

The mitigation on `worktree-capture-bgra-dmabuf-pod` chased the symptom. This
is the mechanism, read out of xdph 1.3.12's source and the box's own journal.

WE YANK THE OUTPUT OUT FROM UNDER A LIVE CAST. `Keepalive` drops `StopGuard`
then `OutputGuard`, and `StopGuard::drop` only SET an atomic and returned. The
portal thread noticed 200 ms later and merely dropped its zbus connection. So
`hyprctl output remove` ran — synchronously, microseconds later — on an output
xdph was still capturing, every single teardown.

Nothing closed the session either. xdph destroys one on exactly one event, an
explicit `org.freedesktop.impl.portal.Session.Close` (`Session.cpp:37`); it has
no peer-vanished watcher. The frontend does (`xdg-desktop-portal.c:230`
`peer_died_cb` → `close_sessions_for_sender`), but only once our bus name goes
away — after the poll, asynchronously, on a GTask thread. Long after the output
is gone. Proof from the box: xdph's toplevel lock stayed at 2 for 4.5 minutes
after our stream ended and its output was removed, and that session's
`Session destroyed` never came.

XDPH THEN SPINS AT 100% CPU, FOREVER. Handed that wreckage, `startSharing`
falls into `Screencopy.cpp:307-313`

    while (pSession->sharingData.nodeID == SPA_ID_INVALID) {
        int ret = pw_loop_iterate(g_pPortalManager->m_sPipewire.loop, 0);

— timeout 0, i.e. NON-blocking, i.e. an unbounded hot spin on xdph's only
event-loop thread, inside the `Start` handler, holding its `m_mEventLock`. From
there it answers no D-Bus, no Wayland, no PipeWire, ever again. MEASURED: the
wedged instance's unit reported `Consumed 3min 51.971s CPU time over 23min
41.092s wall clock`, and there were 232.70 s of wall clock between its last log
flush and its restart — 231.971 s of CPU against 232.70 s of wall, one core
pinned solid for precisely the wedged interval.

Everything after that is queueing. Our next handshake gets nothing, times out at
20 s, and `SelectionFile` deletes the per-session selection file on its way out;
if xdph is restarted mid-queue it finally runs the picker for that stale request
and reads an empty file — the `SHAREDATA returned selection -1` in the log.

THE FIX IS THE ORDER. `StopGuard::drop` now signals and then WAITS for the
portal thread to have closed the ScreenCast session, and only then does
`OutputGuard` remove the output. The close is answered synchronously by the
frontend (`xdp-session.c:217` `handle_close` → `xdp_dbus_impl_session_call_close_sync`),
so when it returns xdph has already run `destroyStream`. The output we remove
next is one nobody is capturing. Bounded at 3 s on each side — an already-wedged
portal must not be able to wedge our teardown with it — and the park poll drops
to 20 ms now that teardown waits on it.

wlroots gets the same change, and NOT on an assumption of symmetry: xdpw was
read to confirm both preconditions. `src/core/session.c` gives its session
object exactly one method, `Close`; and `src/screencast/screencast.c:599-605` is
the identical unbounded `while (cast->node_id == SPA_ID_INVALID)` spin — xdph's
copy is that code. sway's `output unplug` yanks a captured output exactly the
way Hyprland's `output remove` did. Not observed on glass; no sway box.

THE PICKER LINE WAS ALSO MALFORMED, AND IS A RED HERRING FOR THE STALL. xdph
splits the picker's line on the first `/` into flags and selection
(`ScreencopyShared.cpp:86-87`) and we never sent one. `find_first_of` then
returns npos, so FLAGS became the whole payload — and SEL became the whole
payload too, purely because `npos + 1` wraps to 0, which is why the output name
still parsed and why this hid. What did not hide is the flag loop walking
`screen:<name>` one character at a time (`unknown flag from share-picker: s`,
`c`, `e`, …) and setting `allowToken` on the `r` of `sc*r*een`, so xdph answered
every Start with a `restore_data` + `persist_mode: 2` we never asked for. The
reference picker prints the separator unconditionally
(`hyprland-share-picker/main.cpp:133-136`), so empty flags are a bare leading
`/`. Fixed to `[SELECTION]/screen:<NAME>`.

It is NOT what stalled anything: the sessions that streamed fine logged the
identical flag spam and the identical restore token, so it never discriminated.

The format moves to `portal_picker.rs`, declared unconditionally like
`portal_config` and `portal_cursor`, with xdph's parser transcribed into the
tests — including the npos arithmetic. A wire format with no schema and no error
report is invisible from the string alone: the old line's one assertion passed
the entire time it was wrong. Those tests now run on every platform's CI rather
than only the leg that compiles `mod hyprland`.

Verified: cargo fmt --all --check, scripts/xcheck.sh linux clippy AND windows
clippy (-D warnings), cargo test -p pf-vdisplay 122 passed (5 new). Not verified:
on-glass behaviour — the box is in use for live testing and read-only to me.

Upstream bugs worth filing, both wlr-family: the unbounded node-id spin
(hyprwm/xdg-desktop-portal-hyprland Screencopy.cpp:307,
emersion/xdg-desktop-portal-wlr screencast.c:599) should be bounded and fail the
request rather than pinning a core forever; and xdph's picker parse should reject
a line with no `/` instead of reading the whole selection as flags.
2026-08-14 23:56:36 +02:00
enricobuehler dea6395772 fix(capture): xdph offers BGRA on its dmabuf pod, and we offered BGRx, so the link never negotiated
A Hyprland/sway client went black with no error of ours: PipeWire failed the
link itself with

    pw.link: (73.0.0 -> 81.0.0) negotiating -> error no more input formats (-22)

Measured on Hyprland 0.55.4 + xdph 1.3.12 by dumping both EnumFormat pods from
the PipeWire DAEMON (`PIPEWIRE_DEBUG=*:1,pw.link:5` — the pods are not in our
own process's log, which is why this hid for so long):

  ours   BGRx only | 12 NVIDIA tiled + 0 (LINEAR)      | MANDATORY
  xdph   BGRA only | the same 12 + MOD_INVALID         | MANDATORY|DONT_FIXATE
  xdph   BGRA or BGRx, no modifier                     | (the SHM pod)

The modifiers intersect perfectly. Only the fourcc never does, which is exactly
why the failure reads as a GPU/modifier problem and is not one — the host's own
message ("the compositor never accepted the dmabuf-only offer (EGL->CUDA GPU
import)") points at the GPU, and our advert line prints only the first 6 of 13
modifiers so LINEAR is invisible. Both misled a full session of debugging.
Since our offer is dmabuf-only, xdph's mixed SHM pod could not rescue it.

Offer a BGRA dmabuf pod beside the BGRx one. BGRA and BGRx are the same 32-bit
layout, the alpha byte is ignored all the way to the encoder (`vk_util` maps
both to B8G8R8A8_UNORM, VAAPI both to Pixel::BGRA), and the import is driven by
the NEGOTIATED format's fourcc, so an AR24 frame imports as AR24.

Vendor-neutral by construction: the two modifier lists are enumerated PER FOURCC
(`XR24` and `AR24` asked separately), because EGL and libva answer per format and
nothing entitles us to assume a driver importing one imports the other. On the
VAAPI passthrough path there is no importer, so both lists are LINEAR (plus the
PyroWave Vulkan set when armed) — AMD and Intel get the BGRA pod on the same
terms as NVIDIA rather than an NVIDIA-shaped guess.

The BGRA pod is listed AFTER BGRx, so a producer offering both still lands on
the pre-existing path — first compatible consumer pod wins, so this is purely
additive. Both pods are now guarded on a non-empty list (`build_dmabuf_format`
indexes `modifiers[0]`).

Also name `linear_offered` and both counts in the advert log, so the truncated
`sample` can no longer be misread as the whole offer.
2026-08-14 23:56:35 +02:00
enricobuehler 55dbb14cf4 docs(windows): the signing pages still told users to import a certificate we no longer publish
ci / bun-nix (pull_request) Successful in 20s
ci / web (pull_request) Successful in 1m2s
ci / rust-arm64 (pull_request) Successful in 1m22s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Failing after 1m47s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Failing after 1m53s
ci / docs-site (pull_request) Successful in 8m3s
ci / rust (pull_request) Successful in 27m30s
Azure Artifact Signing chains to a public root, so neither the host installer nor the client MSIX ships a .cer any more and users import nothing. install.md, install-client.md and windows-host.md still walked through importing one — and because the pre-Azure files are still sitting in the package registry those URLs return 200 rather than 404, so following the docs didn't fail loudly, it quietly planted a retired self-signed cert in machine Root and TrustedPublisher.

Verified on glass against the 0.29 canary while checking the release: both artifacts verify Valid, timestamped, and publicly trusted, and the MSIX installs with nothing imported.

Also corrected while here: the MSIX publisher change makes a different package identity, so installs from 0.28.1 or earlier need an uninstall rather than an upgrade (and a packaged app's settings go with it, so the client pairs again); a silent UPGRADE reuses the task selection the previous install recorded instead of the wizard defaults, so a once-declined installgamepad silently keeps stale gamepad drivers; installaudiocable stopped being a task name in 4a621de6; and Add-AppxPackage from a non-interactive session can fail 0x80070005 when the Windows App Runtime it depends on is in use.
2026-08-14 23:50:00 +02:00
enricobuehler f0b35de92a feat(screenshots): the library shelf carries real cover art on every platform
apple / swift (pull_request) Successful in 2m5s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 3m15s
ci / web (pull_request) Successful in 1m34s
ci / docs-site (pull_request) Successful in 1m27s
ci / bun-nix (pull_request) Successful in 39s
android / android (pull_request) Successful in 7m21s
ci / rust (pull_request) Successful in 7m50s
The Apple scenes passed artLoader nil, so the store's library frames shipped
empty frosted cards next to Android's populated shelf. LibraryArtSource (a
two-method protocol the production LibraryArtLoader already satisfies) lets the
harness answer art from a canned source, and the four posters are drawn
procedurally at capture time — CoreGraphics on Apple, Canvas on Android, same
designs, same seeds — replacing Android's gradient-plus-monogram tiles. Nothing
is bundled; release builds ship none of it.
2026-08-14 23:39:41 +02:00
enricobuehler 588962f696 Merge pull request 'A lost merge run left an Android fix unpublished, and no re-run could ship it' (#237) from worktree-android-dispatch-publish-hatch into main
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 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 12s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m13s
docker / builders-arm64cross (push) Successful in 7s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m27s
docker / deploy-docs (push) Successful in 32s
android / android (push) Successful in 7m4s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
Reviewed-on: #237
2026-08-14 21:03:12 +00:00
enricobuehler 91b8f1a939 ci(android): a manual dispatch can publish, so a lost merge run is not a dead end
ci / web (pull_request) Successful in 1m1s
ci / docs-site (pull_request) Successful in 1m18s
ci / bun-nix (pull_request) Successful in 1m37s
android / android (pull_request) Successful in 4m33s
ci / rust-arm64 (pull_request) Successful in 12m3s
ci / rust (pull_request) Canceled after 29m33s
Merging two PRs seconds apart can leave the older merge sha with no run at all -
Gitea attributes the window's runs to the newer head. 1e5dca4c (PR #235, the pad
Select/KEYCODE_BACK fix) lost its run to b5cace3a 12 s later and reached main
having never been built, so no canary carries it.

Re-running the PR run cannot recover that: a re-run replays the original
pull_request event, and all four publish steps are gated on a push, so they stay
skipped. Until now the only cure was inventing a filler push touching one of
android.yml's paths.

Add a workflow_dispatch 'publish' input, default false, and widen the four gates
to accept a dispatch that opts in. A plain manual run stays build-only, so a
stray click still cannot reach testers. String-typed and compared against 'true'
to match apple.yml's testflight input, the form proven on this Gitea.
2026-08-14 22:55:13 +02:00
enricobuehler b5cace3a00 Merge pull request 'The app menu ate ⌘Q, so the host's compositor never saw the chord' (#236) from worktree-apple-cmd-passthrough into main
apple / swift (push) Successful in 2m3s
apple / distribute (push) Successful in 10m51s
apple / screenshots (push) Successful in 8m49s
ci / rust (push) Successful in 27m48s
ci / web (push) Successful in 9m48s
ci / docs-site (push) Successful in 8m58s
ci / bun-nix (push) Successful in 27s
ci / rust-arm64 (push) Successful in 2m3s
Reviewed-on: #236
2026-08-14 20:10:16 +00:00
enricobuehler 1e5dca4c25 Merge pull request 'A pad whose Select is KEYCODE_BACK quit the session on one press' (#235) from worktree-shield-select-back-quit into main
Reviewed-on: #235
2026-08-14 20:10:04 +00:00
enricobuehler b2146f33fe fix(apple): the app menu ate ⌘Q, so the host's compositor never saw the chord
ci / rust (pull_request) Successful in 6m40s
ci / web (pull_request) Successful in 1m11s
ci / docs-site (pull_request) Successful in 2m13s
ci / bun-nix (pull_request) Successful in 46s
apple / swift (pull_request) Successful in 2m5s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m58s
With the default modifier layout ⌘ is Super on the host, which makes ⌘Q the chord
a Hyprland/KDE/GNOME user reaches for first. AppKit dispatches menu key
equivalents before the stream view ever sees a keyDown, so it quit the client
instead. Not Hyprland-specific.

`InputCapture`'s local keyDown monitor now claims every ⌘ chord while input is
captured and forwards it to the host itself. It has to send from there: the
monitor runs ahead of BOTH the menu and `StreamLayerView.keyDown`, and on macOS
that second one is the host's only key path (the GCKeyboard send has been
iOS-only since e414ec0) — so returning nil to keep the menu out takes the host's
copy with it. The file's own comment said the opposite, that swallowing keys
"risks starving GC's own delivery"; on macOS there is no GC delivery to starve,
which is why this could never have been a one-line `return nil`.

Verified against AppKit rather than assumed: a standalone harness posting a
synthetic ⌘Q confirms the monitor sees it first and that returning nil stops the
menu item firing, with a passed-through ⌘W as the control.

⌘⎋ and ⌃⌘F stay client-side whatever the setting says — forward those and a
captured stream is a room with no door. ⌘Tab, ⌘Space and Mission Control are out
of reach for a local monitor: macOS claims them before any app sees them, and
catching them needs a CGEventTap and an Accessibility prompt, which is a product
decision rather than a code one.

This answers to the cross-client "Capture system shortcuts"
(`Settings::inhibit_shortcuts`), which the Apple client had no answer to because
SDL's keyboard grab is what implements it everywhere else. Default on,
profileable like its siblings, and — matching the SDL clients — inert under the
desktop mouse model, which is something you ⌘Tab away from.

Two adjacent defects fixed on the way, both the same root cause. macOS stops
delivering keyUp while ⌘ is held, so a forwarded chord key is released when the
last ⌘ comes up rather than waiting for an up that may never arrive, and the
one-shot `suppressedVK` latch is cleared in the same place — left pending (⌃⌘F's
F, ⌘⎋'s Esc) it would go on to eat the next press of that key. Chord matching
also stopped comparing the raw `deviceIndependentFlagsMask`, which carries Caps
Lock and the arrows' .function/.numericPad bits: with Caps Lock on, ⌘⎋ and
⌃⌥⇧Q — both escape hatches — were not recognized at all.

On glass still owed: a clean compile proves nothing for an input grab.
2026-08-14 22:04:30 +02:00
enricobuehler 1ac6c9bf3d fix(android): a pad whose Select is KEYCODE_BACK quit the session on one press
ci / bun-nix (pull_request) Successful in 21s
ci / docs-site (pull_request) Successful in 1m23s
ci / rust-arm64 (pull_request) Successful in 2m50s
ci / web (pull_request) Successful in 5m55s
ci / rust (pull_request) Canceled after 6m47s
android / android (pull_request) Successful in 5m38s
Field report: pressing Select disconnected the stream. The host log was
unambiguous about what it was NOT — "client datagram stream ended" plus "virtual
display torn down (deliberate quit — keep-alive skipped)" is a client that said
it was leaving, not a drop and not a compositor crash.

Only two client paths raise that: StreamScreen's BackHandler, and the exit chord
(router.onExitChord). The chord is excluded by construction — `armExit` posts a
1 s timer and releasing any member calls `disarmExit`, so a tap always cancels.
That leaves the back stack, and from a SOURCE_GAMEPAD device KEYCODE_BACK is the
ONLY keycode that reaches it: a mapped button is consumed in the gamepad branch,
anything with a VK is consumed on the keycode path, volume/power go to the
system, and a FLAG_FALLBACK BACK is swallowed. So a one-press quit identifies the
button's keycode without knowing which controller was on the couch.

Plenty of pads deliver Select as the plain KEYCODE_BACK a remote's Back uses,
with no BUTTON_SELECT scancode behind it — the Android-TV shape, where every
input device is expected to offer Back, reached whether the vendor prints "Back"
on the button or "Select"/"View". `buttonBit` had no row for KEYCODE_BACK, so the
press fell through unconsumed into StreamScreen's BackHandler, which is the
deliberate-quit exit. One press, session over.

The same gap meant those pads could not produce BTN_BACK at all, so every
shortcut built on Select was unreachable on exactly the devices whose users have
no keyboard: the emergency exit chord StreamScreen's own start banner advertises
("Hold Select + Start + L1 + R1 to leave"), the mic mute, the stats tier.

New `Gamepad.padButtonBit(keyCode, flags)` — buttonBit plus that one row —
resolves a gamepad-sourced BACK to BTN_BACK, and MainActivity's streaming branch
asks it instead. It keys off the keycode, not the vendor, so it covers every pad
with this behaviour; a pad that does carry BUTTON_SELECT is unaffected in both
directions, having never had the bug. FLAG_FALLBACK events stay excluded: those
are the synthetic BACK the framework raises after an unconsumed BUTTON_* press,
and forwarding one would put a phantom Select on the wire (one landing while
Start + L1 + R1 were held would complete the exit chord out of nowhere). A
remote's or keyboard's BACK is neither mouse- nor gamepad-sourced, so it still
leaves the stream — for a device with no pad on it that is the documented way
out, and the banner says so.

The mouse-side-button hook moves above the gamepad branch so a device that can
be a mouse keeps its X1/X2 semantics; it answers null for everything that cannot
be a mouse, so nothing else changes route.

PadButtonBitTest pins the mapping, the fallback exclusion, that the three Select
chords are now reachable from a BACK-only pad, and that no other keycode moved.

Verified: :kit:testDebugUnitTest + :app:testDebugUnitTest green (PadButtonBitTest
4/4), :app:compileDebugKotlin clean. NOT yet verified on-glass — the behaviour
needs a real pad: Select reaches the game, and Back no longer quits.
2026-08-14 21:52:16 +02:00
enricobuehler 36e133ae66 Merge pull request 'Android canary ships to Play open + closed testing instead of internal' (#234) from worktree-android-canary-open-testing into main
ci / web (push) Successful in 1m30s
ci / bun-nix (push) Successful in 17s
ci / rust-arm64 (push) Failing after 2m32s
ci / docs-site (push) Successful in 1m45s
android / android (push) Successful in 7m59s
ci / rust (push) Canceled after 17m30s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m20s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 52s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 27s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / builders-arm64cross (push) Successful in 9s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 48s
docker / deploy-docs (push) Successful in 36s
Reviewed-on: #234
2026-08-14 19:50:39 +00:00
enricobuehler d7e66fafe1 Merge pull request 'The store's four marketing frames become screenshot scenes on both platforms' (#233) from worktree-store-marketing-scenes into main
android / android (push) Canceled after 17s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
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/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
apple / swift (push) Successful in 1m59s
apple / distribute (push) Successful in 11m3s
apple / screenshots (push) Canceled after 7m31s
Reviewed-on: #233
2026-08-14 19:49:33 +00:00
enricobuehler a4210024dc ci(android): canary also feeds Play closed testing (alpha)
android / android (pull_request) Successful in 7m7s
ci / rust (pull_request) Successful in 7m38s
ci / web (pull_request) Successful in 2m25s
ci / docs-site (pull_request) Successful in 4m18s
ci / bun-nix (pull_request) Successful in 2m40s
ci / rust-arm64 (pull_request) Successful in 2m23s
Closed testing went dry on 2026-08-01 when tags started publishing
straight to production instead of alpha — its testers have been pinned
to the last pre-access build since. Canaries now assign the same
versionCode to beta (open) AND alpha (closed) via play-upload.py's new
repeatable --also-track flag: both PUTs share one Play edit, so one
commit and one review cover both tracks and they can never disagree
about which canary is current. Tags still go to production only.
2026-08-14 21:45:34 +02:00
enricobuehler ed935ed31c ci(android): canary ships to Play open testing (beta), not internal
Main-push canaries now land on the open-testing track: public opt-in
link, no tester-list cap. Trade-off documented in the workflow header:
open testing goes through Google review (hours/days), where internal
was review-free (minutes). android-promote's from_track default follows
the canary to beta.
2026-08-14 21:39:17 +02:00
enricobuehler daabb85373 Merge pull request 'The Linux data-plane renice was a silent no-op on every install — RealtimeKit fallback, audio threads boosted at all, nice-limit headroom on every channel' (#232) from worktree-thread-qos-rtkit into main
audit / bun-audit (sdk) (push) Successful in 36s
audit / bun-audit (web) (push) Successful in 14s
audit / docs-site-audit (push) Successful in 26s
audit / pnpm-audit (push) Successful in 18s
audit / cargo-audit (push) Successful in 2m26s
apple / swift (push) Successful in 1m56s
audit / bun-audit (plugin-kit) (push) Successful in 3m33s
ci / rust-arm64 (push) Failing after 2m12s
audit / miri (push) Successful in 5m14s
android / android (push) Successful in 8m30s
ci / bun-nix (push) Successful in 26s
ci / docs-site (push) Successful in 1m35s
audit / license-gate (push) Successful in 8m7s
audit / c-abi-asan (push) Successful in 8m29s
ci / web (push) Successful in 6m5s
apple / distribute (push) Successful in 10m58s
apple / screenshots (push) Successful in 8m44s
windows-host / package (push) Successful in 13m15s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 31s
ci / rust (push) Canceled after 29m44s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Failing after 1m49s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 1m49s
flatpak / build-publish (push) Successful in 33m23s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 27s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 29s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 31s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 31s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 25s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 22s
docker / builders-arm64cross (push) Successful in 11s
docker / deploy-docs (push) Successful in 38s
deb / build-publish-gamescope (push) Successful in 1m3s
deb / build-publish (push) Successful in 4m9s
deb / build-publish-client-arm64 (push) Successful in 6m0s
arch / build-publish (push) Successful in 8m3s
deb / build-publish-host (push) Successful in 8m35s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 8m57s
deb / smoke-install (push) Canceled after 1m17s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 18m5s
nix / flake (push) Failing after 26m33s
2026-08-14 19:15:38 +00:00
enricobuehler 9af894a374 docs: changelog for the Linux thread-priority fix
ci / bun-nix (pull_request) Successful in 32s
nix / flake (pull_request) Failing after 44s
ci / rust-arm64 (pull_request) Failing after 51s
android / android (pull_request) Failing after 53s
ci / web (pull_request) Successful in 1m15s
apple / swift (pull_request) Successful in 2m2s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m19s
ci / docs-site (pull_request) Successful in 4m53s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m32s
ci / rust (pull_request) Successful in 17m59s
2026-08-14 20:59:54 +02:00
enricobuehler 52df9c59af pkg(linux): nice-limit headroom on every channel, so the renice also works without rtkit
A new shared drop-in, packaging/linux/50-punktfunk-nice.conf
(user@.service.d, LimitNICE=-15), raises the user-session nice hard
limit so the direct setpriority() path works on rtkit-less boxes — a
limit, not a grant, effective from the next login. Shipped by rpm
(%files + install, flows into the Bazzite sysext via rpm2cpio), Arch,
and deb; the Steam Deck installer writes it to
/etc/systemd/system/user@.service.d instead (SteamOS /usr is
read-only), following its existing sudo-to-/etc pattern.

rpm and deb gain a weak Recommends: rtkit and Arch an optdepends hint —
with rtkit the fix needs no relogin at all. The NixOS module instead
sets security.rtkit.enable = mkDefault true (rtkit is not a given
there; mkDefault keeps it operator-overridable).

It remains true on every channel that the host binary must never carry
a file capability — the spec's no-caps note now names the two fallback
rungs instead of calling the thread nice a best-effort no-op.
2026-08-14 20:59:52 +02:00
enricobuehler b21b2f6ce9 fix(host): the Linux data-plane renice was a silent no-op everywhere — fall back to RealtimeKit, and boost the audio threads at all
Every Linux host to date ran its capture/encode/send threads at nice 0:
boost_thread_priority's setpriority() needs CAP_SYS_NICE or a raised
RLIMIT_NICE, no install channel granted either, and the host binary can
never carry a file capability (a capped process's /proc/<pid>/exe is
unreadable to KWin — the 0.26.0-1 incident). A 2026-08-14 field log
showed the cost end to end: a fresh game launch's shader-compile storm
descheduled the unprioritized threads, 5 ms audio datagrams left late
enough to stutter, the client's OWD signal rose, and ABR cut a
gigabit-Ethernet session to its 5 Mbps floor at zero loss — while the
same box carried 708 Mbps cleanly once the storm passed.

The renice now falls back to RealtimeKit (MakeThreadHighPriorityWithPID,
one blocking system-bus call per boosted thread) — the same unprivileged
broker PipeWire clients use, so nothing enters the permitted set and
KWin identification is untouched. Only the nice verb, never
MakeThreadRealtime: the SCHED_RR reservations apply to rtkit-granted RR
too. zbus rides ashpd's exact backend choice (tokio, no async-io) plus
blocking-api, so the resolved graph gains no second I/O backend.

And the audio plane is boosted for the first time: the 5 ms Opus
capture->encode->send loop (critical — a stall there is directly
audible), the PipeWire capture mainloop thread (its process callbacks
run there; PipeWire's own module-rt only covers data loops we don't
use), and the pad-audio streamer (above-normal, like the session send
thread). The first two had no boost call at all; on Windows the
audio_thread boost also engages, via the SetThreadPriority arm.
2026-08-14 20:59:39 +02:00
enricobuehler 791dedd62a Merge pull request 'The management port is movable, and the client no longer needs mDNS to find it' (#230) from worktree-mgmt-port-single-source into main
arch / build-publish (push) Failing after 58s
ci / rust-arm64 (push) Successful in 1m37s
ci / bun-nix (push) Successful in 19s
apple / swift (push) Successful in 1m57s
deb / build-publish-gamescope (push) Failing after 46s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Failing after 1m37s
deb / build-publish (push) Successful in 5m9s
deb / build-publish-host (push) Successful in 4m37s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
android / android (push) Successful in 7m24s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 1m45s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
deb / build-publish-client-arm64 (push) Failing after 2m43s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
ci / web (push) Successful in 7m11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 46s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m20s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 1m12s
docker / builders-arm64cross (push) Failing after 6s
docker / deploy-docs (push) Failing after 2s
ci / docs-site (push) Successful in 8m48s
nix / flake (push) Failing after 3m33s
deb / smoke-install (push) Successful in 3m17s
apple / distribute (push) Successful in 11m8s
ci / rust (push) Successful in 19m53s
apple / screenshots (push) Successful in 8m49s
flatpak / build-publish (push) Successful in 16m36s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m15s
windows-host / package (push) Successful in 18m5s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Failing after 1s
Reviewed-on: #230
2026-08-14 18:25:12 +00:00
enricobuehler 35f940a3bb fix(presenter): name the Connected callback type — widening it tripped clippy::type_complexity
apple / swift (pull_request) Successful in 1m59s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m15s
ci / rust-arm64 (pull_request) Failing after 1m49s
ci / docs-site (pull_request) Successful in 1m19s
android / android (pull_request) Successful in 5m27s
ci / rust (pull_request) Successful in 5m43s
ci / bun-nix (pull_request) Successful in 6m14s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Failing after 1m40s
nix / flake (pull_request) Successful in 14m6s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Failing after 2m0s
Adding the mgmt port beside the fingerprint pushed the inline
`Option<Box<dyn FnMut([u8; 32], u16)>>` over `clippy::type_complexity`, which CI denies. A named
`ConnectedFn` is what the lint asks for, and it gives the two positional arguments somewhere to be
documented.

My local gates ran `cargo check`, not `clippy -D warnings`, which is exactly why this reached CI
instead of dying locally. Re-verified with `cargo clippy --all-targets -- -D warnings` across all
eight crates: exit 0, pf-presenter confirmed genuinely linted, no type_complexity remaining.
2026-08-14 20:09:05 +02:00
enricobuehler aa53f1e5ef Merge pull request #231 from fix-host-cer-alias-null-key
ci / web (push) Successful in 1m4s
ci / bun-nix (push) Successful in 19s
ci / rust-arm64 (push) Successful in 1m34s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 7s
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 6s
ci / docs-site (push) Successful in 1m12s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 12s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Failing after 49s
docker / builders-arm64cross (push) Skipped
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 17s
docker / deploy-docs (push) Successful in 42s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m58s
ci / rust (push) Canceled after 16m39s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 4m4s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
An unset HOST_CER_PATH is $null, and a null hash key is fatal — not an empty key
2026-08-14 18:08:35 +00:00
enricobuehler 80061fbf6b fix(ci): an unset HOST_CER_PATH is $null, and a null hash key is fatal — not an empty key
ci / rust-arm64 (pull_request) Failing after 1m42s
ci / bun-nix (pull_request) Successful in 16s
ci / web (pull_request) Successful in 1m4s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m43s
ci / docs-site (pull_request) Successful in 8m29s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m18s
ci / rust (pull_request) Failing after 21m4s
Azure signing produces no .cer, so HOST_CER_PATH is deliberately unset. The publish step then built
its alias map as a single hash literal containing $env:HOST_CER_PATH as a KEY, and an unset $env:
var is $null — "A null key is not allowed in a hash literal", which failed the whole step. Canary
run 18256: the installer signed fine and published to its versioned path, then this line killed the
alias refresh, so `canary/punktfunk-host-setup.exe` went stale.

I reasoned about this line while making the .cer optional and concluded an unset variable would give
an empty-string key, which is legal. It does not — that only happens through string interpolation.
The $files guard just above filters the missing .cer correctly; the hash literal ran before anything
could use it.

Build the map incrementally instead, adding the .cer entry only when there is one, so the legacy
.pfx modes still alias it.

windows-client.yml survived the same change only by accident: it writes "$($env:MSIX_CER_PATH)",
and interpolating $null yields an empty string, which IS a legal key. Made that explicit too rather
than leaving correctness resting on quotes someone could reasonably tidy away.

Verified under pwsh 7: the old literal reproduces the exact CI message with the var unset; the new
form yields one entry unset and two entries set, with the .cer alias intact.
2026-08-14 20:07:57 +02:00
enricobuehler 026dbe6153 fix(screenshots): captures grow the status bar Robolectric never had
ci / rust-arm64 (pull_request) Successful in 1m57s
ci / bun-nix (pull_request) Successful in 48s
ci / docs-site (pull_request) Successful in 1m55s
apple / swift (pull_request) Successful in 2m3s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 8m58s
android / android (pull_request) Successful in 6m12s
ci / rust (pull_request) Successful in 27m10s
Robolectric renders no system UI and zero insets, so every phone capture
was missing the status bar and its content sat where the bar belongs — on
the Pixel store render the app title collided with the camera punch-hole.
ShotStatusFrame draws a plausible bar (time left, radios right, the
CENTRE left empty for the hole) and pushes the scene below it, the same
geometry real insets produce; height mirrors a Pixel's tall bar measured
off a real capture. On for the touch screens, off for the immersive
surfaces (stream, console shell, TV) that hide the real bar too.
2026-08-14 19:53:09 +02:00
enricobuehler 3cc8fa7ee0 feat(core): the host tells the client where its library is, so mDNS is no longer required
ci / rust (pull_request) Failing after 10m40s
ci / rust-arm64 (pull_request) Failing after 31s
android / android (pull_request) Failing after 1m9s
ci / docs-site (pull_request) Successful in 1m23s
apple / swift (pull_request) Successful in 2m4s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Failing after 1m47s
ci / bun-nix (pull_request) Successful in 2m23s
ci / web (pull_request) Successful in 5m45s
nix / flake (pull_request) Successful in 13m46s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Failing after 1m47s
ABI 19 -> 20. Wire protocol unchanged (still 2).

Persisting the mgmt port (fe2bfeca) made a moved port survive mDNS going away, but mDNS was still
the only SOURCE: a host that had never been seen on it — VPN-only, a routed subnet, or simply added
by address on a network where multicast has never worked — had nothing to learn from and fell back
to 47990. The `Welcome` now carries the port, so the client learns it over the connection it has
already authenticated and discovery stops being involved at all.

`Welcome.mgmt_port`, a trailing u16 after the cipher block, following the same additive discipline
as the eight fields before it (compositor, gamepad, bitrate_kbps, bit_depth, color, chroma_format,
audio_channels, codec): an older peer stops earlier and gets a documented default, in both
directions, so WIRE_VERSION does not move.

⚠ THE TRAP, and why emitting the port forces the `cipher` placeholder: `cipher` is emitted only
when non-default, so appending the port to an AES Welcome would land its LOW BYTE at offset 68 —
exactly where every shipped 0.28.x client reads `cipher`, whose decode is deliberately fail-closed
on an unknown id. 47991 is 0xBB57, so byte 68 would read 0x57 = 87, and EVERY current client would
fail the handshake against a host that had merely moved its mgmt port. `encode` therefore writes an
explicit cipher byte whenever a port rides along (the placeholder discipline `Hello::encode`
already uses); a current client reads AES, a pre-cipher client stops before 68 regardless. The test
pins the byte, both offsets (69 AES / 101 ChaCha), and that a host advertising no port still emits
exactly 68 bytes — this field costs the common case nothing.

Host: `mgmt::effective_port()` reads the same resolved bind `publish_endpoint` writes, so the wire,
the endpoint file and the mDNS TXT cannot disagree — one lookup, not a fourth place to compute a
port. `0` on the standalone punktfunk1-host binary, which has no management API: advertising 47990
from a host that is not serving it would be worse than saying nothing.

Clients persist it on connect, feeding the store plumbing fe2bfeca already built:
* Rust — `on_connected` grew the port alongside the fingerprint, plus `learn_mgmt_port_by_fp`
  (keyed by fingerprint alone, the identity a just-connected client is certain of).
* Apple — `PunktfunkConnection.hostMgmtPort` + `updateMgmtPort` at the existing markConnected site.
* Android — a new `nativeHostMgmtPort` JNI call, persisted where the session is constructed.

Verified: Linux (pf-lxcheck2, amd64) `cargo check --all-targets` clean across punktfunk-core,
pf-host-config, punktfunk-host, pf-client-core, pf-presenter, punktfunk-cli, punktfunk-client-linux
and punktfunk-client-session, each confirmed genuinely compiled (counting `Compiling` as well as
`Checking` — cargo prints the former for bin-only packages, which is what made an earlier gate look
vacuous when it was not). punktfunk-core quic tests 76/76. Android: :kit+:app Kotlin, ParseRecordTest
12/12, and cargoNdkClippy clean for aarch64-linux-android. Apple: xcframework rebuilt at ABI 20,
`swift build` complete. cargo fmt --all --check clean. NOT verified: the Windows client
(192.168.1.133 unreachable).
2026-08-14 19:44:19 +02:00
enricobuehler 99eb679c07 feat(clients): a moved mgmt port now outlives the advert that announced it
Moving the mgmt port off 47990 (the fix for sharing a box with a Sunshine fork, whose web UI owns
that port) only ever worked for as long as mDNS did. The real port lived in the advert and nowhere
else: every client read it live and threw it away, so on a VPN, a routed subnet, or any
multicast-dead network the library silently fell back to a port nothing was listening on.

`KnownHost` gains `mgmt_port: Option<u16>` + `effective_mgmt_port()` + `learn_mgmt_port()`, exactly
the shape `mac` and `os` already use ("learned from the advert while online, persisted so it
survives the host going to sleep") — except this one is load-bearing rather than cosmetic, so
`upsert` states the preserve rule explicitly instead of relying on the does-not-mention-it accident
that `clipboard_sync` survives by, and `upsert_trusted` carries it across a re-key.

Wired through all four client families, each of which was wrong in its own way:

* CLI / Windows / Linux reached for `DEFAULT_MGMT_PORT` at the call site — the constant is the
  FALLBACK, not the answer. Windows also needed the port on `Target`, which the library screen has
  instead of a `KnownHost`.
* The session console read `advert.and_then(mgmt_port)` with NO saved fallback, two lines above an
  `os` that gets the three-rung treatment right. It now matches, and learns on every tick.
* Linux's `mgmt_port_for` consulted live adverts only; it now falls back to the store.
* Android never carried the port at all — its native discovery record stopped at 8 fields. Added
  `mgmt` as the 9th (the record's own documented "new fields append, never reorder" rule), then
  through `DiscoveredHost` -> `KnownHost` -> `LibraryScreen`.
* Apple LOOKED done and was not: `StoredHost.mgmtPort` and `effectiveMgmtPort` have existed all
  along, but nothing anywhere wrote the field and the `mgmt` TXT was never parsed — so it was
  permanently nil and every Apple client resolved to 47990 regardless. That is worse than the
  honest omissions above, because it reads as finished. Now parsed, carried on `DiscoveredHost`,
  and written by `HostStore.updateMgmtPort` at the same site that learns MACs and the OS chain.

Also `PUNKTFUNK_NATIVE_PORT` in host.env, finishing the pair with PUNKTFUNK_MGMT_BIND: `--native-port`
was likewise CLI-only and died on a package upgrade. A bad value is a startup ERROR rather than the
silent fall back to 9777 that `PUNKTFUNK_DATA_PORT` still does — the failure that reads as "I moved
the port and the client still can't reach me". The client side of the native port already worked
(`KnownHost.port` is persisted, `--connect HOST:PORT` names it).

Adding the field broke three `KnownHost` literals in tests, which is the `Default` impl's stated
purpose working ("adding a field here can't silently produce records that lack it"). All three now
carry 47991 — deliberately NOT the default, so the assertions cannot pass vacuously against a
hardcode. New coverage: forward-compat decode of a store predating the field, the resolver
fallback, re-key carry-forward, and on Android the 9th-field parse plus 0/non-numeric/out-of-range
all reading as unknown.

What this does NOT fix: a host that moved its mgmt port and has NEVER been seen over mDNS. Nothing
tells the client where to look, and the honest fix is for the host to announce it in-band — the
`Welcome` message has an established "append a trailing field, older peer decodes to the default"
pattern for exactly this, at the cost of a C ABI accessor and a bump. Left for a separate change.

Verified: Linux (punktfunk-rust-ci/pf-lxcheck2, amd64) `cargo check --all-targets` clean for
pf-host-config, punktfunk-host, pf-client-core, punktfunk-cli, punktfunk-client-linux and
punktfunk-client-session — the last confirmed non-vacuous by planting a compile_error! and watching
the gate fail (cargo prints "Compiling", not "Checking", for bin-only packages, so the usual marker
grep lies about it). Android: :kit + :app compileDebugKotlin clean, ParseRecordTest 12/12 with both
new cases named in the XML. Apple: xcframework built, `swift build` complete, SharedFoundationTests
pass. cargo fmt --all --check clean. NOT verified: the Windows client (192.168.1.133 unreachable).
2026-08-14 19:44:19 +02:00
enricobuehler bb78117504 feat(host): moving the management port off 47990 now survives, and the console follows
47990 is the management API's port and also Sunshine's (and Apollo's, and Vibeshine's) web UI
port. With the GameStream planes off it is the ONLY port the two still share, so moving it is the
whole of what "run both on one box" needs — except moving it was barely possible:

* `--mgmt-bind` was the sole route, and it lives in a unit file / service registration that a
  package upgrade rewrites. There was no `host.env` key, so the change did not survive.
* The literal 47990 appeared in SIX places — mgmt::DEFAULT_PORT, the Windows service's console
  launch, scripts/punktfunk-web.service, the NixOS module, web/web-run.cmd, and the console's own
  default. Nothing downstream could learn a different port, so moving the listener silently left
  the console proxying to a port nothing was listening on.

Now there is one source of truth. `PUNKTFUNK_MGMT_BIND` joins `host.env` (the `--gamestream` /
PUNKTFUNK_GAMESTREAM shape: either source works, the CLI flag wins), and `serve` publishes the port
it ACTUALLY bound to ~/.config/punktfunk/mgmt-endpoint, in the same KEY=VALUE form mgmt-token
already uses so it is sourceable as a systemd EnvironmentFile and readable by the Windows service's
existing read_env_file_value. Every consumer derives from that; the 47990 literals survive only as
the fallback that keeps an OLD host working with a NEW console.

The two unit files drop their hardcoded `Environment=PUNKTFUNK_MGMT_URL=` rather than layering a
default beneath the file: whether Environment= or EnvironmentFile= wins is a directive-ordering
question, and the hand-written unit and the Nix-generated one do not order the same way. No
default, no precedence puzzle — the server's own built-in fallback covers a host that never wrote
the file.

Two robustness details worth naming, because both fail in the same direction:
* mgmt-endpoint is written write-then-rename. A torn read would set PUNKTFUNK_MGMT_URL to EMPTY,
  which is worse than a missing file — a built-in default only rescues an *unset* variable.
* mgmtUrl() now treats blank as unset, which `??` alone does not.

The publish happens in parse_serve next to the token persistence, so both files appear together;
the console's unit gates on mgmt-token, and its Restart=always picks up a lost race anyway.

What this does NOT change: a lost 47990 bind is still fatal to the whole host (the bind sits in
tokio::try_join! with the native plane), and running two Moonlight-compatible hosts at once is
still unsupported — on Windows the exclusive display topology is a second, independent conflict.
Both are documented rather than altered.

Verified on Linux in punktfunk-rust-ci (amd64): cargo check --all-targets clean for punktfunk-host
and pf-host-config with the "Checking punktfunk-host" marker confirmed present (a first run exited
0 having compiled nothing — the warm shared target dir judged it fresh), 40/40 mgmt tests pass
including the new one pinning the published line against both parsers that consume it. Console:
tsc --noEmit clean, bun test server/ 9/9. cargo fmt --all --check clean.
2026-08-14 19:44:19 +02:00
enricobuehler 4676d20dc1 Host capture gain works on punktfunk/1, and boosting no longer hard-clips (#229)
arch / build-publish (push) Failing after 7m4s
apple / distribute (push) Successful in 11m50s
ci / docs-site (push) Successful in 9m13s
android / android (push) Successful in 8m2s
apple / screenshots (push) Successful in 8m39s
deb / build-publish-gamescope (push) Successful in 28s
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 12s
docker / builders-arm64cross (push) Failing after 8s
flatpak / build-publish (push) Successful in 11m42s
deb / build-publish-host (push) Successful in 5m14s
docker / deploy-docs (push) Failing after 4s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 27s
deb / build-publish (push) Successful in 12m23s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13s
apple / swift (push) Successful in 1m56s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 10s
deb / smoke-install (push) Failing after 5s
deb / build-publish-client-arm64 (push) Successful in 1m23s
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 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 24s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 6m50s
windows-host / package (push) Failing after 12m55s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
ci / web (push) Successful in 1m10s
ci / rust-arm64 (push) Failing after 2m16s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m2s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m1s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m19s
ci / rust (push) Successful in 28m39s
ci / bun-nix (push) Successful in 18s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m58s
2026-08-14 17:20:50 +00:00
enricobuehler be0030f953 Merge pull request #228 from worktree-azure-trusted-signing
android / android (push) Canceled after 0s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
apple / swift (push) Canceled after 0s
apple / distribute (push) Canceled after 0s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-gamescope (push) Canceled after 0s
decky / build-publish (push) Successful in 1m0s
deb / build-publish-client-arm64 (push) Canceled after 0s
deb / smoke-install (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
Sign Windows releases with Azure Artifact Signing — a 3-day leaf makes timestamping mandatory
2026-08-14 17:20:10 +00:00
enricobuehler cc70c64797 feat(screenshots): the add-host sheet becomes a scene — the blends' phone screens were three designs old
The Blender store scenes render whatever screens/ holds, and theirs were
June captures of the pre-console UI. Fresh captures existed for hosts and
pair but the add-host sheet had no scene: AddHostSheet's state is hoisted
(ConnectScreen keeps half-typed values across dismissal), so the scene
passes a filled form straight in.

Two capture-truth fixes with it: dialog scenes advance the frozen clock
1.6 s (a ModalBottomSheet's entrance spring is still mid-rise at 0.8 s),
and the add-host shot uses Pixel-like geometry (411×915dp @ 420 dpi —
same 1080×2400 px, but the dp headroom is what lets the Connect button,
the row carrying the resolution promise, fit in frame).
2026-08-14 19:15:37 +02:00
enricobuehler 8ee963b2b0 Merge pull request 'Exclusive topology left the KDE panel lit under a gamescope spawn — DPMS it dark' (#227) from worktree-gamescope-exclusive-dpms into main
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Failing after 2s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Failing after 1s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 2s
ci / rust-arm64 (push) Failing after 3s
docker / builders-arm64cross (push) Skipped
ci / rust (push) Canceled after 5m33s
ci / bun-nix (push) Successful in 1m29s
ci / web (push) Successful in 3m32s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Failing after 2s
deb / build-publish-host (push) Failing after 2m10s
ci / docs-site (push) Successful in 4m13s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Failing after 8s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Failing after 2s
docker / deploy-docs (push) Skipped
deb / build-publish (push) Canceled after 5m32s
deb / smoke-install (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Failing after 24s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Failing after 22s
android / android (push) Canceled after 5m25s
deb / build-publish-gamescope (push) Failing after 1m50s
deb / build-publish-client-arm64 (push) Failing after 1m40s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 15s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 12s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 6s
windows-host / package (push) Canceled after 5m46s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
arch / build-publish (push) Canceled after 5m30s
2026-08-14 17:14:42 +00:00
enricobuehler 2d15548e38 ci(windows): provision the signing toolchain — no .NET runtime meant signtool exited 3 in silence
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 7m59s
android / android (pull_request) Failing after 2m16s
ci / web (pull_request) Successful in 2m29s
ci / rust-arm64 (pull_request) Successful in 2m35s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 4m24s
apple / swift (pull_request) Successful in 2m7s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / bun-nix (pull_request) Successful in 5m25s
ci / rust (pull_request) Successful in 7m16s
ci / docs-site (pull_request) Successful in 7m48s
Verified the whole Azure signing path on the runner (.133) today and it failed twice, for two
reasons that neither error message named. Both are now provisioned here so a rebuild from the
unom/infra Packer template cannot silently un-fix them.

Azure.CodeSigning.Dlib.dll is a mixed-mode C++/CLI assembly: it ships Ijwhost.dll and a
runtimeconfig.json pinning Microsoft.NETCore.App 8.0.0. The runner had NO .NET runtime at all —
pwsh 7 is a self-contained install and brings no shared runtime — so signtool exited 3 having
printed absolutely nothing. Installing the .NET 8 runtime turned that into a clean sign.

The client itself installs machine-wide under C:\trusted-signing rather than a user's .nuget,
because act_runner runs as SYSTEM, whose USERPROFILE is C:\Windows\System32\config\systemprofile.
A per-user install under Administrator is invisible to every job that actually builds. Confirmed by
resolving Find-AzureDlib from a SYSTEM scheduled task, which is also how the earlier SSH-only
attempts misled: over a network logon New-SelfSignedCertificate hits NTE_PERM, so a control test
that "fails" there proves nothing about how CI will behave.

Both downloads are SHA-256 pinned against version-immutable URLs (nuget.org flat-container and the
dotnet builds CDN), so they fail closed on tampering rather than on every Microsoft patch release —
unlike the BtbN `latest` pin above, which re-rolls. The .NET install uses Start-Process -Wait
because the bundle is a GUI PE that returns instantly under `&`, leaving $LASTEXITCODE unset and
racing the completion check (cost one false failure here).

End-to-end result on .133, as SYSTEM: sign rc=0, verify rc=0, chain Microsoft Identity Verification
Root CA 2020 -> ID Verified CS EOC CA 04 -> "unom - Enrico Buhler", leaf thumbprint
DD6A610F242CB5B2078C2A5D628699B6AB0CAC07 (matches the profile Azure reports), timestamped, leaf
expires in 3 days as expected. Signing an unsigned binary and reading the subject back reproduces
pack-msix.ps1's Publisher assertion exactly (match=True) — checked against a NON-catalog-signed
binary on purpose, because Get-AuthenticodeSignature on a catalog-signed system exe returns the
catalog signer and would have read as a false mismatch.
2026-08-14 19:05:21 +02:00
enricobuehler 6eb5edaff4 feat(audio): capture gain on punktfunk/1, and a soft knee instead of the clamp that made boosting a trap
android / android (pull_request) Failing after 1m5s
apple / swift (pull_request) Successful in 1m57s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m37s
ci / web (pull_request) Successful in 1m58s
ci / bun-nix (pull_request) Successful in 41s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m41s
ci / rust (pull_request) Successful in 21m14s
ci / rust-arm64 (pull_request) Failing after 3m56s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m50s
`PUNKTFUNK_AUDIO_GAIN` had two defects that compounded.

It existed only on the GameStream plane, so on native `punktfunk/1` it silently did
nothing — and since WASAPI loopback is tapped UPSTREAM of the endpoint's master volume,
turning the host's speaker slider up does not change the level a client receives either.
Between the two there was no host-side way at all to lift a quiet desktop mix on the
protocol that matters.

And where it did apply it was `(s * gain).clamp(-1.0, 1.0)` — a hard clip. Flat-topping a
waveform is a first-derivative discontinuity, which radiates harsh high-order harmonics, so
any operator who pushed past roughly 1.5x heard gross distortion long before reaching the
level they were chasing. A field report of "+18 dB and everything warbles" is the expected
output of that line, not a fault anywhere downstream of it.

`punktfunk_core::audio::apply_gain` replaces the clamp with a tanh soft knee above 0.7
(~-3.1 dBFS), chosen for three properties: C1-continuous where the branches meet (slope 1
on both sides, so the onset of limiting is not itself an audible event), bounded by
construction (asymptotic to 1.0, and +-inf maps to +-1.0, so nothing leaves out of range),
and odd-symmetric (benign harmonics, no DC). It is a memoryless waveshaper, so it costs
zero latency in the realtime encode path.

Unity is a no-op inside `apply_gain` itself, not merely at the call sites, so the default
wire stays byte-for-byte identical and a future caller that forgets to gate cannot quietly
bend every peak. `capture_gain` is now shared by both planes and rejects the two values
that are always typos: non-positive (would invert or mute) and above 8.0/+18 dB (capped,
and said out loud).

This buys headroom, NOT loudness. It cannot close a peak-to-loudness gap against
already-limited broadcast content; that needs a compressor with a real time constant, which
this deliberately is not, and the docs say so.

`SOFT_LIMIT_KNEE` is excluded from cbindgen: it is host-side capture processing that no C
embedder can act on, and exporting it would add a bare `#define` against the config's own
R21 rule. Verified by regenerating `include/punktfunk_core.h` — byte-identical, ABI 19
untouched.
2026-08-14 18:49:29 +02:00
enricobuehler 9dde564835 Merge pull request 'CRA Phase 1 closeout — vendor-CVE watch doc, retention verified, docs-site deps current' (#226) from worktree-cra-phase1-closeout into main
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 14s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
ci / docs-site (push) Successful in 1m9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 15s
docker / builders-arm64cross (push) Successful in 17s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m10s
docker / deploy-docs (push) Successful in 35s
ci / web (push) Successful in 5m55s
audit / c-abi-asan (push) Successful in 9m42s
audit / bun-audit (plugin-kit) (push) Successful in 2m7s
audit / bun-audit (sdk) (push) Successful in 14s
audit / bun-audit (web) (push) Successful in 15s
audit / docs-site-audit (push) Successful in 16s
audit / pnpm-audit (push) Successful in 9s
nix / flake (push) Successful in 13m53s
audit / cargo-audit (push) Successful in 5m42s
ci / rust (push) Successful in 20m6s
ci / rust-arm64 (push) Successful in 1m17s
audit / license-gate (push) Successful in 4m29s
audit / miri (push) Successful in 4m36s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
ci / bun-nix (push) Successful in 22s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 8s
2026-08-14 16:37:35 +00:00
enricobuehler b79ff45bd1 feat(windows): sign via Azure Artifact Signing — a 3-day leaf makes timestamping mandatory
Releases move from the self-signed CN=unom cert to Azure Artifact Signing (formerly Trusted
Signing): account `unomsigning`, profile `unom-io`, signed by the `punktfunk-ci-signing` service
principal, which holds only the Artifact Signing Certificate Profile Signer role scoped to that one
profile. Both pack scripts gain the backend ahead of the existing .pfx and ephemeral fallbacks, so
canary and fork builds are unaffected.

Three things that are easy to get wrong, and are handled here rather than discovered in the field:

Azure mints a leaf certificate per signing request that expires in about three days. Both scripts
previously retried WITHOUT a timestamp when a timestamped sign failed — under Azure that ships an
artifact which verifies on the runner and goes untrusted days later, on every user's machine at
once. The retry is now gated on the mode: still lenient for a .pfx whose cert outlives the release,
a hard failure for Azure.

The MSIX manifest Publisher must equal the signer subject byte-for-byte, because package identity is
Name + Publisher. The default is now the profile's verified subject, written with `[char]0xFC`
escapes rather than literal umlauts so this UTF-8-without-BOM file cannot silently mojibake the DN
into one that no longer matches. pack-msix.ps1 now also reads the signature back off the packed
.msix and fails on drift — asymmetric on purpose: a subject that disagrees is fatal, a subject that
cannot be read is only a warning, since Get-AuthenticodeSignature's .msix support varies by Windows
version and signtool has already reported success by then. NOTE this changes package identity, so
existing installs need an uninstall, not an upgrade.

The updater's leaf-pinning note was wrong and is corrected: update/windows.rs claimed the
AUTHENTICODE_SHA256 field made Trusted Signing "a manifest edit", but a per-request leaf is exactly
what a leaf pin cannot track — a pin would go stale within days and reject every release after it.

Drivers are deliberately untouched: their catalogs keep the DRIVER_CERT_* cert and the installer
still plants it as a machine root. The two signatures were always independent (SmartScreen/UAC vs
PnP), which is why the installer could move without them. Whether a publicly-trusted catalog would
let us drop that root plant is recorded as an unverified follow-up, not assumed.

Verified: both scripts parse under the PowerShell 7 AST parser, both workflows are valid YAML, the
evaluated Publisher default matches the subject Azure reports for the profile (86 chars, ordinal),
rustfmt clean. NOT verified on Windows — the sign path itself needs an on-glass run on .133.
2026-08-14 18:24:27 +02:00
enricobuehler b66bcef528 fix(screenshots): the macOS leg built the harness out of existence
The whole shot harness is #if DEBUG, and shoot_macos built -c release —
so the binary launched as the NORMAL app, never printed PF_SHOT_WINDOW,
and every scene 'never reported a window' while the script SIGKILLed a
perfectly healthy app. Build debug: SwiftUI has no release-only visuals,
and the harness actually exists there. All eight mac scenes capture now.
2026-08-14 18:23:38 +02:00
enricobuehler 42848c56b7 docs(compliance): the vendor-CVE watch the technical file will cite — and the SBOM learns we ship Bun
ci / rust-arm64 (pull_request) Successful in 1m33s
ci / bun-nix (pull_request) Successful in 1m33s
ci / web (pull_request) Successful in 2m5s
ci / docs-site (pull_request) Successful in 2m20s
ci / rust (pull_request) Successful in 5m58s
nix / flake (pull_request) Successful in 14m59s
compliance/vendored-components.md records, per vendored/bundled component,
where the pin lives, how it updates, and which feed to watch — the CRA
Art. 13(5) due-diligence evidence (S4 in the roadmap). Retention verified
while writing it: Gitea serves the full release history v0.17.x -> current,
stable sysext feeds publish KEEP=0, flatpak rsyncs without --delete.

The manual SBOM fragment gains the bundled Bun 1.3.14 runtime (portable
bun.exe in the Windows installer for the console + plugin runner — it was
in no lockfile and no SBOM) and stops hardcoding the gamescope patch count
at 3 when the series is at 9. SECURITY.md gets the one sentence Annex I
Part II asks for: security fixes are free, prompt, and ride patch releases
— which the stable channel already did, unwritten.
2026-08-14 18:14:32 +02:00
enricobuehler 39b9e9e276 chore(docs-site): current deps all around — the 67 leftover advisories all live inside @unom/ui's payload chain
bun update (fumadocs 16.14, tanstack ~1.170, react 19.2) plus @unom/ui 0.8.16
-> 0.9.2 and @unom/app-ui 0.1 -> 0.2.1. Build, tsc --noEmit and a served
smoke test all pass. The audit stays non-blocking: every remaining advisory
is pinned inside @unom/ui's own dependency tree (@payloadcms/* -> fast-uri/
image-size/sharp, next 16.x, sass -> immutable) — nothing bumpable from this
lockfile, and overrides would fork what the CMS actually ships. The comment
in audit.yml now names that blocker instead of the stale dompurify/node-tar
list.
2026-08-14 18:14:29 +02:00
enricobuehler 79114891df fix(vdisplay): exclusive topology left the KDE panel lit under a gamescope spawn — DPMS it dark
ci / bun-nix (pull_request) Successful in 1m42s
ci / web (pull_request) Successful in 3m41s
ci / rust (pull_request) Successful in 23m56s
ci / rust-arm64 (pull_request) Successful in 6m2s
android / android (pull_request) Successful in 7m12s
ci / docs-site (pull_request) Successful in 7m45s
A bare-spawn gamescope session is its own headless compositor, so it was the one
Linux route that never consulted effective_topology(): on a KDE desktop box the
physical panel kept showing the idle desktop for the whole stream while the
policy said exclusive. The KWin route's mechanism (disable the physicals) is
closed on this route — KWin refuses a configuration with zero enabled outputs
and no output on that desktop is ours to leave enabled — so the honest
translation is DPMS: the desktop stays exactly where it is, the panels go dark,
local input wakes them, and stream input never does (it enters gamescope's own
EIS socket, not KWin's libinput).

New kwin_dpms module drives the vendored org_kde_kwin_dpms protocol in-process
over the desktop's own Wayland (the kwin_output_mgmt stack and rationale), with
a kscreen-doctor --dpms fallback on kwin.rs's shared verdict/budget. The darken
is refcounted host-wide rather than floated through the registry's per-group
restore, because every gamescope spawn is its own group — the float alone would
re-light the panel when the first of two concurrent spawns ends. Each exclusive
spawn registers the release as its per-display topology restore, so the
registry still times every release (§6.1) and the last one out re-lights only
what the first darken actually turned off. Crash-safe by construction: DPMS is
non-persistent, so a dead host leaves nothing to journal — the panel re-lights
on the next local input.

Managed and Attach are deliberately untouched: managed's takeover already
stopped the desktop, and attach may be mirroring a gamescope that is itself
driving the physical panel.
2026-08-14 18:11:50 +02:00
enricobuehler d0a3eca7b8 Merge pull request 'A per-user Playnite install is invisible to a SYSTEM host, and one tile killed the whole library' (#225) from fix/playnite-launcher-resolve into main
arch / build-publish (push) Successful in 9m22s
deb / build-publish-host (push) Successful in 5m46s
ci / docs-site (push) Successful in 3m54s
deb / build-publish-gamescope (push) Successful in 33s
ci / rust-arm64 (push) Successful in 4m33s
ci / rust (push) Successful in 5m19s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / deploy-docs (push) Successful in 52s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
windows-host / package (push) Successful in 16m20s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 13s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m34s
windows-host / winget-source (push) Skipped
deb / build-publish (push) Successful in 4m0s
windows-host / canary-manifest (push) Successful in 38s
deb / smoke-install (push) Successful in 6m25s
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 25s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m28s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m3s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m5s
android / android (push) Successful in 7m28s
deb / build-publish-client-arm64 (push) Successful in 3m31s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m2s
ci / web (push) Successful in 1m4s
ci / bun-nix (push) Successful in 1m30s
docker / builders-arm64cross (push) Successful in 50s
2026-08-14 16:07:37 +00:00
enricobuehler f033d3f5df fix(screenshots): pin the shot palette — a reused device's saved choice shipped a sunset Apple TV set
The aurora screens read the LIVE uiPalette default, and shot mode never
forced one: the Apple TV Simulator had a sunset palette persisted from
manual use, so every tvOS capture came out pink-on-pale while the iPhone
set stayed violet. ScreenshotHostView now pins the palette (violet, or
PUNKTFUNK_SHOT_PALETTE) before the scene mounts.

Also documents the local tvOS-SIMULATOR wall in screenshots.sh: Xcode
26.6 and the 27 beta plan the macro targets swiftui-navigation-transitions
pulls in for the tvOS triple and never schedule their swift-syntax deps
('unable to resolve module dependency') — prebuilts on or off. Only the
tvOS target links that package, which is why iOS and device builds never
hit it. Local workaround, since HomeView's use is canImport-guarded:
temporarily unlink the product from the tvOS target, capture, restore.
2026-08-14 17:11:35 +02:00
enricobuehler bf741f8693 fix(library): a per-user Playnite install is invisible to a SYSTEM host, and one tile killed the library
ci / web (pull_request) Successful in 1m13s
ci / bun-nix (pull_request) Successful in 1m25s
ci / rust (pull_request) Successful in 4m18s
ci / docs-site (pull_request) Successful in 4m27s
ci / rust-arm64 (pull_request) Successful in 4m39s
android / android (pull_request) Successful in 7m42s
Syncing the Playnite plugin failed outright:

  PUT /library/provider/playnite failed: entries[9]: launch.value for kind
  launcher_ui names a launcher this host cannot open (playnite)

Two defects, and the second is why it cost every game rather than one tile.

1. The host looked for Playnite in the wrong registry hive and the wrong
   profile. `playnite_fullscreen_exe()` read HKEY_CURRENT_USER, then fell back
   to %LOCALAPPDATA% — but the Windows host is a LocalSystem service, so its
   HKCU is the SYSTEM hive (S-1-5-18) and its %LOCALAPPDATA% is
   C:\Windows\System32\config\systemprofile\AppData\Local. Playnite installs
   per-user by default, so both lookups miss on a default install. The doc
   comment reasoned correctly that Playnite is per-user and then read the one
   HKCU that cannot see it.

   It also hardcoded `…\Uninstall\Playnite`. Playnite ships an Inno Setup
   installer, and Inno registers `<AppId>_is1` — measured on a Windows box
   where Git and Inno itself appear as `Git_is1` and `Inno Setup 6_is1` — so
   that key matched nothing anywhere.

   Now: every loaded hive under HKEY_USERS plus both HKLM views, matched on
   DisplayName rather than key name, then `C:\Users\*\AppData\Local\Playnite`
   for the conventional install (and for a user whose hive is not loaded).

2. One unopenable tile 400'd the whole reconcile. The Playnite plugin appends
   a single launcher tile beside its games, so refusing the payload cost the
   operator the entire library — the same shape as the unservable-cover bug
   that sanitize_art_paths was introduced to fix, on the launch side this time.

   `valid_launcher_ui` conflated two different failures. Split into
   `known_launcher_ui` (vocabulary — a plugin bug, still a hard 400, because
   the author has no other way to find out) and `resolvable_launcher_ui`
   (environment — the launcher just is not installed here, which is a fact
   about the box). `sanitize_launcher_entries` drops only the latter, with one
   warn, and the games sync.
2026-08-14 15:28:41 +02:00
enricobuehler e3443da108 fix(screenshots): the store frames go landscape — the app is built for horizontal use
Portrait captures show a layout nobody streams in. The touch controllers
frame and the library shot now render at landscape phone geometry (the
portrait library variant is gone), a console-controllers-landscape frame
joins the set, and the Apple 12-controllers scene rotates: on the
landscape canvas the two pads sit as side-by-side columns — one
ControllerTestView per pad — so neither story is cut by the short height.

Known wart, deliberate: the console landscape frame's floating legend
overlaps the second pad card mid-scroll; the styled composite crops above
it, and the touch variant carries the uncropped two-card view.
2026-08-14 15:07:23 +02:00
enricobuehler 5a4dd7423e feat(screenshots): the Apple controller panel renders a value model, so the harness can inject pads
ControllerTestView drew straight from GCController/GCExtendedGamepad, and a
GCController cannot be constructed — the store plan's FEEL THE GAME frame
had no Apple scene. Every card now renders plain values (ShotPad,
InputSnapshot): the live path flattens the active DiscoveredController and
samples the pad into a snapshot on each 30 Hz tick, the screenshot harness
hands the panel pads that were never connected via a default-nil shotPads
parameter (the seam Android's ControllersScreen grew in 0a468c96). Live
behavior is unchanged — same cards, same order, same live feeds.

The 12-controllers scene injects the two pads the listing names — the
DualSense leading with the feedback surface (adaptive-trigger effects,
rumble backend, lightbar + player LEDs), the Xbox pad carrying the input
readout frozen mid-game; transport/battery/player ride in the header's
detail line because the panel has no dedicated battery row. Registered in
the iOS/macOS block and the store set only: ControllerTestView does not
build on tvOS, so the tvOS CI scene list is untouched.
2026-08-14 15:01:20 +02:00
enricobuehler 0a468c96da feat(screenshots): the two missing marketing frames — the library shelf and pads that exist
The store plan's PICK & PLAY and FEEL THE GAME shots had no scene on any
platform: the library screen's state comes off the network, and the
controllers screens enumerate InputDevices, of which Robolectric has none
(the old shot honestly said 'no controller detected' — a palette proof
that sells nothing).

- Android library: Coverflow goes internal and LibraryScene rebuilds the
  real shell around it (aurora, header, hint bar) with a mock shelf.
  Cover art is answered synchronously by coil-test's FakeImageLoaderEngine
  with generated gradient posters, so the frozen animation clock never
  races an async load. Shot at phone portrait+landscape and TV geometry.
- Android controllers: PadRow renders a PadInfo model instead of a raw
  InputDevice (padInfoOf maps real devices; both screens take a
  padsOverride). The scenes inject the two pads the listing names —
  DualSense (player 1) and Xbox (player 2), real VID:PIDs.
- Apple library: ShotLibrary composes the real LibraryCoverflowView with
  a JSON-decoded mock shelf (GameEntry's memberwise init is internal to
  PunktfunkKit; Codable is the public construction surface), registered
  as cross-platform scene 11-library and added to the store set + the
  tvOS CI scene list. Artless entries settle to their deterministic
  fallback posters, which is also what keeps the shot offline.

Apple controllers stays a follow-up: ControllerTestView binds to live
GCController hardware and has no injection surface yet.

Verified: all 31 Roborazzi scenes render; the new tv-library,
phone-library and controllers shots reviewed by eye.
2026-08-14 14:38:08 +02:00
enricobuehler ea5afbaa8c Merge pull request '0.28.1 notes — the Mac microphone loop is a headline fix the notes had never heard of' (#224) from worktree-release-0281-notes into main
decky / build-publish (push) Successful in 43s
arch / build-publish (push) Successful in 7m39s
ci / rust-arm64 (push) Successful in 6m17s
windows-host / package (push) Successful in 13m46s
windows-host / canary-manifest (push) Skipped
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 23m15s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 23m25s
windows-host / winget-source (push) Successful in 20s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m31s
android / android (push) Successful in 6m39s
ci / web (push) Successful in 1m9s
ci / docs-site (push) Successful in 1m31s
linux-client-screenshots / screenshots (push) Successful in 3m50s
sbom / sbom (push) Successful in 22s
deb / build-publish-host (push) Successful in 6m58s
deb / build-publish (push) Successful in 7m11s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 21s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 23s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 14s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 22s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 18s
docker / builders-arm64cross (push) Successful in 13s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 24s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 23s
deb / build-publish-client-arm64 (push) Successful in 1m24s
deb / build-publish-gamescope (push) Successful in 1m30s
docker / deploy-docs (push) Successful in 20s
android-screenshots / screenshots (push) Successful in 2m2s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 6m58s
ci / rust (push) Successful in 5m30s
apple / swift (push) Successful in 2m4s
web-screenshots / screenshots (push) Successful in 4m45s
flatpak / build-publish (push) Successful in 8m47s
apple / distribute (push) Successful in 12m34s
deb / smoke-install (push) Successful in 10m14s
ci / bun-nix (push) Successful in 5m52s
apple / screenshots (push) Successful in 8m59s
2026-08-14 11:26:53 +00:00
enricobuehler 832a5ffd8d docs(release): the Mac microphone loop is a headline fix, and the notes had never heard of it
ci / rust-arm64 (pull_request) Successful in 3m5s
ci / web (pull_request) Successful in 2m50s
ci / bun-nix (pull_request) Successful in 38s
ci / docs-site (pull_request) Successful in 2m19s
ci / rust (pull_request) Successful in 6m56s
Ten more commits landed after the 0.28.1 release commit — the deb image fix, the
two macOS audio ones (#221 + #223) and the TV screenshot automation — so the
release paperwork no longer described the release.

CHANGELOG: 50 -> 60 commits since v0.28.0. Nothing else moves; the version table
is unchanged on every row, re-verified against the tag (`include/`,
`crates/pf-driver-proto`, `plugin-kit/package.json` and `sdk/` are all still
byte-identical to v0.28.0, so the C ABI stays 19). #221 brought its own CHANGELOG
section, so the technical half already covered it.

NOTES: the user-facing file had no mention of the macOS fault at all, and it is
headline-grade — streaming from a Mac with the mic on cut audio AND froze input
on a ~2.5 s metronome, with turning the microphone off as the only workaround. It
now leads the summary paragraph, has a TL;DR line and a full Fixed entry
explaining the loop in plain terms (a mic that cannot run echo cancellation, each
failed attempt knocking out the working path and thereby triggering the next).

The TL;DR was also trimmed from nine multi-line bullets to seven one-liners.
`docs/releases/README.md` asks for 3-6, and this release has an unusual number of
genuinely severe entries — seven is the honest floor without hiding one, and the
long-form detail was already duplicated below in Fixed, which is where it belongs.
The Apple stats-overlay and Apple TV colour bullets lost their TL;DR slots and
keep their Fixed entries.

`SessionAudio.start()` being asynchronous on macOS is added to the notes' `For
developers` paragraph — it is the one embedder-visible edge in #223, and an
embedder who only reads the notes would otherwise meet it at runtime.

Play notes are untouched and still accurate: the only commit to touch
clients/android since is `b6b3c10c`, which is screenshot CI, not app behaviour.

Gates on this tree: fmt clean, `cargo metadata --locked` consistent,
`cargo test -p punktfunk-core` 210 passed, C ABI harness abi_version=19,
`api/openapi.json` and the docs-site copy still byte-identical.
2026-08-14 13:15:08 +02:00
enricobuehler 76c677a8f8 Merge pull request 'The TV storefronts were the only ones with no automated screenshot captures' (#222) from worktree-store-shots-tv-automation into main
apple / screenshots (push) Successful in 8m26s
ci / rust-arm64 (push) Successful in 6m15s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 24s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 17s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 18s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / builders-arm64cross (push) Successful in 17s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 57s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
ci / docs-site (push) Successful in 8m18s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m49s
docker / deploy-docs (push) Successful in 14s
ci / web (push) Successful in 9m5s
android / android (push) Successful in 12m53s
apple / distribute (push) Successful in 10m48s
ci / rust (push) Canceled after 14m52s
apple / swift (push) Successful in 1m57s
ci / bun-nix (push) Successful in 43s
Reviewed-on: #222
2026-08-14 11:11:46 +00:00
enricobuehler 7cb70bf6ea Merge pull request 'Apple audio engine starts leave the main thread — input never waits on the audio server' (#223) from worktree-macos-mic-rebuild-loop into main
apple / swift (push) Canceled after 18s
apple / screenshots (push) Canceled after 0s
apple / distribute (push) Canceled after 0s
ci / rust (push) Canceled after 38s
ci / rust-arm64 (push) Canceled after 14s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 5s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
2026-08-14 11:05:55 +00:00
enricobuehler b6b3c10cb5 ci(screenshots): the TV storefronts were the only ones with no automated captures
ci / bun-nix (pull_request) Successful in 1m49s
ci / rust-arm64 (pull_request) Successful in 4m41s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 3m53s
apple / swift (pull_request) Successful in 2m3s
ci / docs-site (pull_request) Successful in 5m11s
android / android (pull_request) Successful in 11m24s
ci / rust (pull_request) Failing after 10m14s
Google Play's Android TV slot needs 16:9 1920x1080 shots and the App Store
needs Apple TV 1920x1080 — neither existed as automation output:

- apple.yml screenshots job now runs the tvos leg. The harness supported it
  all along (tools/screenshots.sh tvos); what the job was missing is the
  Tier-3 tvOS xcframework slices (nightly + -Zbuild-std, same recipe the
  distribute job uses on this runner) and an explicit scene list — the
  gamepad-console scenes are compiled out on tvOS, and an UNKNOWN scene
  name falls back to a normal app launch, which would silently capture the
  real empty app. Still best-effort: a tvOS hiccup warns, never reds.
- TvScreenshotTest renders the console scenes + the stream HUD at Android
  TV geometry (w960dp-h540dp-television-xhdpi = native 1920x1080, no
  resampling), prefixed tv- so the artifact separates the form factors.
  Verified locally: 6 scenes, all 1920x1080.

android-screenshots.yml needs no change — it runs the whole unit-test task
and uploads the whole roborazzi output dir.
2026-08-14 12:55:03 +02:00
enricobuehler 1a8fa2282f fix(apple): engine starts leave the main thread — input never waits on the audio server
ci / rust (pull_request) Successful in 5m26s
apple / swift (pull_request) Successful in 1m57s
apple / screenshots (pull_request) Skipped
apple / distribute (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 5m7s
ci / bun-nix (pull_request) Successful in 2m11s
ci / web (pull_request) Successful in 3m32s
ci / docs-site (pull_request) Successful in 3m32s
An AVAudioEngine start can block on the audio server for seconds (~1.9 s
per attempt in the 2026-08-14 field case), and macOS captures and sends the
stream's input from the main thread — so every device-change rebuild, loop
or no loop, froze the stream's input for the length of the rebuild, and a
mic-on session start stalled the UI at connect.

All engine lifecycle work (start/startEngines and below, teardown, rebuild)
now runs on a per-session serial engineQueue; the main queue keeps only the
trigger bookkeeping — debounce, backoff, and the retry ladder — which is
cheap by construction. The rebuild path splits accordingly: rebuildFire
(main: bookkeeping, reads the config) → performRebuild (engineQueue: the
actual teardown + start) → rebuildFailed (main: ladder scheduling; a fresh
trigger already queued wins over a retry).

Confinement moves with the work: ring, startConfig and enginesAttempted go
under the existing stateLock (start paths write on engineQueue, stats and
the revive gate read elsewhere); combinedGate is engineQueue-confined; the
permission-grant continuation lands on engineQueue instead of main. The
engines were already lock-guarded and stopped cross-thread by stop(), and
every start path already re-checks the stop flag after publishing, so the
in-flight-start-vs-stop race keeps its existing resolution.

Embedder-visible edge: SessionAudio.start() is now asynchronous on macOS
too (it always was on iOS/tvOS) — playback is live shortly after the call,
not on return; stats is safe from any thread.

Gates: swift build + 295 tests 0 failures (macOS), full-package
arm64-apple-ios17.0 typecheck.
2026-08-14 12:49:48 +02:00
enricobuehler d669064dc0 Merge pull request 'The macOS device-change recovery answered itself — mic-on streams cut audio and input every ~2.5 s' (#221) from worktree-macos-mic-rebuild-loop into main
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m43s
ci / web (push) Successful in 3m20s
docker / deploy-docs (push) Successful in 37s
apple / screenshots (push) Canceled after 3m35s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 9m4s
ci / docs-site (push) Successful in 3m38s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7m25s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 7m12s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 4m36s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7m26s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 5m46s
apple / distribute (push) Successful in 10m35s
ci / bun-nix (push) Successful in 17s
ci / rust (push) Successful in 6m11s
docker / builders-arm64cross (push) Successful in 2m57s
apple / swift (push) Successful in 2m4s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 48s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m12s
ci / rust-arm64 (push) Successful in 3m5s
Reviewed-on: #221
2026-08-14 10:49:37 +00:00
enricobuehler d4ad8be6bf Merge pull request 'The gamescope deb image never needed x11-xcb until we started building the WSI layer' (#220) from worktree-gamescope-deb-x11xcb into main
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
ci / rust (push) Canceled after 16s
ci / web (push) Canceled after 18s
ci / bun-nix (push) Canceled after 19s
ci / rust-arm64 (push) Canceled after 16s
ci / docs-site (push) Canceled after 19s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 27s
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/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
Reviewed-on: #220
2026-08-14 10:49:24 +00:00
enricobuehler e0c10bad85 fix(apple): the macOS device-change recovery answered itself — mic-on streams cut audio and input every ~2.5 s
ci / bun-nix (pull_request) Successful in 22s
ci / docs-site (pull_request) Successful in 1m15s
ci / rust-arm64 (pull_request) Successful in 1m17s
ci / web (pull_request) Successful in 1m25s
apple / swift (pull_request) Successful in 2m6s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Successful in 4m33s
The voice-processing engine cannot start on some input devices (field case:
a 6-channel interface — 'combined engine failed to start', every time). The
device-change recovery re-tried it on every rebuild, and the failed attempt's
HAL churn (VPIO builds and tears down an aggregate device) stopped the healthy
fallback engines, which posted the AVAudioEngineConfigurationChange that
scheduled the next rebuild: a self-sustaining ~2.5 s loop for the session's
whole life. Each ~1.9 s rebuild runs on the main thread — where macOS input
capture and sending live — so the stream's INPUT cut out on the same beat,
while video (own socket, own threads) ran untouched; the wire signature
matched network loss and the host's METRONOMIC heuristic pointed at the
display stack, which is what made the field report so misleading.

Three defenses, layered because no single one covers every feedback shape:
a VPIO start failure latches per input device (CombinedTopologyGate — a
rebuild goes straight to the split topology; a different default input earns
exactly one fresh attempt); a configuration change posted by an engine that
is RUNNING is the rebuild's own echo and is ignored (an engine stops itself
before posting, so a live poster was already restarted); and rebuilds that
chain anyway back off exponentially (RebuildBackoff, 0.5 s floor doubling to
a 30 s cap, reset by 10 s of quiet) with a WARN that names the condition.

Both policies extracted to AudioRebuildPolicy.swift where a unit test can
reach them: 7 new tests, the loop test plant-the-defect verified (the shipped
flat floor produces 800 rebuilds in the 10-minute sim; the ladder ≤ 25, and
responsiveness after quiet is asserted). iOS/tvOS semantics untouched.

Gates: swift build + 295 tests 0 failures (macOS), full-package
arm64-apple-ios17.0 typecheck.
2026-08-14 12:40:51 +02:00
enricobuehler 1b28a7f7f1 fix(ci): the gamescope deb image never needed x11-xcb until we started building the WSI layer
ci / docs-site (pull_request) Successful in 1m28s
ci / bun-nix (pull_request) Successful in 2m57s
ci / web (pull_request) Successful in 3m9s
ci / rust-arm64 (pull_request) Successful in 5m17s
ci / rust (pull_request) Successful in 8m33s
The v0.28.1 deb leg failed for real, and the package it costs is the whole
punktfunk-gamescope .deb:

    gamescope/layer/meson.build:3:14: ERROR: Dependency "x11-xcb" not found, tried pkgconfig

Not a flake and not the pin. v0.28.1 flipped
`-Denable_gamescope_wsi_layer=true` in build-punktfunk-gamescope.sh (it was off
before, on the recorded and false premise that the layer is version-independent
of the compositor). The layer is a separate meson subdir with its own dependency
set, and it wants x11-xcb — which the compositor never did. So an image that had
been sufficient for every previous release stopped being sufficient the moment
the layer started building, and nothing named the new dep anywhere.

Debian is the only channel that has to name it: Arch's libx11 and Fedora's
libX11-devel both ship x11-xcb.pc themselves, which is why arch.yml and rpm.yml
build the same tree fine and only the trixie image came up short.

Asserted as well as installed. The image already asserts the wayland-server
floor at build time, on the argument that the one version deciding whether the
image can do its job should fail loudly HERE rather than inside a deb.yml run —
and this is the same class, only worse: a missing x11-xcb does not fail the
compositor build, it fails the layer's, and the layer is the only route to an
HDR10 swapchain for a nested game. Losing it silently produces a package that
looks completely healthy and denies every game HDR, which is precisely the
failure v0.28.1 exists to end. The assertion means the next dependency the layer
grows fails at image build instead of mid-release.

ORDERING, for whoever lands this: docker.yml rebuilds the image on a push to
main (its key hashes the ci/ tree, so this change busts it), and deb.yml's
gamescope job consumes `:latest`. Let the image publish before the deb job that
needs it runs — on a release cut that means merging this, letting docker.yml
finish, and only then pushing the tag. The failed job saved no cache, so the tag
run rebuilds against the new image rather than restoring the broken state.

NOT verified locally: no Docker on this machine, so the image was not built and
the layer was not compiled here. The package name is confirmed against Debian's
own package index (libx11-xcb-dev ships x11-xcb.pc, and exists in trixie), and
the assertion added here is what proves it in CI — if the name were wrong the
image build fails loudly instead of the deb leg failing quietly.
2026-08-14 12:03:55 +02:00
enricobuehler ceb081f045 Merge pull request '0.28.1' (#219) from worktree-release-0281 into main
audit / bun-audit (web) (push) Successful in 48s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 33s
audit / c-abi-asan (push) Successful in 7m51s
docker / deploy-docs (push) Successful in 33s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 46s
apple / distribute (push) Successful in 11m17s
audit / pnpm-audit (push) Successful in 1m49s
deb / build-publish (push) Successful in 4m36s
deb / build-publish-host (push) Successful in 5m11s
deb / build-publish-client-arm64 (push) Successful in 3m22s
apple / screenshots (push) Successful in 6m51s
docker / builders-arm64cross (push) Successful in 19s
arch / build-publish (push) Successful in 8m11s
deb / build-publish-gamescope (push) Failing after 1m41s
ci / rust-arm64 (push) Successful in 1m50s
audit / miri (push) Successful in 5m53s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
deb / smoke-install (push) Successful in 2m16s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 6m47s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
ci / web (push) Successful in 1m6s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 15s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m6s
ci / bun-nix (push) Successful in 35s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 1m20s
windows-host / package (push) Successful in 13m7s
windows-host / winget-source (push) Skipped
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 1m23s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m23s
audit / cargo-audit (push) Successful in 1m37s
android / android (push) Successful in 11m50s
windows-host / canary-manifest (push) Successful in 28s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m21s
ci / docs-site (push) Successful in 1m22s
flatpak / build-publish (push) Successful in 10m34s
apple / swift (push) Successful in 1m56s
audit / bun-audit (plugin-kit) (push) Successful in 1m12s
audit / license-gate (push) Successful in 7m31s
ci / rust (push) Successful in 20m19s
audit / bun-audit (sdk) (push) Successful in 1m9s
nix / flake (push) Failing after 16m12s
audit / docs-site-audit (push) Successful in 20s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m27s
Reviewed-on: #219
2026-08-14 09:24:20 +00:00
enricobuehler 0870f81148 release: 0.28.1 — version bump, notes, CHANGELOG, Play notes
ci / bun-nix (pull_request) Successful in 58s
ci / web (pull_request) Successful in 6m9s
ci / docs-site (pull_request) Successful in 6m22s
ci / rust (pull_request) Successful in 14m57s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m39s
nix / flake (pull_request) Successful in 15m55s
ci / rust-arm64 (pull_request) Successful in 9m42s
apple / swift (pull_request) Successful in 2m0s
apple / screenshots (pull_request) Skipped
apple / distribute (pull_request) Skipped
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m6s
android / android (pull_request) Successful in 5m48s
50 commits since v0.28.0 (32 non-merge). Cut from origin/main f8361f3e.

THE NUMBER: 0.28.1 is defensible but not free. Three `feat(...)` commits landed
since the tag — the "unpair all" button and its two endpoints, the Apple
gamepad-UI host menu, and the tvOS present-floor levers. That is not the shape
of v0.28.0's cut (17 feats, a packager-visible default flip, an MSRV rise and a
deletion that empties the library grid), and none of the three changes a
contract: every one is additive, and the version table is unchanged on every row
an embedder, packager or driver author reads. `scripts/ci/pf-version.sh` derives
the canary base as latest-stable + one minor, so 0.28.1 and 0.29.0 both leave
canary on 0.29.x and neither collides.

NOTHING BREAKS, and this was measured rather than assumed, twice — before and
after the four late PRs. `include/` is byte-identical to the v0.28.0 tag, so the
C ABI stays 19; `crates/pf-driver-proto`, `plugin-kit/package.json` and `sdk/`
show no diff against the tag at all. The one Rust-visible change is an addition:
`punktfunk_core::client::FLUSH_COOLDOWN` went `pub(crate)` -> `pub`, so the host
can compare against the constant instead of a copy of the number.

ONE DEFECT FOUND AND FIXED WHILE PREPARING:

`docs-site/public/openapi.json` had drifted for the THIRD time in two release
cycles. It was still stamped 0.27.0 and missing both new collection deletes,
while `api/openapi.json` sits at 0.28.0. v0.28.0 fixed this once (it was five
releases stale at 0.21.0) and it drifted again inside that same cycle. Re-synced;
the two files are byte-identical again, and re-checked after the rebase. The copy
is a documented manual step (CONTRIBUTING.md) that nothing in CI enforces — three
drifts is the argument for gating it, and that gate is not in this commit.

CHANGELOG: the in-development section carried four topics and the late PRs
brought four more of their own; the remaining twenty-one commits had none. Added
the version table (every row measured, not copied forward), an explicit empty
breaking-changes verdict, and sections for the management API's two collection
deletes, the Hyprland/Sway cursor-mode negotiation, the gamescope WSI layer we
now ship ourselves, the 203-nit SDR anchor, the Apple stats/colour faults, the
Skia loader-version regression, the AV1 level sentinel, the stats stage-line
partition, the two host warnings that named the wrong subsystem, and the
docs-site openapi drift.

NOTES: `docs/releases/v0.28.1.md` follows the post-v0.25.0 split — user-facing
only, TL;DR first, internals left to the CHANGELOG link, which points at the
v0.28.1 TAG rather than main. The two Windows headliners lead it: the Steam
add-on publishing nothing (a 0.28.0 regression that emptied the grid) and an idle
host wrecking a locally played game. `Before you update` carries the two
Sound-settings changes an operator will see and could read as defects, plus the
0.27-and-older pointer at v0.28.0's action items.

luxus is credited three times: in the lead-in the Discord embed shows, inline on
the fix itself, and in a new `## Thanks` section — the linger crash was his find,
his patch and his on-glass proof, and it ships as he wrote it. The CHANGELOG
keeps its own credit with the overlay#9 link.

Play notes are 436 characters against the 500 cap and cover only what changed in
the Android app, which this release is still one commit of.

GATES, all green on this tree after the rebase: `cargo fmt --all --check` clean;
`cargo metadata --locked` consistent; Cargo.lock diff is versions-only, 36/36
lines, zero non-version lines against the new base; `cargo test -p punktfunk-core`
210 passed; the C ABI harness passes printing abi_version=19 (needs
`LIBRARY_PATH=/opt/homebrew/opt/opus/lib` on macOS — a link path, not a defect);
the repo pre-push hook exits 0.
2026-08-14 11:22:08 +02:00
enricobuehler f8361f3e6f Merge pull request 'The NixOS module started a second host in root's systemd, which stole the ports from the real one' (#218) from worktree-nixos-module-user-scoping into main
docker / deploy-docs (push) Canceled after 3m33s
ci / web (push) Successful in 1m10s
docker / builders-arm64cross (push) Successful in 9s
ci / rust-arm64 (push) Successful in 1m25s
nix / flake (push) Canceled after 4m8s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
ci / bun-nix (push) Successful in 15s
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 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (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 9s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 15s
ci / rust (push) Canceled after 6m29s
ci / docs-site (push) Canceled after 5m54s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 15s
Reviewed-on: #218
2026-08-14 09:17:54 +00:00
enricobuehler e8bc10bf0c Merge remote-tracking branch 'origin/main' into worktree-nixos-module-user-scoping
ci / rust-arm64 (pull_request) Successful in 3m45s
ci / docs-site (pull_request) Successful in 3m49s
ci / bun-nix (pull_request) Successful in 24s
ci / rust (pull_request) Successful in 18m12s
nix / flake (pull_request) Successful in 15m35s
ci / web (pull_request) Successful in 1m1s
# Conflicts:
#	CHANGELOG.md
2026-08-14 11:16:49 +02:00
enricobuehler 4499313749 fix(nix): the module started a second host in root's systemd, stealing the ports from the real one
ci / docs-site (pull_request) Successful in 1m17s
ci / bun-nix (pull_request) Successful in 1m33s
nix / flake (pull_request) Failing after 1m23s
ci / rust (pull_request) Canceled after 1m54s
ci / rust-arm64 (pull_request) Canceled after 2m3s
ci / web (pull_request) Successful in 1m8s
`systemd.user.*` has no per-user form in NixOS — it installs units into every
user's manager. With `host.autoStart` adding them to `default.target`, that
included root, whose `user@0.service` exists the moment anybody SSHes in as
root. Root's host won the race for the fixed ports and the desktop user's copy
crash-looped forever on `bind RTSP 48010: Address already in use`.

Every other listener binds first and logs success, so the log reads like a
clash with an unrelated program; a second copy of itself running as root is the
last thing you look for. `host.users` did not help — it only granted
input/punktfunk group membership and never scoped the units.

Render `ConditionUser=` on all four user units from `host.users`. Entries are
written `|user`: the pipe makes each a triggering condition, which systemd ORs,
where plain repeated `ConditionUser=` lines are ANDed and would match nobody.
With `host.users` empty, fall back to `!@system` — still keeps root out while
leaving the manual `systemctl --user enable --now` route working for a login.

module-check.nix gains three assertions covering both branches and web-init
keeping its non-triggering ConditionPathExists alongside the new condition.
They run in nix.yml's eval leg, and were confirmed to fail against the unfixed
module (2 of 23) before being committed. Verified on the box that found this:
root force-starting the host now yields ConditionResult=no.
2026-08-14 11:00:43 +02:00
enricobuehler 784f880fbf Merge pull request 'Helldivers 2 tanked on an IDLE host: the pad DualSense speaker stayed visible and the recording default was parked forever' (#217) from worktree-hd2-idle-recording-default into main
deb / smoke-install (push) Successful in 9m33s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
ci / rust (push) Successful in 18m45s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m52s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 47s
deb / build-publish (push) Successful in 4m49s
ci / rust-arm64 (push) Successful in 1m28s
deb / build-publish-host (push) Successful in 5m18s
ci / bun-nix (push) Successful in 27s
ci / web (push) Successful in 1m45s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m3s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m11s
arch / build-publish (push) Failing after 2m47s
ci / docs-site (push) Successful in 1m55s
deb / build-publish-client-arm64 (push) Successful in 5m23s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 1m39s
deb / build-publish-gamescope (push) Failing after 1m48s
docker / builders-arm64cross (push) Successful in 33s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 10s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
windows-host / package (push) Successful in 13m9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
windows-host / winget-source (push) Skipped
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
windows-host / canary-manifest (push) Successful in 20s
android / android (push) Successful in 13m47s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 10s
docker / deploy-docs (push) Failing after 6m58s
2026-08-14 08:41:12 +00:00
enricobuehler 8ca4c6eb0e Merge pull request 'Hyprland/Sway black client — the wlr-family backends asserted a cursor mode instead of negotiating it' (#216) from worktree-hyprland-cursor-mode-negotiation into main
deb / build-publish (push) Canceled after 1m17s
docker / deploy-docs (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 37s
deb / build-publish-gamescope (push) Canceled after 19s
deb / build-publish-client-arm64 (push) Canceled after 0s
deb / smoke-install (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 1s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (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 12s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
windows-host / package (push) Canceled after 1m53s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
arch / build-publish (push) Failing after 33s
ci / web (push) Successful in 1m11s
ci / rust-arm64 (push) Successful in 1m21s
android / android (push) Canceled after 1m50s
ci / rust (push) Canceled after 1m37s
ci / docs-site (push) Canceled after 1m42s
ci / bun-nix (push) Canceled after 1m42s
2026-08-14 08:39:18 +00:00
enricobuehler 13aa59c575 fix(vdisplay): the wlr-family backends asserted a cursor mode instead of negotiating it, so the portal refused the call
ci / bun-nix (pull_request) Successful in 24s
ci / docs-site (pull_request) Successful in 1m13s
ci / web (pull_request) Successful in 3m27s
android / android (pull_request) Successful in 4m2s
ci / rust-arm64 (pull_request) Successful in 4m40s
ci / rust (pull_request) Successful in 10m47s
Hyprland and wlroots both hardcoded portal `CursorMode::Metadata` whenever the
session had negotiated the cursor channel, and never asked the backend what it
supports. That is not a soft failure: xdg-desktop-portal's FRONTEND validates the
requested mode against the backend's `AvailableCursorModes` and fails the call
with `"Unavailable cursor mode %x"` before the backend ever sees it.

So a cursor-forward session (desktop mouse mode) died at `select_sources`,
surfacing as "pipeline build failed" and a black client, with
`unavailable cursor mode 4` in the portal log. Field report 2026-08-14.

MEASURED on .21 the same day, and it is worse than the report suggested: against
a LIVE Hyprland 0.56.2 with xdg-desktop-portal-hyprland 1.4.1 and
xdg-desktop-portal 1.22.1 — all current — `AvailableCursorModes` reads **3**
(Hidden|Embedded) on both the backend impl interface and the frontend. xdph does
not offer the metadata cursor at all, so this broke EVERY cursor-forward session
on current Hyprland, not merely on old installs. Updating the portal would not
have helped. xdpw is the same from the other end: its screencast.c refuses
METADATA outright.

pf-capture's own portal path has always negotiated (`choose_cursor_mode`); this
restates that ladder in pf-vdisplay, which may not depend on pf-capture. The
downgrade is graceful rather than merely survivable: with the portal on Embedded
no `SPA_META_Cursor` arrives, so the host feeds the cursor channel nothing and a
cursor-forward client draws nothing of its own — one pointer, not two.

`PUNKTFUNK_PORTAL_CURSOR_MODE=auto|hidden|embedded|metadata` pins the preference
for a backend that advertises a mode it implements badly, which negotiation
cannot detect. It is a preference only: pins run the same ladder, so no value can
re-create the refused request.

The module is declared unconditionally so its ladder tests run on every CI leg
rather than only the one that compiles `mod hyprland` — including a Linux-only
test pinning our bit values against ashpd's enum, verified non-vacuous by
planting a wrong discriminant (ashpd answers 4 for Metadata, the number in the
report). The regression test uses 3, the bitfield measured on glass. Linux: 225
tests pass, clippy --all-targets -D warnings clean.
2026-08-14 10:27:14 +02:00
enricobuehler 652de8b5e0 docs(changelog): the pad-audio DualSense speaker hides while no client pad is attached
ci / bun-nix (pull_request) Successful in 33s
ci / rust-arm64 (pull_request) Successful in 1m21s
ci / docs-site (pull_request) Successful in 1m14s
android / android (pull_request) Successful in 4m21s
ci / rust (pull_request) Successful in 8m45s
ci / web (pull_request) Successful in 6m32s
2026-08-14 10:26:16 +02:00
enricobuehler ec36597058 fix(audio/windows): the pad-audio DualSense speaker hides while no client pad is attached — idle libScePad titles stalled on it
The per-pad endpoint is stamped to be indistinguishable from a real
DualSense speaker — that is the feature during a pad session (libScePad
titles route haptics audio at it) and a trap the rest of the time: the
endpoint is pre-provisioned at EVERY host start and stayed visible
forever, so an idle Helldivers 2 found it by identity, engaged its
DualSense-haptics path against a device nothing services, and dropped to
2–5 FPS 1% lows — host idle, no controller plugged in, no session ever
run (field-confirmed 2026-08-14: the reporter isolated the 'DualSense
speaker' and disabling it in mmsys.cpl restored full performance).

That manual remedy is now automatic: the endpoint parks HIDDEN
(DEVICE_STATE_DISABLED, IPolicyConfig::SetEndpointVisibility — the call
behind mmsys.cpl's own Disable, vtable slot pinned next to the
SetDefaultEndpoint we already bind) whenever no client pad is attached.
Provisioning hides it at startup, a PUNKTFUNK_PAD_AUDIO=0 host hides
leftovers from earlier runs, and the per-pad streamer shows it for
exactly the pad's lifetime — to a game, a DualSense arriving and
leaving. The devnode, driver binding and stamps stay put (registry-based
resolution finds a disabled endpoint at the next boot), so the flips
raise no PnP traffic and the expensive provisioning still happens once
at boot — the #185 lesson holds.

Devtest: pad-endpoint grew show/hide verbs; tone/capture need a show
first on a parked box.
2026-08-14 10:26:13 +02:00
enricobuehler e5c0d6b4eb docs(changelog): an idle Windows host no longer owns the box's default microphone 2026-08-14 10:12:00 +02:00
enricobuehler 0bba8d7f8c fix(audio/windows): the default recording device is session-scoped now — an idle host parked every game's voice input on a dead virtual mic
The wiring pass asserted 'default recording = virtual mic capture' on EVERY
pass — including the mic pump's eager boot pass — so an idle box permanently
held the Windows default recording device (and, since SetDefaultEndpoint
covers eCommunications, every game's voice input) on a virtual microphone
whose render feeder is idle-stopped, with no restore path at all: not at
session end, not at service stop. Field-measured 2026-08-14: Helldivers 2
(Wwise + always-on voice) played LOCALLY on an idle host tanks to 2–5 FPS 1%
lows, and mmsys.cpl's own Recording tab goes unresponsive polling the same
endpoint; the reporter's Sound settings showed 'Punktfunk Microphone —
Dispositivo predefinito' with the host idle.

The recording default now follows the exact discipline the playback default
has always had — parked only while a desktop-audio capture is open, with the
operator's device remembered (in memory + an on-disk crash marker,
audio-default-rec.prev), restored on capture close, recovered after a crash
on the next boot's first wiring pass, and unparked by the uninstaller. A
game launched during a stream still binds the client's mic (the park runs
before the session's game does); one launched before the stream keeps the
operator's own microphone — the honest answer.

Because earlier builds recorded nothing to restore, an upgraded box would
have stayed wedged on the virtual mic forever: an idle-pass hygiene now
moves a default found sitting on the plan's mic capture back to the first
REAL microphone (pure picker wiring_plan::real_capture, unit-tested against
the field box's exact recording-tab inventory). Session passes are exempt,
and a box with no real microphone is left alone.

Also folded in: the mid-idle drift re-assert is gone with the gating, so a
mic-pump reopen no longer stomps a recording device the operator chose
themselves.
2026-08-14 10:11:58 +02:00
enricobuehler 0ead084838 Merge pull request 'Steam's art lives in Program Files, which was never an allowed art root' (#215) from worktree-steam-art-root-windows into main
arch / build-publish (push) Failing after 32s
ci / web (push) Successful in 1m20s
ci / rust-arm64 (push) Successful in 1m43s
ci / bun-nix (push) Successful in 1m48s
apple / swift (push) Successful in 2m2s
decky / build-publish (push) Failing after 41s
deb / build-publish-host (push) Failing after 2m13s
deb / build-publish-gamescope (push) Failing after 1m46s
deb / build-publish-client-arm64 (push) Successful in 1m26s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 27s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 15s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
ci / docs-site (push) Successful in 3m26s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 21s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 21s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 22s
docker / builders-arm64cross (push) Successful in 46s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m11s
ci / rust (push) Successful in 8m11s
android / android (push) Successful in 9m34s
docker / deploy-docs (push) Failing after 6m25s
windows-host / package (push) Successful in 13m53s
windows-host / winget-source (push) Skipped
apple / distribute (push) Successful in 12m19s
deb / build-publish (push) Failing after 14m16s
windows-host / canary-manifest (push) Successful in 28s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m40s
apple / screenshots (push) Successful in 6m48s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m21s
deb / smoke-install (push) Failing after 9m33s
2026-08-14 07:51:30 +00:00
enricobuehler 0f9ccfa8b6 fix(ci): funnel the art tests' env overrides through one RAII guard
ci / bun-nix (pull_request) Successful in 24s
apple / swift (pull_request) Successful in 2m8s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 3m59s
ci / web (pull_request) Successful in 5m47s
ci / docs-site (pull_request) Successful in 5m51s
ci / rust-arm64 (pull_request) Successful in 5m54s
ci / rust (pull_request) Successful in 18m16s
CI gate C (unsafe hygiene) failed on the previous commit: `library/art.rs`
went from 4 process-global-API mentions to 10, because the two new tests each
hand-rolled a set/restore pair the way the two existing ones already did.

The gate says fix the call sites rather than raise the baseline, and it is
right to here — the hand-rolled pattern was also leaking. Each test set
`PUNKTFUNK_LIBRARY_ART_ROOTS` and unset it at the end, so any assertion
firing between the two halves left the override installed for every later
test in the process, turning one real failure into a cascade.

`ArtRootsEnv` now holds the lock and the saved values and restores them on
drop, which runs on an unwind too. `write_env` is the single write point, so
the gate has exactly one pair of call sites to judge: the count drops to 2,
below the old baseline of 4, and stays flat however many tests are added.
Baseline lowered to 2 in the same commit, as the ratchet's policy requires.

⚠ The gate greps for the API names in COMMENTS as well as code, so the SAFETY
comments here deliberately describe the calls instead of naming them.

Re-verified after the refactor: .25 493/493 + clippy clean, .133 12/12 art
tests + clippy clean, `check-unsafe-hygiene.sh` clean locally.
2026-08-14 09:32:43 +02:00
enricobuehler 8d60f1cec0 Merge remote-tracking branch 'origin/main' into worktree-steam-art-root-windows
ci / web (pull_request) Successful in 1m10s
ci / rust-arm64 (pull_request) Successful in 1m34s
ci / bun-nix (pull_request) Successful in 1m51s
android / android (pull_request) Successful in 3m57s
ci / docs-site (pull_request) Successful in 3m8s
ci / rust (pull_request) Failing after 6m35s
# Conflicts:
#	CHANGELOG.md
2026-08-14 09:12:39 +02:00
enricobuehler 6dd4add11b fix(library): Steam's art lives in Program Files, which was never an allowed art root
ci / bun-nix (pull_request) Successful in 20s
android / android (pull_request) Canceled after 1m22s
ci / rust-arm64 (pull_request) Successful in 1m17s
ci / rust (pull_request) Canceled after 1m22s
ci / web (pull_request) Canceled after 1m18s
ci / docs-site (pull_request) Canceled after 1m18s
A field report: the Steam plugin installed, the grid stayed empty, and the
only clue was one warn per sync — `art.hero: local art must be an image file
… inside an allowed art root`.

Two defects, both here.

The art roots defaulted to the users base (`C:\Users`, from `%PUBLIC%`'s
parent). That covers the launchers that install per-user, but not Steam,
which installs to `C:\Program Files (x86)\Steam` and keeps both the things
the plugin publishes there — `appcache\librarycache\<appid>\<hash>\` and each
account's `userdata\<id>\config\grid\`. So every cover was out of root. It is
a v0.28.0 regression: the built-in scanner the plugin replaced served covers
through the legacy `steam:` art-proxy branch, which never passed through the
H-2 confinement, so deleting the scanner routed that art through a gate it
had never been measured against. `art_roots()` now also carries every Steam
install it can find, from the three Program Files vars and from HKLM
`Valve\Steam\InstallPath` so a Steam on another drive counts too. POSIX needs
no equivalent — native and Flatpak Steam are both already under `$HOME`.

The confinement is not weakened. It exists to stop the host (SYSTEM) reading
what the plugin lane (LocalService) cannot reach itself; the Steam directory
is readable by LocalService already, so nothing there is reachable *because*
the host is privileged, and the extension, regular-file, magic-byte and
config-dir gates still apply on top. Tested: `config.vdf` is not servable
from an art root, nor is a non-image wearing `.png`.

Second, and the reason this cost a whole library rather than a thumbnail: the
provider reconcile validated art per entry and 400'd the WHOLE payload on the
first bad value. A path mismatch therefore deleted every game from that
store, and the plugin — which only ever sees `HostRequestError` — could not
say which. A reconcile now strips unservable local art and syncs the rest,
logging one aggregated warn with the count, an example path and the env var.
The invariant the 400 held is unchanged: no unservable path is persisted. The
operator's own single-entry writes keep the hard 400, because there the path
was typed by hand and silence would be the wrong answer.

Verified on Linux (.25: 493/493, clippy clean) and Windows (.133: 12/12 art
tests, clippy clean). The new Windows test is hermetic — it repoints
`%ProgramFiles(x86)%` at a synthetic Steam tree rather than asserting over
whatever Steam the box happens to have, since the vacuous version of that
test is what would have let this ship. Confirmed non-vacuous by disabling the
fix: it fails on "the DEFAULT art roots must include it".
2026-08-14 08:38:43 +02:00
enricobuehler b6cc76c472 Merge pull request 'The Android audio plane trusted AAudio, so a TV that opened a dead stream was silent all session' (#214) from worktree-android-aaudio-shield-silence into main
ci / bun-nix (push) Successful in 1m10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 23s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
ci / docs-site (push) Successful in 2m3s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 55s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m21s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 3m23s
ci / web (push) Successful in 3m52s
docker / builders-arm64cross (push) Successful in 9s
docker / deploy-docs (push) Successful in 37s
ci / rust-arm64 (push) Successful in 4m25s
ci / rust (push) Successful in 4m8s
android / android (push) Successful in 12m7s
2026-08-14 06:22:33 +00:00
enricobuehler e2239fd964 Merge remote-tracking branch 'origin/main' into worktree-android-aaudio-shield-silence
ci / web (pull_request) Successful in 1m6s
ci / rust-arm64 (pull_request) Successful in 2m17s
ci / bun-nix (pull_request) Successful in 1m16s
ci / docs-site (pull_request) Successful in 1m56s
ci / rust (pull_request) Successful in 5m29s
android / android (pull_request) Successful in 7m31s
# Conflicts:
#	CHANGELOG.md
2026-08-14 08:07:00 +02:00
enricobuehler f17fe1fc2b Merge pull request 'Unpair every device from one button, over a collection DELETE per plane' (#213) from worktree-unpair-all-button into main
arch / build-publish (push) Failing after 1m7s
ci / docs-site (push) Successful in 1m21s
ci / bun-nix (push) Successful in 4m16s
ci / rust-arm64 (push) Successful in 4m43s
ci / rust (push) Successful in 5m49s
ci / web (push) Successful in 5m48s
deb / build-publish-gamescope (push) Failing after 1m33s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 10s
deb / build-publish-host (push) Successful in 4m42s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 37s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 16s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 30s
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 36s
android / android (push) Successful in 8m2s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m29s
docker / builders-arm64cross (push) Successful in 16s
deb / build-publish-client-arm64 (push) Successful in 4m25s
deb / build-publish (push) Failing after 8m38s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4m57s
deb / smoke-install (push) Successful in 2m36s
windows-host / package (push) Successful in 16m54s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 20s
docker / deploy-docs (push) Successful in 6m42s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m23s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m28s
2026-08-13 22:53:06 +00:00
enricobuehler a3a6444e6e feat(host,web): unpair every device from one button, over a collection DELETE per plane
ci / bun-nix (pull_request) Successful in 23s
ci / docs-site (pull_request) Successful in 1m10s
ci / rust-arm64 (pull_request) Successful in 5m59s
ci / web (pull_request) Successful in 6m25s
android / android (pull_request) Successful in 4m20s
ci / rust (pull_request) Successful in 16m23s
Clearing a host's trust store meant clicking the row trash icon once per
device and confirming each time — tedious with a handful of clients, and
easy to leave half-done.

The "Paired devices" card header now carries an "Unpair all" action behind
a single confirmation. It is backed by two new endpoints rather than a loop
over the per-fingerprint deletes:

    DELETE /api/v1/clients          -> {"unpaired": N}
    DELETE /api/v1/native/clients   -> {"unpaired": N}

one per pairing plane, because the two planes own separate trust stores
with separate persistence and separate revocation duties. Each empties its
store in ONE persisted write. Doing it as N deletes would rewrite (and
atomically rename) the store once per client, and a failure partway would
leave the operator with a half-emptied store and no way to tell which half.

They are collection deletes, so they carry the single delete's revocation
guarantees across the whole set: a live session owned by any removed
certificate is ended, and on the Moonlight side the ENet control port
closes, because no pairing is left to hold it open.

200 with a count rather than the single delete's 204/404: "unpair
everything" is idempotent, an already-empty store satisfies it, and the
count still tells the operator whether that meant three devices or none.

Both gates match on (method, path), so the roster's plugin-readable GET
does not carry over to emptying it — both new routes are admin-token only,
like every other pairing-administration route, with explicit rows in the
route-classification table.

The console calls only the planes that actually have a row: the native
endpoint answers 503 on a host built without that plane, which would
otherwise report a failure for devices that were never there.
2026-08-14 00:35:33 +02:00
enricobuehler 38631d43df fix(client/android): the audio plane trusted AAudio, so a TV that opened a dead stream was silent all session
ci / bun-nix (pull_request) Successful in 19s
ci / web (pull_request) Successful in 1m13s
android / android (pull_request) Canceled after 1m41s
ci / rust (pull_request) Canceled after 1m39s
ci / docs-site (pull_request) Canceled after 1m41s
ci / rust-arm64 (pull_request) Canceled after 1m43s
Field report: no audio at all on an NVIDIA Shield Android TV, stereo, same
host and settings that play fine on an Apple TV. Video unaffected. Turning
off low-latency mode — which gates the forced HDMI mode switch and the
usage=Game tagging, the two things that toggle controls — changed nothing.

This client opens AAudio directly, where the Apple one goes through
AVAudioEngine and gets route-change handling for free; that is why this was
Android-only. Opening AAudio is a negotiation with a vendor HAL and this
plane treated it as a formality: one Exclusive attempt, one Shared retry,
everything after the open taken on trust. Three separate failures all came
out as "the app has no sound" behind a perfectly ordinary log line:

  - a configuration that opens but routes nowhere — nothing ever checked
    that the device pulled a single sample, so the decode thread fed Opus
    into a dead stream indefinitely;
  - request_start failing — we gave up on the spot rather than trying
    anything else, so one unhappy config disabled audio for the session;
  - a disconnect — by AAudio's contract the stream is then DEAD and the
    only recovery is close + open a new one, but the error callback logged
    a warning and did nothing. On a TV that is not rare: this client drives
    an HDMI mode switch on the video plane, and the platform's own
    match-content-frame-rate setting drives more.

The open now walks a ladder, every rung must prove the device is pulling
before it is accepted, and a supervisor owns the plane for the session and
reopens it when the device goes away — with bounded retries across the
settling time of a route change, so a reopen landing mid-switch cannot
permanently disable audio. Granted rate/channels/format are checked rather
than assumed: the realtime callback casts AAudio's buffer to f32 and writes
num_frames * channels of them, so a HAL that disagreed was an out-of-bounds
write on the audio thread, not just a mistuning.

TV boxes now start at Shared. Exclusive is MMAP, the lowest-latency path
AAudio has and the one rung whose routing cannot be verified from inside
the process; the latency it buys was never banked, since the ring depths
are unchanged from the Shared-only era (AAUDIO still primes at 25 ms). On a
mains-powered HDMI box that trade is not worth betting the audio plane on.
Phones keep Exclusive first. If no rung proves itself the first one that
opened and started is used anyway — a watchdog must never be able to turn
working audio into no audio.

nativeStartAudio takes isTv (FEATURE_LEANBACK, the source the video plane
already used) because ro.build.characteristics is not answered by every TV.

debug.punktfunk.audio_sharing / audio_perf / audio_reopen bisect all of it
with setprop, for the device that reports silence and cannot be handed a
custom build. A stream that stops taking samples after it started now says
so at error level instead of looking exactly like an app with no sound.

Not verified on a Shield — no such device here.
2026-08-14 00:15:48 +02:00
enricobuehler 4caf2b76e8 Merge pull request 'gamescope +pfhdr7 — linger no longer dies of its own capture teardown (luxus's fix, overlay#9)' (#212) from worktree-gamescope-linger-pw-destroy-race into main
ci / web (push) Successful in 1m15s
ci / docs-site (push) Successful in 1m29s
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 7s
ci / rust (push) Successful in 7m33s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m34s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 55s
ci / rust-arm64 (push) Successful in 4m37s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m23s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 1m29s
ci / bun-nix (push) Successful in 4m9s
docker / deploy-docs (push) Successful in 33s
arch / build-publish (push) Successful in 10m38s
docker / builders-arm64cross (push) Successful in 1m45s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m28s
Reviewed-on: #212
2026-08-13 22:15:40 +00:00
enricobuehler 0f64551c56 fix(packaging/gamescope): +pfhdr7 — linger no longer dies of its own capture teardown
ci / rust-arm64 (pull_request) Successful in 2m5s
ci / bun-nix (pull_request) Successful in 5m28s
ci / web (pull_request) Successful in 7m2s
ci / docs-site (pull_request) Successful in 8m0s
ci / rust (pull_request) Successful in 16m28s
Patch 0009, reported, written and proven live by luxus (punktfunk-overlay#9): when the capture
consumer leaves, stream_handle_remove_buffer — and the stale-push path in dispatch_nudge —
destroyed idle buffers on the PipeWire thread. Dropping the last CVulkanTexture reference there
calls into the Vulkan driver (vkDestroyImage / FreeMemory / dmabuf fds) while steamcompmgr can
still be inside vulkan_screenshot on another buffer of the same 4-buffer pool; on NVIDIA the race
lands as a SIGSEGV in CVulkanCmdBuffer::insertBarrier. The timing is what made it selectively
lethal: it fires at stream END — exactly the window where the host keeps the headless display
lingering for a reconnect. So the kept display was already dead (journal: linger line → coredump →
"kept display was dead — recreating") and the "resumed" session was a fresh compositor with the
game lost.

The fix queues the corpses (bury_buffer, mutex-guarded) and steamcompmgr reaps them on every
vblank, including while the stream is only paused — the linger state itself. Field-proven on the
reporter's NVIDIA host: 4 coredumps in one evening of BG3 at 4K60 HDR with --pipewire-composite-
cursor (the heaviest paint path we ship), zero after; disconnect/reconnect confirmed live to reuse
the lingered session (2026-08-13). Three of the four stacks are this race; the fourth
(~CVulkanDevice during exit) is patch 0006's already-fixed static-destruction bug — do not
re-diagnose it as part of this.

Ours differs from the overlay's original only by the meson.build banner hunk: +pfhdr6 → +pfhdr7,
PKGBUILD 3.16.25.pfhdr7-1. No new capability — same rule as pfhdr5/6: "reconnect lost my game"
triage has to read a box's exposure off its banner, and every probe is >=. Known residual,
deliberately untouched: add_buffer's error path still deletes on the PW thread. By the later
`goto error`s a texture may be attached, so the race is reachable there in theory — but only when
an add FAILS mid-renegotiation, which no field coredump shows; the patch stays byte-identical with
what was proven on-glass.

Verified: the full 0001..0009 series applies onto the bare 5fb8dce4 pin with plain `git am` (the
build script's own invocation, no -3, no fuzz) and with `git am -3`; the fc44 CI image
(punktfunk-fedora44-rpm) builds the result with rpm.yml's exact dep recipe to a binary whose
banner reads `3.16.25-20-g40fe8b5+pfhdr7 (gcc 16.1.1)`. After 0009, destroy_buffer has exactly
two callers left —
pipewire_reap_dead_buffers (steamcompmgr vblank) and pipewire_destroy_buffer (steamcompmgr's
copy-completion path) — both on the compositor thread. Nix, deb, sysext and rpm all glob
patches/*.patch and read the level off the banner, so no other packaging file moves.
2026-08-14 00:03:17 +02:00
enricobuehler 8fe5acf7f2 Merge pull request 'The OSD stage line stays a partition — an async decode figure is not one of its terms' (#211) from worktree-decode-stat-overlap into main
android / android (push) Failing after 1m54s
ci / docs-site (push) Successful in 1m32s
deb / build-publish (push) Successful in 3m38s
ci / rust-arm64 (push) Successful in 7m6s
ci / web (push) Successful in 6m49s
deb / build-publish-gamescope (push) Failing after 1m33s
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 9s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 52s
ci / bun-nix (push) Successful in 6m10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
arch / build-publish (push) Successful in 10m47s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 14s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 13s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 1m57s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 1m57s
deb / build-publish-client-arm64 (push) Successful in 3m27s
deb / build-publish-host (push) Successful in 8m5s
docker / builders-arm64cross (push) Successful in 47s
ci / rust (push) Canceled after 15m18s
docker / deploy-docs (push) Canceled after 3m24s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 4m53s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 3m46s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m9s
deb / smoke-install (push) Successful in 4m34s
flatpak / build-publish (push) Successful in 11m50s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 11m16s
Reviewed-on: #211
2026-08-13 21:56:44 +00:00
enricobuehler fb33555059 Merge pull request 'HDR was a stop bright, in-game HDR was unreachable on every distro, and SDR shipped untagged' (#210) from worktree-hdr-colour-anchor-fixes into main
arch / build-publish (push) Failing after 6s
ci / bun-nix (push) Successful in 30s
ci / web (push) Successful in 1m1s
deb / build-publish-gamescope (push) Failing after 1m48s
deb / build-publish-client-arm64 (push) Successful in 1m16s
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
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
apple / swift (push) Successful in 2m5s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
deb / build-publish-host (push) Successful in 4m29s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
ci / rust-arm64 (push) Successful in 5m37s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 17s
android / android (push) Canceled after 6m33s
ci / rust (push) Canceled after 6m33s
ci / docs-site (push) Canceled after 6m29s
deb / build-publish (push) Canceled after 6m29s
deb / smoke-install (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 1m16s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 1m11s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 16s
nix / flake (push) Failing after 2m20s
windows-host / package (push) Successful in 12m56s
windows-host / winget-source (push) Skipped
apple / distribute (push) Successful in 11m11s
windows-host / canary-manifest (push) Successful in 41s
apple / screenshots (push) Successful in 6m56s
Reviewed-on: #210
2026-08-13 21:50:11 +00:00
enricobuehler 81022bcc80 fix(client/stats): keep the stage line a partition — an async decode figure is not one of its terms
ci / web (pull_request) Successful in 2m11s
ci / bun-nix (pull_request) Successful in 3m25s
ci / rust-arm64 (pull_request) Successful in 4m39s
android / android (pull_request) Successful in 6m20s
ci / docs-site (pull_request) Successful in 5m37s
ci / rust (pull_request) Successful in 6m40s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m49s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m34s
A 2026-08-13 field report read the OSD's stage line as a breakdown of e2e and
asked why the parts did not add up: `host 5.4 · net 0.3 · decode 6.6 ·
display 1.4` against `e2e 8.1/9.1`. Fair question, and the numbers are all
individually true. They add up without `decode`: 5.4 + 0.3 + 1.4 ≈ 8.1.

The stages ARE a per-frame partition of e2e — pts →(host+net)→ received
→(decode)→ decoded →(display)→ displayed — and that holds for as long as the
`decoded` stamp is a COMPLETION stamp. On the synchronous rungs it is. On the
native-Vulkan rung `receive_frame` returns at SUBMISSION (~0.1 ms) and the
stamp shipped to the presenter is taken there, so `display` is measured from
submit and the GPU decode happens INSIDE it. `host+net` and `display` already
tile e2e between them; the `decode` figure, measured received → fence-complete,
re-counts the GPU work `display` contains. Two figures, one overlap, printed
side by side as though they tiled.

So on that rung `decode` leaves the stage line and gets its own, carrying the
two caveats a reader needs before the number means anything: it is ONE sample
per window there, not the p50 every other figure on that line is, and it is
already inside `display` so adding it double-counts. The synchronous rungs are
untouched — `decode` is a real term there and stays inline.

Deliberately NOT changed: the one-sample-per-window design. `pf_client_core::
session` argues it at length — a per-frame fence wait serialises the decode
pipeline (an APU's 19 ms decode capping a 5120×1440 stream at ~51 fps), and M4
already re-examined and rejected polling, which quantises every sample up by a
frame interval (8.3 ms at 120 Hz against decodes of ~0.1-2 ms). That reasoning
still holds; the reporting around it was the defect. Making `decode` a genuine
per-frame term would need a completion stamp off the hot path — a waiter thread
on the timeline, which that comment already names as the remaining option — and
is a bigger change than this one.

Also not answered here: why the sampled frame read 6.6 ms when the sampling
comment expects 0.1-2 ms. It is a tail frame by construction (a frame that took
6.6 ms to decode also took ≥ 6.6 ms to display, against a 1.4 ms display p50),
but whether the first frame of a window is SYSTEMATICALLY a tail frame needs
instrumenting rather than guessing.

Verified in the linux/amd64 container: pf-presenter 47/47 (incl. the new case,
which pins both shapes and the timed-out-window zero), pf-client-core 188/188,
`clippy --all-targets -D warnings` clean on both, fmt clean. The pf-client-core
leg was proven non-vacuous with a planted compile_error! first.
2026-08-13 23:49:41 +02:00
enricobuehler 1b167f8e35 Merge pull request 'The host tile gets a menu, and the start-of-stream banner becomes an About tab' (#209) from worktree-gamepad-ui-host-mgmt-about into main
ci / bun-nix (push) Successful in 24s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 19s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 26s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 23s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 1m16s
apple / swift (push) Successful in 2m2s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 58s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m16s
ci / web (push) Successful in 3m13s
docker / builders-arm64cross (push) Successful in 1m48s
docker / deploy-docs (push) Successful in 31s
ci / docs-site (push) Successful in 3m24s
apple / distribute (push) Canceled after 1m47s
apple / screenshots (push) Canceled after 0s
ci / rust (push) Canceled after 3m50s
ci / rust-arm64 (push) Canceled after 3m49s
2026-08-13 21:46:19 +00:00
enricobuehler 082c65755f fix(packaging): every channel ships the WSI layer, so in-game HDR works off a stock install
ci / web (pull_request) Successful in 1m0s
ci / bun-nix (pull_request) Successful in 2m6s
ci / rust-arm64 (pull_request) Successful in 3m29s
ci / docs-site (pull_request) Successful in 3m53s
android / android (pull_request) Successful in 5m4s
ci / rust (pull_request) Successful in 5m9s
apple / swift (pull_request) Successful in 2m0s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
nix / flake (pull_request) Failing after 6m17s
The previous commit built the layer and taught the host to use it, but only the
Arch PKGBUILD carried the files, so every other channel still landed on the
no-game-HDR fallback. This finishes the job.

The packaging scripts now take `--stage`, the DESTDIR the gamescope build script
wrote, instead of a path to one binary. That is the part worth keeping: the next
file this package needs will not require a new flag in four scripts and two
workflows. CI caches the whole staged tree for the same reason. The gs-cache key
already hashes packaging/gamescope/**, which this commit changes, so stale caches
in the old single-file shape cannot be restored into the new layout.

Channels, all of them:
  rpm     spec gains Source1/Source2 and %files entries
  deb     build-gamescope-deb.sh copies the layer into the package root
  Arch    PKGBUILD (previous commit); the sysext extracts the whole usr tree
  sysext  bazzite takes --gamescope-stage; arch asserts the layer arrived
  nix     the derivation keeps, renames and rewrites the layer rather than
          deleting it with everything else

A missing layer is fatal in every one of them, not best-effort. A package that
carries the compositor without it looks completely healthy and then silently
denies every game an HDR10 swapchain -- the exact failure this whole change
exists to end, so it must not be possible to ship it again by accident.

Two things needed care:

The layer manifest carries an ABSOLUTE library_path baked in at build time, so
every channel has to install the .so at exactly that path. That means literal
/usr/lib/punktfunk, not %{_libdir} (which is /usr/lib64 on Fedora) and not a
Debian multiarch triplet. Nothing links the .so by soname -- the loader dlopens
it by that path -- so multilib has no claim here. The rpm and nix install checks
now read the path back out of the manifest and fail if it names a file the
package does not install, because a manifest pointing at nothing is the silent
shape of this bug.

NixOS has no /usr, so the layer lives inside the gamescope derivation and the
host's path is overridable via PUNKTFUNK_GAMESCOPE_WSI_LAYER_DIR, which the
module sets -- the same posture as PUNKTFUNK_GAMESCOPE_BIN, and documented.

The manifest rewrite moved out of a heredoc into
packaging/gamescope/rewrite-wsi-layer-manifest.py because the FHS builds and the
Nix store both need it and must rename the layer identically; two copies would
drift into a host looking for a name only one of them produces.

Verified: 214 pf-vdisplay tests pass in a linux container, clippy -D warnings and
rustfmt clean, bash -n on all five changed shell scripts, both workflow YAMLs
parse, and the rewrite script was run against a synthetic FROG manifest to
confirm it renames/repoints/regates while preserving the `functions` block --
which is the field that decides whether the layer loads at all.
NOT verified: no nix on this machine, so gamescope.nix, flake.nix and the module
are unevaluated; no gamescope build, no package build of any kind, and no game
has taken an HDR swapchain on glass.
2026-08-13 23:37:48 +02:00
enricobuehler 9c2c8d1643 fix(apple/about): drop the identity card for a version line under the rows
ci / bun-nix (pull_request) Successful in 42s
ci / web (pull_request) Successful in 1m9s
ci / docs-site (pull_request) Successful in 1m13s
ci / rust-arm64 (pull_request) Successful in 1m20s
ci / rust (pull_request) Successful in 13m50s
apple / swift (pull_request) Successful in 2m0s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
The card led the About tab with the app icon, and on tvOS that icon is a 400x240
rectangle meeting a layout built for square art. Three passes at framing it —
aspect-correct frame, then dropping the zero-radius clip that was cropping it,
then a max frame so it could shrink instead of overflow — and it was still cut
off on real hardware.

So the card goes. A version string answers the only question anyone opens About
to ask, it has no aspect ratio to get wrong, and it belongs under the rows rather
than over them: quiet and centred, reading as a footer instead of a row you
failed to press. `Row.Kind.footer` draws it.

swift build clean on macOS, arm64-apple-ios17.0 and arm64-apple-tvos17.0;
on glass on an Apple TV as 0.29.0 (100004).
2026-08-13 23:28:04 +02:00
enricobuehler c591b7b4af Merge pull request 'The Apple HUD froze its clock offset at connect, so every host-anchored number lied — and the tvOS present floor is closed on sound evidence now' (#208) from worktree-appletv-present-depth into main
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 47s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 46s
ci / bun-nix (push) Successful in 56s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 12s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
ci / web (push) Successful in 1m30s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
ci / docs-site (push) Successful in 1m38s
ci / rust-arm64 (push) Successful in 1m39s
apple / swift (push) Successful in 2m3s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 35s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 40s
docker / builders-arm64cross (push) Successful in 12s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m16s
ci / rust (push) Successful in 4m49s
docker / deploy-docs (push) Failing after 6m59s
apple / distribute (push) Successful in 10m26s
apple / screenshots (push) Successful in 6m45s
2026-08-13 21:21:40 +00:00
enricobuehler 3ac4548cf8 fix(gamescope): ship the WSI layer built beside our compositor, instead of guessing at the distro's
A game nested under gamescope gets an HDR10 swapchain from the FROG WSI layer and
from nothing else -- gamescope advertises no runtime colour-management protocol a
Mesa/NVIDIA WSI could negotiate through. That layer talks `gamescope_swapchain`
to the compositor, and when the two disagree the compositor rejects the client's
swapchain_feedback and every Vulkan client dies on a black screen with sound and
input and no error anywhere.

We ship our own compositor and did NOT ship a layer, on the recorded grounds that
the layer is "version-independent of the compositor binary". It is not, and
wsi_layer_matches_our_gamescope() exists because it is not. So the host was left
guessing from version triples, and that guess is wrong in both directions: a
distro at the same upstream tag that patched the protocol compares EQUAL and
keeps a layer that will kill every game, while a distro at a different tag with a
byte-identical protocol compares unequal and loses HDR for nothing. Since we pin
a rev, the second case is the normal one -- on essentially every box with a
distro gamescope, the layer was disabled and no game could render HDR.

Ship the layer instead. It is built from the same tree at the same rev as the
compositor, so the two cannot drift, and the guess stops being load-bearing. It
is installed under our own name (VK_LAYER_PUNKTFUNK_gamescope_wsi) at our own
path with our own enable/disable variables, so it coexists with the distro's
rather than colliding -- the Vulkan loader keys implicit layers on that name --
and the host switches the two independently in one session.

WsiPlan makes the three states explicit and resolves them once per launch, since
the fallback spawns `--version` probes:
  Ours            our layer is installed: enable it, force the distro's off
  DistroKept      no layer of ours, distro's looks compatible: touch nothing
  DistroDisabled  no layer of ours, distro's untrusted: today's behaviour

That last arm is the fail-safe. A host newer than its gamescope package behaves
exactly as it does today rather than enabling a layer that is not there, so this
can roll out one packaging surface at a time without a flag day.

Only the Arch PKGBUILD carries the new files so far. The rpm path takes a
CI-cached binary rather than the build script's stage dir, so it needs the cache,
build-gamescope-rpm.sh and the spec moved together; the deb, both sysexts and
gamescope.nix need the same two files added. Until each lands, those boxes take
the DistroDisabled arm and are no worse off than before.

Verified: 214 pf-vdisplay tests pass in a linux container (including a new one
pinning that the Ours arm enables ours AND forces the distro's off together --
either half alone is a bug), clippy -D warnings and rustfmt clean, both shell
files pass bash -n, and the manifest rewrite was run against a synthetic FROG
manifest to confirm it renames/repoints/regates while preserving the `functions`
block. NOT verified: an actual gamescope build, any package build, or a game
taking an HDR swapchain on glass.
2026-08-13 23:18:02 +02:00
enricobuehler 6fd5769b3b fix(apple/about): a zero-radius clip is still a clip, and it cropped the TV's wide icon
`cornerRadius: 0` reads as "no rounding", but a RoundedRectangle clip is not a
no-op at zero — it still clips to the layout frame, so any art whose aspect ratio
isn't the frame's loses its ends. The TV's 400x240 icon did exactly that as soon
as there was a real icon to draw instead of the square monogram. The mask now
applies only where it is wanted: iOS, whose icon ships unmasked because the
springboard rounds it at draw time.

The frame goes from fixed to MAX for the same failure one step further out: at a
fixed width the image cannot shrink when its row is tight, so it overflows and is
cropped by whatever is above it. `.fit` inside a max frame gives the whole icon
back, just smaller. And the icon takes layout priority in the identity card — the
tagline beside it is happy to wrap, and a 5:3 icon is what suffers first if the
text is given the width it asks for.

swift build clean on macOS, arm64-apple-ios17.0 and arm64-apple-tvos17.0.
2026-08-13 23:10:34 +02:00
enricobuehler ed8c080603 fix(apple/about): the identity card ignored the row column, and the TV had no icon to show
Both found on glass on an Apple TV.

The card was laid out against the SCREEN while everything under it is laid out
against a centred column of `rowMaxWidth` — 920pt against a 1920-wide TV. So it
began a few hundred points to the left of every row it introduced and read as a
separate banner rather than the head of the list. It now takes the same column
and the same inner inset as a row's contents, so the icon sits directly above
the row icons.

And it was drawing the "P" monogram, never the app's mark. That fallback exists
because tvOS ships its icon as a parallax image STACK (Back/Circle1/Circle2/
Front) with no single image to load, so `AppIconView.bundleIcon` returned nil
there and always had. `AboutAppIcon` is those four layers flattened into one
asset, generated from the SAME art the stack uses so the two cannot drift into
being subtly different icons. A TV icon is a 400x240 rectangle rather than a
squircle, so `side` means HEIGHT on tvOS and the width follows the real 5:3 art
— framed square it would have sat in a box two thirds empty.

Verified the asset actually survives compilation (`assetutil` finds AboutAppIcon
in the built Assets.car at both scales) — a missing imageset would silently fall
back to the monogram again, which is exactly the bug being fixed.

swift build clean on macOS, arm64-apple-ios17.0 and arm64-apple-tvos17.0.
2026-08-13 23:04:29 +02:00
enricobuehler a190fdb1ae fix(apple/tvos): an SDR stream forced the TV into HDR10 output
applyDisplayCriteriaIfNeeded builds a synthetic format description hardcoding
BT.2020 primaries, ST.2084 PQ and the BT.2020 matrix, then hands it to
AVDisplayManager to pick a display mode. Its guard checked only that no criteria
had been set yet and that the user's HDR setting was on -- never that the stream
itself was HDR. Since that setting defaults to true, an ordinary SDR session
drove an HDR-capable TV into PQ output.

That is a standard way to raise the black floor: the Apple TV switches HDMI to
limited-range levels in its HDR modes, and a set configured for full range then
renders code 16 as grey rather than black.

Now gated on connection.isHDR as well. Layout re-runs this, so a session that
flips to HDR mid-stream still picks the mode up on the next pass.

This was NOT the cause of the 2026-08-13 grey-blacks report -- that one had the
client's HDR setting off, so this path never ran (see the SDR layer tagging in
the previous commit for the mechanism that did apply). It is a real bug on its
own, found while investigating it.

Verified: full tvOS compile clean against the AppleTVOS SDK. Not yet verified on
glass.
2026-08-13 22:59:38 +02:00
enricobuehler 94f049ba70 fix(apple): the SDR layer shipped untagged, because an SDR session never configured colour
configure(hdr:) guards on hdr != hdrActive, and hdrActive starts false. A
session that is SDR from its first frame therefore matched the initial state and
fell straight through the guard, so configureColor never ran even once and the
layer kept make()'s bare configuration -- which never assigns a colour space.

An untagged CAMetalLayer gets no colour matching: the BT.709-encoded stream is
drawn in the display's native space. That is mild oversaturation on a P3 Mac or
iPad, and on a tvOS display composited for HDR it also lifts the black floor.
It matches a field report of greys where blacks should be, which arrived with
the client's own HDR switch already OFF -- so nothing else in the pipeline had
tagged those pixels either.

It also meant PUNKTFUNK_SDR_COLORSPACE was dead code on exactly the sessions it
existed to fix: an operator A/B-ing it in the field would have seen no change at
all, because the assignment it feeds was never executed.

So: configureColor now runs once regardless (same-state calls after the first
are still no-ops), and tagging is the default rather than opt-in, since drawing
a BT.709 stream in the panel's native space is not a rendering anyone asked for.
PUNKTFUNK_SDR_COLORSPACE=none restores the untagged look as the A/B lever.

The tvOS HDR tone-map branch gets the same tag -- pf_frag_hdr_tv outputs BT.709,
so it is an SDR layer by the time it is presented.

Verified: full tvOS compile of every PunktfunkKit source clean against the
AppleTVOS SDK. (The build's link step fails on three ABI symbols missing from
the checked-in PunktfunkCore.xcframework, which predates them -- pre-existing,
main fails identically, unrelated to these files.) Not yet verified on glass.
2026-08-13 22:59:36 +02:00
enricobuehler c29e720324 fix(gamescope): HDR sessions anchored SDR white a stop bright, and never said game HDR was unreachable
Field report 2026-08-13, Bazzite host in gaming mode to an iPad: Steam's Big
Picture UI looked glaring and over-saturated while HDR game content looked
washed out, both on the same stream.

Those are one error. gamescope maps everything that is not an HDR game -- the
desktop, the Steam overlay, an SDR title -- into the session's PQ container at
--hdr-sdr-content-nits, and we only passed that flag when an operator had set
PUNKTFUNK_GAMESCOPE_SDR_NITS. Unset, gamescope used its own default of 400,
while every first-party client anchors diffuse white at 203 (BT.2408 reference
white; the Apple presenter hands exactly that to CAEDRMetadata.hdr10's
opticalOutputScale). The two ends were nearly a stop apart, so the UI landed
above SDR white and the client's tone-mapper worked from a reference point the
host had never used, flattening the content around it.

The flag is now always passed, defaulting to 203. The knob still overrides it
for anyone who wants a brighter or dimmer desktop.

Separately, and visible in the same log: the two HDR decisions in a gamescope
session are made independently. hdr_args() never consults
wsi_layer_matches_our_gamescope(), so when the WSI-layer version check fires --
which it does on essentially every Bazzite box, since we pin our own gamescope
rev and the check compares version triples -- the session launches advertising
HDR while having made an HDR10 swapchain unreachable for every game in it. That
layer is the only route to one, so a title told to render HDR renders it into an
SDR swapchain and looks washed out, with nothing anywhere saying why. It now
warns. The behaviour of the check itself is deliberately left alone: re-enabling
a genuinely mismatched layer black-screens every Vulkan client, which is worse
than losing HDR, and that trade needs a real box to retest.

Verified: scripts/xcheck.sh linux clippy clean on pf-vdisplay (-D warnings),
rustfmt clean. Not yet verified on glass.
2026-08-13 22:59:30 +02:00
enricobuehler e057bd60f4 refactor(apple/gamepad-ui): About is its own section, not the last row of Interface
Reachable, but wrong: About sat at the bottom of the Interface tab, under the
palette and the overlay position — a page about the app filed among the settings
that change how it looks, found only by scrolling past them.

It is a tab now, trailing the strip beside Profiles. Both are built from
something other than the settings store, and About is where the strip ends
because it is the one section that changes nothing.

The standalone GamepadAboutView goes away with it. Its content is the tab's rows,
its two reading surfaces (shortcuts, licences) are in-place layers like the pin
picker, and the identity card — icon, name, version, tagline — rides in the
header under the tab strip. In the header rather than as a first row so the list
holds no focus stop that does nothing when pressed; laid out sideways rather than
centred like the touch page, because this header already carries a title and a
strip and a centred icon-name-version-tagline stack would leave no room for the
rows under it.

Row grows a `kind`, so the About tab can draw a heading and a block of prose
without every other tab's rows pretending to be one.

swift build clean on macOS, arm64-apple-ios17.0 and arm64-apple-tvos17.0;
288 tests pass.
2026-08-13 22:57:17 +02:00
enricobuehler 96278eebb5 feat(apple/gamepad-ui): the host tile gets a menu, and the start-of-stream banner becomes a page you can open
The gamepad UI could add a host and connect to one, and that was all: a renamed
machine or a fat-fingered address stayed wrong forever, because the only surface
that could edit or remove one was the touch UI. The desktop console and the
Android console have both had a host menu on UP for a while — this is the Apple
port of it, so the three consoles are learned once.

UP on a saved tile opens Wake / Copy link / Edit… / Forget pairing / Remove.
Wiring UP takes the whole vertical axis away from scrolling (down goes inert): a
horizontal carousel has no vertical travel to spend, and one meaning per
direction is what makes the gesture learnable. Remove arms on the first press
and only fires on the second, and disarms if focus wanders off the row — the
touch grid gets a system confirmation dialog, and a thumbstick from across a
room is a good reason to be at least as strict. A pinned profile card offers
only Unpin: it is a shortcut, not a second host.

Edit reuses GamepadAddHostView, seeded from the record and writing a COPY back
through HostStore.update, so the fingerprint, MACs, pins and binding the form
never shows survive a rename. It REPLACES the menu rather than stacking on it,
which keeps the shell's "depth <= 1 by construction" true.

This also retires the start-of-stream shortcut banner. Telling someone the
controls for six seconds, over the stream they have just connected to, answers
the question at the one moment nobody is asking it — and it put a composited
overlay above the stream to do it. The words are now ShortcutsCatalog, rendered
by an About page on BOTH surfaces: the new gamepad one (icon, version, licenses,
shortcuts) and the touch AboutView. The touch half is not a bonus — the banner
fired in touch mode on a Mac too, so deleting it without that would have cost
those users the only place the keys were written down.

Verified: swift build clean on macOS, arm64-apple-ios17.0 and arm64-apple-tvos17.0
(the iOS pass is what typechecks the shell-layer code, which is #if os(iOS));
288 tests pass. NOT verified on glass — screen capture is unavailable in this
environment, so the new screens have been compiled and reasoned about but not
seen.
2026-08-13 22:34:46 +02:00
341 changed files with 31665 additions and 2777 deletions
+2 -2
View File
@@ -6,7 +6,7 @@
# android.yml would mean an `if:` on all ten of its build steps.
#
# What it is for:
# * promote a tested build up a track (alpha -> production)
# * promote a tested build up a track (beta -> production)
# * roll production back by re-pointing it at an older versionCode (to_track=production,
# version_code=<the good one>, from_track blank)
# * halt a rollout (status=halted)
@@ -36,7 +36,7 @@ on:
from_track:
description: 'track to verify it is on, then clear (blank = touch nothing else)'
required: false
default: 'alpha'
default: 'beta'
notes_tag:
description: "tag whose docs/releases/whatsnew/<tag>.txt to attach, e.g. v0.23.0 (blank = none)"
required: false
+44 -13
View File
@@ -36,8 +36,13 @@ on:
- '.gitea/workflows/android.yml'
# Single project version: a `vX.Y.Z` tag is THE release (publishes to Play `production` at
# 100% + attaches the .aab/.apk to the unified Gitea Release). A main push is canary
# (Play `internal`). Production access was granted 2026-08-01; before that a tag could only
# reach `alpha` and someone had to promote it by hand in the Console.
# (Play `beta` = open testing: public opt-in, no tester list — but unlike the previous
# `internal` target, every canary now passes Google review before testers see it, so a
# canary lands in hours/days, not minutes). The same canary versionCode is also assigned
# to `alpha` (closed testing) in the same Play edit, so the pre-production-access closed
# testers keep receiving builds without re-opting-in. Production access was granted
# 2026-08-01; before that a tag could only reach `alpha` and someone had to promote it
# by hand in the Console.
tags: ['v*']
pull_request:
paths:
@@ -51,7 +56,21 @@ on:
- 'rust-toolchain.toml'
- 'scripts/ci/**'
- '.gitea/workflows/android.yml'
# Manual runs are BUILD-ONLY by default. The escape hatch below exists because a push run can
# go missing entirely: merge two PRs seconds apart and Gitea attributes the window's runs to the
# newer head, so the older merge sha gets no run at all — its android change then sits on main
# having never been built, let alone published (2026-08-14: `1e5dca4c`, PR #235, lost its run to
# `b5cace3a` 12 s later). Re-running the PR run does NOT recover it: a re-run replays the original
# `pull_request` event, so every gate below stays false. Only a dispatch with publish=true can
# ship that commit without inventing a filler push.
workflow_dispatch:
inputs:
publish:
# String, not a boolean: matches apple.yml's `testflight` input, which is the form proven
# to evaluate correctly on this Gitea. Compared as `inputs.publish == 'true'` below.
description: "Also publish this build (registry + Google Play). main -> beta+alpha, vX.Y.Z tag -> production at 100%. Default false: a stray click must not reach testers."
required: false
default: "false"
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io, LAN-pinned via ci-core's
# unbound). The NDK clang targets get their own key universes automatically (keys embed
@@ -94,8 +113,9 @@ jobs:
# store listing. Failing here also means a missing file cannot leave a half-published
# release: nothing is built, nothing is attached to the Gitea release, nothing reaches Play.
#
# Canary is exempt on purpose: it has no curated notes, and Play reusing text for internal
# testers costs nothing.
# Canary is exempt on purpose: it has no curated notes. Open-testing users therefore see
# the previous release's text on a canary — cosmetic, and cheaper than gating every main
# push on a notes file.
- name: Play release notes gate (tags only)
if: startsWith(github.ref, 'refs/tags/v')
run: |
@@ -222,15 +242,18 @@ jobs:
# Single source of the version name + the Play track for the release steps below. versionCode
# stays github.run_number (monotonic across both tracks; Play rejects a regressed code).
- name: Version + channel
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
if: >-
(github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish == 'true'))
&& (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
run: |
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of the latest stable tag)
case "$GITHUB_REF" in
refs/tags/v*) VN="${GITHUB_REF_NAME#v}"; TRACK="production" ;;
*) VN="${PF_BASE}-ci${GITHUB_RUN_NUMBER}"; TRACK="internal" ;;
refs/tags/v*) VN="${GITHUB_REF_NAME#v}"; TRACK="production"; ALSO="" ;;
*) VN="${PF_BASE}-ci${GITHUB_RUN_NUMBER}"; TRACK="beta"; ALSO="alpha" ;;
esac
echo "VERSION_NAME=$VN" >> "$GITHUB_ENV"
echo "PLAY_TRACK=$TRACK" >> "$GITHUB_ENV"
echo "PLAY_ALSO_TRACK=$ALSO" >> "$GITHUB_ENV"
# Play's own "What's new" (500-char cap, its own file — the vX.Y.Z.md body is ~34 KB).
# On a tag the gate step above already proved this exists, so the else branch is only
# ever the canary path. See docs/releases/README.md.
@@ -240,10 +263,12 @@ jobs:
else
echo "no Play release notes at $NOTES (canary — Play keeps the previous text)"
fi
echo "android version $VN -> Play track '$TRACK'"
echo "android version $VN -> Play track '$TRACK'${ALSO:+ (+ '$ALSO')}"
- name: Build Release (signed AAB + universal APK)
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
if: >-
(github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish == 'true'))
&& (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
working-directory: clients/android
env:
VERSION_CODE: ${{ github.run_number }} # VERSION_NAME comes from the Version+channel step (GITHUB_ENV)
@@ -278,7 +303,9 @@ jobs:
# main = canary store + `canary/` sideload alias; a `vX.Y.Z` tag = `latest/` alias + attached
# to the unified Gitea Release.
- name: Publish to generic registry + attach to Gitea release
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
if: >-
(github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish == 'true'))
&& (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
env:
REGISTRY: git.unom.io
OWNER: unom
@@ -312,7 +339,8 @@ jobs:
# Direct Publishing-API upload instead of r0adkll/upload-google-play — that action hides the
# real API error behind "Unknown error occurred."; this prints it. stdlib + openssl only (no
# pip), reuses SERVICE_ACCOUNT_JSON (raw JSON or base64), auto-handles changesNotSentForReview.
# Track: canary main -> `internal`; a vX.Y.Z release -> `production` at 100% (`completed`).
# Track: canary main -> `beta` (open testing) + the same versionCode on `alpha` (closed
# testing) in the same Play edit; a vX.Y.Z release -> `production` at 100% (`completed`).
#
# A tag therefore ships to real users with no further click. Two things keep that honest:
# the tag is only pushed once every platform is green, and Play reviews each production
@@ -320,13 +348,16 @@ jobs:
# `--status inProgress --user-fraction 0.2`; to undo a bad one, halt or roll back from the
# Console (or `android-promote.yml`, which can re-point production at an older versionCode).
- name: Upload to Google Play
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
if: >-
(github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish == 'true'))
&& (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
env:
SERVICE_ACCOUNT_JSON: ${{ secrets.SERVICE_ACCOUNT_JSON }}
run: |
echo "uploading to Play track '$PLAY_TRACK'"
echo "uploading to Play track '$PLAY_TRACK'${PLAY_ALSO_TRACK:+ (+ '$PLAY_ALSO_TRACK')}"
set -- --package io.unom.punktfunk \
--aab clients/android/app/build/outputs/bundle/release/app-release.aab \
--track "$PLAY_TRACK" --status completed
if [ -n "${PLAY_ALSO_TRACK:-}" ]; then set -- "$@" --also-track "$PLAY_ALSO_TRACK"; fi
if [ -n "${PLAY_NOTES:-}" ]; then set -- "$@" --release-notes-file "$PLAY_NOTES"; fi
python3 clients/android/ci/play-upload.py "$@"
+21 -10
View File
@@ -676,20 +676,23 @@ jobs:
# Skipped on PRs (cost); runs on main pushes + manual dispatch. Needs the build/test job green
# first, and is a separate job so a capture hiccup can never red the core signal.
#
# Scope = the two REQUIRED iOS sizes (iPhone 6.9" + iPad 13"), captured on the Simulator
# (`simctl io screenshot`, no Screen Recording grant needed). macOS and tvOS are deliberately
# NOT in CI: the self-hosted runner is headless (no window-server session), so the mac window
# capture can't run there; tvOS needs the Tier-3 build-std slice. Generate those two locally on
# a GUI Mac with `clients/apple/tools/screenshots.sh macos tvos`.
# Scope = the two REQUIRED iOS sizes (iPhone 6.9" + iPad 13") + Apple TV (1920×1080), captured
# on the Simulator (`simctl io screenshot`, no Screen Recording grant needed). The tvOS slice is
# Tier-3 (nightly -Zbuild-std, same as the distribute job — slow cold, cached on the self-hosted
# runner). The tvOS scene list is explicit: the gamepad-console scenes are iOS/macOS-only, and an
# unknown scene name falls back to a NORMAL app launch — the capture would silently be of the
# real empty app. macOS stays deliberately NOT in CI: the runner is headless (no window-server
# session), so the mac window capture can't run there — generate it locally on a GUI Mac with
# `clients/apple/tools/screenshots.sh macos`.
screenshots:
needs: swift
if: gitea.event_name != 'pull_request'
runs-on: macos-arm64
timeout-minutes: 75
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- name: Rust toolchain + iOS Simulator targets
- name: Rust toolchain + iOS Simulator targets (+ nightly for the tvOS slices)
run: |
if ! command -v rustup >/dev/null && [ ! -x "$HOME/.cargo/bin/rustup" ]; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
@@ -699,6 +702,10 @@ jobs:
dirname "$RUSTUP" >> "$GITHUB_PATH"
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin \
aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
# tvOS targets are tier-3 (no prebuilt std) — build-xcframework.sh compiles them with
# nightly + -Zbuild-std, so ensure nightly + rust-src are present (see the swift job).
"$RUSTUP" toolchain install nightly --profile minimal
"$RUSTUP" component add rust-src --toolchain nightly
# Shared compile cache. The script handles the macOS side (user-prefix install +
# GITHUB_PATH, bsdtar globbing) — see scripts/ci/ensure-sccache.sh.
@@ -735,10 +742,10 @@ jobs:
-mtime +7 -exec rm -rf {} + 2>/dev/null || true
fi
- name: Build PunktfunkCore.xcframework (mac + iOS slices)
run: BUILD_IOS=1 bash scripts/build-xcframework.sh
- name: Build PunktfunkCore.xcframework (mac + iOS + tvOS slices)
run: BUILD_IOS=1 BUILD_TVOS=1 bash scripts/build-xcframework.sh
- name: Capture screenshots (iPhone 6.9" + iPad 13"; auto-creates the Simulators)
- name: Capture screenshots (iPhone 6.9" + iPad 13" + Apple TV; auto-creates the Simulators)
working-directory: clients/apple
env:
SETTLE: "8" # Simulators settle slower than a local run
@@ -746,6 +753,10 @@ jobs:
# Independent invocations: one platform failing skips it, not the other.
bash tools/screenshots.sh ios || echo "::warning::iOS (iPhone 6.9\") screenshots skipped"
bash tools/screenshots.sh ipad || echo "::warning::iPad 13\" screenshots skipped"
# tvOS shoots only the scenes that exist there — the 0609 gamepad-console scenes are
# compiled out on tvOS (native focus engine), and an unknown name = a normal app launch.
SCENES="01-stream 02-hosts 11-library 05-settings 03-pair" \
bash tools/screenshots.sh tvos || echo "::warning::Apple TV screenshots skipped"
echo "Produced:"; ls -la screenshots || true
- name: Shut the Simulators down (leaked booted sims once piled up 846 deep)
+7 -5
View File
@@ -9,11 +9,13 @@
# login gate, session sealing, mgmt bearer token), sdk (@punktfunk/host),
# plugin-kit (@punktfunk/plugin-kit).
# * pnpm audit → clients/decky (the Steam Deck plugin).
# * docs-site → scanned NON-blocking (continue-on-error): known transitive advisories ride in
# via the CMS/UI chain (@unom/ui → payload → dompurify/monaco) and the nitropack
# build chain (node-tar, brace-expansion); clearing them needs coordinated bumps
# verified against the LIVE site (the docs don't build standalone) — tracked in
# punktfunk-planning design/cra-readiness.md. Flip to blocking once clean.
# * docs-site → scanned NON-blocking (continue-on-error). 2026-08-14: docs-site's own deps
# are current (fumadocs/tanstack/react bumped; build + tsc + serve verified),
# but every remaining advisory is pinned INSIDE @unom/ui 0.9.2's dependency
# tree (@payloadcms/* → fast-uri/image-size/sharp, next 16.x, sass→immutable) —
# nothing bumpable from this lockfile, and overrides would fork what the CMS
# actually ships. The fix belongs in the @unom/ui package repo; flip this to
# blocking after a ui release with a clean payload chain lands here.
# * cargo-about → license-allowlist gate over the host + driver workspaces (about.toml `accepted`);
# fails if any crate carries a license outside the allowlist — the regression
# guard about.toml always promised. (The Android Gradle tree has no lockfile, so
+16 -4
View File
@@ -114,8 +114,14 @@ jobs:
path: |
/usr/local/cargo/registry
/usr/local/cargo/git
key: cargo-home-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-home-
# Namespaced `-ci-` so a fork PR's cargo-home cache can never land in the `cargo-home-`
# pool the SIGNED release builds (deb.yml / android.yml) restore: registry/src holds
# already-extracted crate sources that cargo compiles WITHOUT re-checksumming past
# `.cargo-ok`, so a poisoned entry would be arbitrary Rust source compiled into a release
# artifact with no Cargo.lock diff. security-review 2026-08-15 finding 5. (The definitive
# control is operator-side: Gitea's "require approval for fork PRs".)
key: cargo-home-ci-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-home-ci-
- uses: actions/cache@v4
with:
path: target
@@ -258,8 +264,14 @@ jobs:
path: |
/usr/local/cargo/registry
/usr/local/cargo/git
key: cargo-home-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-home-
# Namespaced `-ci-` so a fork PR's cargo-home cache can never land in the `cargo-home-`
# pool the SIGNED release builds (deb.yml / android.yml) restore: registry/src holds
# already-extracted crate sources that cargo compiles WITHOUT re-checksumming past
# `.cargo-ok`, so a poisoned entry would be arbitrary Rust source compiled into a release
# artifact with no Cargo.lock diff. security-review 2026-08-15 finding 5. (The definitive
# control is operator-side: Gitea's "require approval for fork PRs".)
key: cargo-home-ci-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-home-ci-
- uses: actions/cache@v4
with:
path: target
+5 -3
View File
@@ -517,15 +517,17 @@ jobs:
bash packaging/gamescope/build-punktfunk-gamescope.sh \
--destdir "$PWD/gs-stage" --prefix /usr --jobs "$(nproc)" \
--extra-fallback libdisplay-info
install -Dm0755 gs-stage/usr/bin/punktfunk-gamescope gs-cache/punktfunk-gamescope
# The WHOLE staged tree, not just the binary: it also carries the Vulkan WSI layer built
# beside the compositor, which is the only route to an HDR10 swapchain for a nested game.
mkdir -p gs-cache && cp -a gs-stage/. gs-cache/
# The binary must RUN, not merely link: `--version` is what the old job used as its ship
# gate, and it is the cheapest proof that the static-libstdc++ trick and the vendored wlroots
# actually produced a working compositor.
- name: Build the .deb
run: |
gs-cache/punktfunk-gamescope --version
bash packaging/debian/build-gamescope-deb.sh --binary gs-cache/punktfunk-gamescope
gs-cache/usr/bin/punktfunk-gamescope --version
bash packaging/debian/build-gamescope-deb.sh --stage gs-cache
- name: Publish to the Gitea apt registry
env:
+6
View File
@@ -363,6 +363,12 @@ jobs:
envs: REGISTRY_TOKEN
script: |
set -euo pipefail
# Log out on EVERY exit path: unlike the ephemeral LAN-registry runners, this is a
# long-lived internet-facing VM, so a `write:package` PAT left base64-encoded in
# ~/.docker/config.json is credential-at-rest on the most exposed host in the estate.
# The LAN jobs above already `docker logout`; this one omitted it. security-review
# 2026-08-15 finding 14.
trap 'docker logout git.unom.io || true' EXIT
printf '%s' "$REGISTRY_TOKEN" | docker login git.unom.io -u enricobuehler --password-stdin
cd ~/punktfunk-docs
docker compose -f compose.production.yml pull docs
+10 -6
View File
@@ -293,7 +293,11 @@ jobs:
dnf -y install wayland-protocols-devel glm-devel cmake libXcursor-devel || true
if bash packaging/gamescope/build-punktfunk-gamescope.sh \
--destdir "$PWD/gs-stage" --prefix /usr --jobs "$(nproc)"; then
install -Dm0755 gs-stage/usr/bin/punktfunk-gamescope gs-cache/punktfunk-gamescope
# The WHOLE staged tree, not just the binary: it also carries the Vulkan WSI layer built
# beside the compositor, which is the only thing that can give a nested game an HDR10
# swapchain. Caching the tree rather than a file per artifact is what keeps the next
# addition from needing a change here as well as in every packaging script.
mkdir -p gs-cache && cp -a gs-stage/. gs-cache/
else
# Warn only, even on a tag — the hard gate is the LAST step of this job. Failing here
# would skip the sysext build, the sysext feed, AND the release attach below, so a
@@ -311,9 +315,9 @@ jobs:
# existing SDR/host-composited path. The spec re-checks the +pfhdr marker itself.
- name: Package punktfunk-gamescope as an RPM
run: |
if [ -x gs-cache/punktfunk-gamescope ] && gs-cache/punktfunk-gamescope --version >/dev/null 2>&1; then
if [ -x gs-cache/usr/bin/punktfunk-gamescope ] && gs-cache/usr/bin/punktfunk-gamescope --version >/dev/null 2>&1; then
bash packaging/gamescope/build-gamescope-rpm.sh \
--binary gs-cache/punktfunk-gamescope \
--stage gs-cache \
--release "$PF_RELEASE"
else
# Warn only — see the note on the build step. The gate is the last step of this job.
@@ -372,9 +376,9 @@ jobs:
# whose runtime libs are missing from this container must cost the image its HDR, not the
# image itself.
gs=()
if [ -x gs-cache/punktfunk-gamescope ] && gs-cache/punktfunk-gamescope --version >/dev/null 2>&1; then
gs=(--gamescope gs-cache/punktfunk-gamescope)
echo "folding in $(gs-cache/punktfunk-gamescope --version 2>&1 | head -1)"
if [ -x gs-cache/usr/bin/punktfunk-gamescope ] && gs-cache/usr/bin/punktfunk-gamescope --version >/dev/null 2>&1; then
gs=(--gamescope-stage gs-cache)
echo "folding in $(gs-cache/usr/bin/punktfunk-gamescope --version 2>&1 | head -1)"
else
echo "::warning::no usable punktfunk-gamescope for f${{ matrix.fedver }} — the sysext ships without it (gamescope sessions stay SDR)"
fi
+31 -9
View File
@@ -70,12 +70,18 @@
# latest stable tag via scripts/ci/pf-version.ps1, run number climbs monotonically).
# Both arches share the version; artifacts are arch-suffixed (..._x64.msix / ..._arm64.msix).
#
# Signing (clients/windows/packaging/pack-msix.ps1): if the MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD
# Actions secrets are set (a real or shared code-signing .pfx whose subject DN == Publisher), the
# package is signed with them. Otherwise an ephemeral self-signed cert is generated and its public
# .cer is published next to the .msix (users import it to Trusted People before install).
# Signing (clients/windows/packaging/pack-msix.ps1), first match wins:
# 1. Azure Artifact Signing — what this workflow always takes, since the AZURE_CODESIGNING_*
# endpoint/account/profile are literals below and only the AZURE_TENANT_ID / AZURE_CLIENT_ID /
# AZURE_CLIENT_SECRET secrets are needed. Publicly trusted, so NO .cer is emitted or published
# and users import nothing. NOTE the Publisher DN is the Azure profile's verified subject, and
# MSIX identity is name + publisher: moving to it changed the package identity, so installs
# predating it need an uninstall, not an upgrade.
# 2. MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD — the older self-signed .pfx, kept as a fallback.
# 3. an ephemeral self-signed cert. Modes 2 and 3 DO emit a .cer next to the .msix, which users
# would have to import into Trusted People before Windows will install the package.
#
# That fallback is for canary/CI ONLY. On a v* tag the pack script FAILS CLOSED — a missing secret
# Modes 2 and 3 are for canary/CI ONLY. On a v* tag the pack script FAILS CLOSED — a missing secret
# aborts the build instead of quietly shipping a release signed by a per-build throwaway cert that
# no one can pin. Nothing to opt into here: the script reads GITHUB_REF itself.
name: windows-client
@@ -257,6 +263,19 @@ jobs:
if: github.event_name != 'pull_request'
shell: pwsh
env:
# Azure Artifact Signing (formerly Trusted Signing) — takes precedence over MSIX_CERT_*
# when all three are set. Not secret: an account/profile name and a regional endpoint,
# inert without the credentials below. The profile's verified subject is also the MSIX
# manifest Publisher; pack-msix.ps1 reads the signature back and fails on a mismatch.
AZURE_CODESIGNING_ENDPOINT: https://neu.codesigning.azure.net/
AZURE_CODESIGNING_ACCOUNT: unomsigning
AZURE_CODESIGNING_PROFILE: unom-io
# Service principal 'punktfunk-ci-signing', holding ONLY the Artifact Signing Certificate
# Profile Signer role, scoped to the unom-io profile — it can sign and nothing else.
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
# Legacy self-signed path, kept as the fallback for builds without Azure access.
MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }}
MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }}
run: |
@@ -275,10 +294,13 @@ jobs:
# stable release -> `latest/` alias; canary main build -> `canary/` alias.
$alias = if ($env:GITHUB_REF -like 'refs/tags/v*') { 'latest' } else { 'canary' }
# version-less, arch-suffixed alias names so each channel keeps one predictable URL.
$aliasNames = @{
"$($env:MSIX_PATH)" = "$($env:PKG)_${{ matrix.arch }}.msix"
"$($env:MSIX_CER_PATH)" = "$($env:PKG)_${{ matrix.arch }}.cer"
}
# Under Azure signing there is no .cer, so MSIX_CER_PATH is unset. The quotes below are
# load-bearing: "$($env:UNSET)" interpolates to an empty string (a legal key), whereas a
# BARE $env:UNSET is $null and a null key is a hard error in a hash literal — which is
# exactly how windows-host.yml's publish step broke. Added explicitly rather than relying
# on that accident, so removing the quotes can't silently reintroduce it.
$aliasNames = @{ "$($env:MSIX_PATH)" = "$($env:PKG)_${{ matrix.arch }}.msix" }
if ($env:MSIX_CER_PATH) { $aliasNames[$env:MSIX_CER_PATH] = "$($env:PKG)_${{ matrix.arch }}.cer" }
$files = @($env:MSIX_PATH, $env:MSIX_CER_PATH) | Where-Object { $_ -and (Test-Path $_) }
if (-not $files) { throw "pack produced no artifacts to publish" }
function Put($f, $url) {
+38 -4
View File
@@ -20,12 +20,18 @@
# main push / dispatch -> <next-minor>.<run_number> (canary; `canary/` alias; base one minor
# ahead of the latest stable tag via scripts/ci/pf-version.ps1, run climbs).
#
# Signing reuses the client's MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD secrets (CN=unom). Without them
# an ephemeral self-signed cert is generated and its public .cer published next to the installer
# (import once to LocalMachine\TrustedPublisher). That fallback is for canary/CI ONLY — on a v* tag
# Signing goes through Azure Artifact Signing (account `unomsigning`, profile `unom-io`) — a publicly
# trusted CA, so there is no .cer for users to import and no SmartScreen "unknown publisher" prompt.
# It falls back to the old MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD self-signed cert, and then to an
# ephemeral one, for builds without Azure access. Those fallbacks are for canary/CI ONLY — on a v* tag
# the pack script FAILS CLOSED rather than ship a release signed by a per-build throwaway cert.
# See packaging/windows/pack-host-installer.ps1.
#
# The bundled DRIVERS are NOT signed by Azure — they keep their own DRIVER_CERT_* cert and are still
# trusted by planting that cert in the machine Root store at install time. Independent by design:
# Windows checks the installer's signature via SmartScreen/UAC and driver catalogs via PnP, and never
# requires a common signer. See packaging/windows/README.md for why that root-plant is still there.
#
# GPU backends: the host builds with --features nvenc,amf-qsv,qsv = all three vendors in one installer.
# - NVENC (NVIDIA, direct SDK): nothing needed at build time — the entry points are resolved at
# RUNTIME from the driver's nvEncodeAPI64.dll (a link-time import would kill the binary on
@@ -291,9 +297,17 @@ jobs:
# so the installer ships just bun + a ~75-file .output instead of node + a node_modules forest.
$ver = 'bun-v1.3.14'
$url = "https://github.com/oven-sh/bun/releases/download/$ver/bun-windows-x64.zip"
# SHA-256 of this exact asset, pinned. GitHub release assets are MUTABLE at a fixed URL, so
# the tag alone vouches for nothing — this binary is Authenticode-signed into our installer
# and its hash published in the Ed25519 update manifest, i.e. our signature vouches for bytes
# we downloaded. Verify them. On a bun bump, update BOTH $ver and $sha (compute:
# `shasum -a 256 bun-windows-x64.zip`). security-review 2026-08-15 finding 12.
$sha = '0a0620930b6675d7ba440e81f4e0e00d3cfbe096c4b140d3fff02205e9e18922'
New-Item -ItemType Directory -Force -Path C:\t | Out-Null
$zip = 'C:\t\bun.zip'; $dst = 'C:\t\bundist'
Invoke-WebRequest -Uri $url -OutFile $zip
$got = (Get-FileHash -Algorithm SHA256 $zip).Hash.ToLower()
if ($got -ne $sha) { throw "bun zip sha256 mismatch for ${ver}: got $got, pinned $sha" }
if (Test-Path $dst) { Remove-Item $dst -Recurse -Force }
Expand-Archive -Path $zip -DestinationPath $dst -Force
$bun = (Get-ChildItem -Path $dst -Recurse -Filter bun.exe | Select-Object -First 1).FullName
@@ -415,12 +429,26 @@ jobs:
- name: Pack + sign installer
shell: pwsh
env:
# Azure Artifact Signing (formerly Trusted Signing) — takes precedence over MSIX_CERT_*
# when all three of these are set. Not secret: an account/profile name and a regional
# endpoint, all inert without the credentials below, so they live here where a reviewer
# can see which profile a release was signed by.
AZURE_CODESIGNING_ENDPOINT: https://neu.codesigning.azure.net/
AZURE_CODESIGNING_ACCOUNT: unomsigning
AZURE_CODESIGNING_PROFILE: unom-io
# Service principal 'punktfunk-ci-signing', holding ONLY the Artifact Signing Certificate
# Profile Signer role, scoped to the unom-io profile — it can sign and nothing else.
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
# Legacy self-signed path, kept as the fallback for builds without Azure access.
MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }}
MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }}
# The DRIVER cert is separate from the host/MSIX one and reaches the two driver build
# scripts through the environment (pack-host-installer.ps1 invokes them, they read
# $env:DRIVER_CERT_PFX_B64 themselves). Without it they sign with a per-build throwaway,
# which the installer then trusts as a machine root — see packaging/windows/README.md.
# NOT moved to Azure: driver catalogs are a separate track, see that README.
DRIVER_CERT_PFX_B64: ${{ secrets.DRIVER_CERT_PFX_B64 }}
DRIVER_CERT_PASSWORD: ${{ secrets.DRIVER_CERT_PASSWORD }}
run: |
@@ -452,7 +480,13 @@ jobs:
# Refresh the channel alias (delete-then-reupload, like flatpak.yml/decky.yml) for a
# predictable download URL: stable release -> `latest/`, canary main build -> `canary/`.
$alias = if ($env:GITHUB_REF -like 'refs/tags/v*') { 'latest' } else { 'canary' }
$aliasNames = @{ $env:HOST_SETUP_PATH = 'punktfunk-host-setup.exe'; $env:HOST_CER_PATH = 'punktfunk-host-windows.cer' }
# Build this incrementally, NOT as one literal: under Azure signing there is no .cer, so
# HOST_CER_PATH is unset — and an unset $env: var is $null, which is a HARD ERROR as a hash
# literal key ("A null key is not allowed in a hash literal"), not the empty-string key it
# looks like it should be. The $files guard above filters the missing .cer out just fine;
# this line ran before anything could use it and failed the whole publish step.
$aliasNames = @{ $env:HOST_SETUP_PATH = 'punktfunk-host-setup.exe' }
if ($env:HOST_CER_PATH) { $aliasNames[$env:HOST_CER_PATH] = 'punktfunk-host-windows.cer' }
foreach ($f in $files) {
$an = $aliasNames[$f]; if (-not $an) { continue }
curl.exe -fsS -o NUL --user "enricobuehler:$($env:REGISTRY_TOKEN)" -X DELETE "$base/$alias/$an" 2>$null
+799 -1
View File
@@ -12,7 +12,760 @@ with the version table of the release you are moving to, then read **Breaking ch
---
## v0.28.1 — in development
## v0.29.0
53 commits since v0.28.1 (36 non-merge).
The headline contract change is one **additive** C ABI bump: the host now tells the client, in-band,
where its management API lives, and the connection grew an accessor for it. The wire protocol, the
driver protocol and the plugin contract do not move; every 0.28.x host, client, driver and plugin
keeps interoperating with 0.29.0 in both directions, with no re-pairing. The one thing that needs an
operator's hand is on Windows: the MSIX package identity changed with the move to a publicly
trusted signing certificate, so that install path needs a one-time uninstall + reinstall.
### Versions
| | v0.28.1 | v0.29.0 | Notes |
|---|---|---|---|
| Wire protocol | 2 | **2** | unchanged — `Welcome` grew a trailing field older peers never read (below) |
| C ABI | 19 | **20** | one symbol added: `punktfunk_connection_mgmt_port` (below) |
| Rust edition | 2024 | **2024** | unchanged |
| MSRV (`rust-version`) | 1.85 | **1.85** | unchanged |
| Workspace crate dirs | 27 | **27** | unchanged |
| Virtual-display driver protocol | 6 | **6** | unchanged (minimum accepted still 3); `pf-driver-proto` shows no diff against the v0.28.1 tag |
| Windows virtual-gamepad channel | 3 | **3** | unchanged |
| Plugin index schema | 1 | **1** | unchanged |
| `api/openapi.json` | 0.28.0 | **0.28.0** | the management API surface did not change; the file keeps the stamp it was regenerated under |
| gamescope patch level (`+pfhdrN`) | 7 | **7** | unchanged — the patch series is untouched |
| `@punktfunk/host` (SDK) | 0.1.4 | **0.1.4** | unchanged |
| `@punktfunk/plugin-kit` | 0.4.1 | **0.4.1** | unchanged |
### ⚠ Breaking changes
- **C ABI 19 → 20, addition only.** `include/punktfunk_core.h` gains exactly one declaration,
`punktfunk_connection_mgmt_port(const PunktfunkConnection *, uint16_t *)` — the management-API
port the host advertised in its `Welcome`, or the documented default when it advertised none.
Nothing is removed or reshaped; an embedder that compares `PUNKTFUNK_ABI_VERSION` at build time
rebuilds against the new header and is done. Nothing in-tree compares it at runtime.
- **The Windows MSIX package identity changed.** Releases are now signed by Azure Artifact Signing
(below), and the MSIX manifest `Publisher` must equal the signer subject byte-for-byte — so it
moved from the self-signed `CN=unom` to the verified subject. Package identity is Name +
Publisher: Windows treats the new package as a different app, and an in-place upgrade is
impossible by design. One-time uninstall + reinstall for MSIX installs; the `.exe` installer and
winget-via-installer paths upgrade normally.
- **Android embedder edge, additive:** `NativeBridge` gains `nativeHostMgmtPort`, and the native
discovery record gains its 9th field, `mgmt` (the record's append-only rule; 0, non-numeric and
out-of-range all parse as unknown). Out-of-tree JNI callers are unaffected unless they want the
value.
### The management port is movable, survives, and is learned in-band
47990 is the management API's port and also the web-UI port of Sunshine and its forks — with the
GameStream planes off, the only port the two still contend for. Moving it now actually works, end
to end:
- **`PUNKTFUNK_MGMT_BIND` joins `host.env`** (the `PUNKTFUNK_GAMESTREAM` shape: env or CLI flag,
the flag wins), so the choice survives package upgrades that rewrite the unit file. `serve`
publishes the port it *actually bound* to `~/.config/punktfunk/mgmt-endpoint` (KEY=VALUE, written
write-then-rename), and the console, the Windows service and the unit files all derive from that
one file; the six hardcoded 47990 literals survive only as the old-host fallback.
- **`Welcome.mgmt_port`** — a trailing `u16` after the cipher block, the same additive discipline
as the eight fields before it, so `WIRE_VERSION` stays 2 and an older peer stops earlier and uses
the default. ⚠ One encode subtlety, pinned by test: `cipher` used to be emitted only when
non-default, and appending the port to an AES `Welcome` would land its low byte at offset 68 —
exactly where every shipped 0.28.x client reads `cipher`, fail-closed. `encode` therefore writes
an explicit cipher byte whenever a port rides along; a host advertising no port still emits
exactly 68 bytes. The standalone `punktfunk1-host` binary advertises `0` (it has no management
API).
- **Clients persist it**: `KnownHost.mgmt_port` + `effective_mgmt_port()` across the Rust clients
(three-rung: live advert → stored → default), the session console, Android (through
`DiscoveredHost`), and Apple — where `StoredHost.mgmtPort` had existed all along but nothing ever
wrote it, so every Apple client resolved 47990 regardless. A host that has never been seen over
mDNS (VPN, routed subnet, multicast-dead network) now learns the port from the authenticated
connection itself.
- **`PUNKTFUNK_NATIVE_PORT`** completes the pair for the data plane — `--native-port` was CLI-only
and died on upgrade. A bad value is a startup **error**, not a silent fall back to 9777.
- The Windows shell's half of the client-side learn landed separately (#241): `trust.rs` re-exports
`learn_mgmt_port`, the shell's own mDNS browser parses the `mgmt` TXT, and `HostTarget` carries
the port like the mac client's target does.
### Linux thread priority: the renice was a no-op on every install to date
`boost_thread_priority`'s `setpriority()` needs `CAP_SYS_NICE` or a raised `RLIMIT_NICE`; no
channel granted either, and the host binary can never carry a file capability (a capped process's
`/proc/<pid>/exe` is unreadable to KWin — the 0.26.0-1 incident). So capture/encode/send ran at
nice 0, and a shader-compile storm could deschedule them hard enough to stutter audio and drag ABR
to its floor at zero loss. Now:
- **RealtimeKit fallback** — `MakeThreadHighPriorityWithPID`, the same unprivileged broker
PipeWire clients use. Only the nice verb, never `MakeThreadRealtime`; nothing enters the
permitted set, KWin identification is untouched.
- **The audio plane is boosted at all, for the first time**: the 5 ms Opus
capture→encode→send loop, the PipeWire capture mainloop, and the pad-audio streamer (on Windows
too, via the existing `SetThreadPriority` arm).
- **Packaging ships headroom for rtkit-less boxes**: `packaging/linux/50-punktfunk-nice.conf`
(`user@.service.d`, `LimitNICE=-15` — a limit, not a grant; effective from next login) on rpm,
Arch and deb, written to `/etc/systemd/system/user@.service.d` by the Steam Deck installer; deb
and rpm gain a weak `Recommends: rtkit`, Arch an optdepends hint, and the NixOS module sets
`security.rtkit.enable = mkDefault true`.
### Host capture gain works on `punktfunk/1`, and boosting no longer hard-clips
`PUNKTFUNK_AUDIO_GAIN` existed only on the GameStream plane, and where it applied it was a hard
`clamp(-1.0, 1.0)` — flat-topping, so pushing past ~1.5× sounded broken long before it got loud
(WASAPI loopback taps upstream of the endpoint's master volume, so the host's own slider never
changes the sent level either). `punktfunk_core::audio::apply_gain` now serves **both planes** with
a tanh soft knee above `SOFT_LIMIT_KNEE` (0.7, ≈−3.1 dBFS): C1-continuous, bounded by
construction, odd-symmetric, memoryless (zero added latency). Unity is a no-op inside the function
itself, so the default wire stays byte-for-byte identical. `capture_gain` rejects non-positive
values and caps at 8.0 (+18 dB). This buys headroom, not loudness — it is deliberately not a
compressor, and the docs say so. `SOFT_LIMIT_KNEE` is excluded from cbindgen on purpose.
### Windows binaries are signed by Azure Artifact Signing
Account `unomsigning`, profile `unom-io`, signed by a service principal holding only the
profile-scoped signer role. Azure mints a **per-request leaf that expires in ~3 days**, which
changes two rules: a timestamped countersignature is now *mandatory* (the old retry-without-
timestamp fallback is a hard failure in Azure mode — it would ship an artifact that goes untrusted
days later, everywhere at once), and leaf pinning is structurally impossible (the updater's
`AUTHENTICODE_SHA256` note claiming otherwise is corrected). `pack-msix.ps1` reads the signature
back off the packed `.msix` and fails on Publisher drift. Driver catalogs are deliberately
untouched: they keep the `DRIVER_CERT_*` cert and the installer still plants it as a machine root
(PnP trust is independent of SmartScreen/UAC trust). Canary and fork builds keep the `.pfx` and
ephemeral fallbacks.
### Library: a launcher the host cannot open no longer costs the whole sync
`valid_launcher_ui` conflated vocabulary with environment. It is now split: `known_launcher_ui`
(an unknown launcher kind is a plugin bug — still a hard 400) and `resolvable_launcher_ui` (the
launcher just is not installed on this box — the entry is dropped with one warn and the games
sync). Same shape as the unservable-cover fix, on the launch side. And Playnite is actually
findable now: the old lookup read the LocalSystem service's own HKCU and `%LOCALAPPDATA%` (the
SYSTEM profile — a per-user Playnite is invisible there) and matched a registry key name Inno Setup
never writes. Now: every loaded hive under `HKEY_USERS` plus both HKLM views, matched on
`DisplayName`, then `C:\Users\*\AppData\Local\Playnite`.
### Hyprland/sway capture: six defects, all ours, and streaming now survives past one session
The wlr portal route looked environmental and never was. Measured on Hyprland 0.55.4 +
xdg-desktop-portal-hyprland 1.3.12, fixed in one arc (#240):
- **The dmabuf pod offered `BGRx`; xdph offers `BGRA`.** The modifier lists intersect perfectly,
the fourcc never does, so PipeWire failed the link itself (`no more input formats`) — and the
pods live only in the PipeWire *daemon's* log, which is why it read as a GPU/modifier problem.
- **A per-cast tokio runtime orphaned ashpd's process-global D-Bus connection.** ashpd caches its
connection in a `OnceLock`; the first cast's runtime hosted zbus's reader task and then died
with the cast, so the first stream of a host process worked and every later one went black.
Both wlr backends now share one long-lived portal runtime.
- **Teardown removed the captured output before closing the cast**, and xdph spun on the wreckage;
the order is now cast-then-output.
- **A hung portal handshake leaked its thread** and the leak poisoned every later cast; the
handshake is now bounded.
- **The wlr absolute-motion injector aimed at the operator's head**, never the streamed one; the
pointer is now bound to the streamed output.
- **The cursor park schedule read a missing cursor overlay as a lost pointer** — an Embedded-mode
portal never sends one.
### Everything else an integrator might notice
- **vdisplay/KDE:** a bare-spawn gamescope session under an exclusive topology now darkens the
physical panels over `org_kde_kwin_dpms` (new in-process `kwin_dpms` module,
`kscreen-doctor --dpms` fallback), refcounted host-wide so concurrent spawns compose; DPMS is
non-persistent, so a dead host leaves nothing to journal. Managed and Attach routes untouched.
- **macOS client:** `Settings::inhibit_shortcuts` is finally implemented on Apple — a local
keyDown monitor claims every ⌘ chord while input is captured and forwards it host-side (AppKit
dispatches menu key equivalents before the stream view sees them, so ⌘Q used to quit the
client). ⌘⎋ and ⌃⌘F stay client-side; ⌘Tab/⌘Space/Mission Control are out of reach without a
CGEventTap. Chord matching no longer compares Caps Lock and `.function`/`.numericPad` bits raw.
- **Android client:** `Gamepad.padButtonBit` resolves a gamepad-sourced `KEYCODE_BACK` to
`BTN_BACK` — pads that report Select as plain BACK (the Android-TV shape) no longer quit the
stream on one press, and the Select chords (exit chord, mic mute, stats tier) become reachable
on exactly those pads. `FLAG_FALLBACK` events stay excluded.
- **CI:** Android canaries now feed Play **open testing (beta) and closed testing (alpha)** from
one Play edit (`play-upload.py --also-track`); tags still publish production only, and a manual
`android.yml` dispatch can now opt into publishing (`publish=true`), so a lost merge run is no
longer a dead end. Windows
runners provision the .NET 8 runtime and a machine-wide signing client (a mixed-mode dlib with
no runtime makes signtool exit 3 in silence).
60 commits since v0.28.0.
A patch release in the strict sense: **nothing on the wire, in the C ABI, in the driver protocol or
in the plugin contract moves.** Every host, client, driver and plugin built against v0.28.0 keeps
working against v0.28.1 and vice versa, in both directions and with no re-pairing.
### Versions
| | v0.28.0 | v0.28.1 | Notes |
|---|---|---|---|
| Wire protocol | 2 | **2** | unchanged |
| C ABI | 19 | **19** | unchanged — `include/punktfunk_core.h` is byte-identical to the v0.28.0 tag |
| Rust edition | 2024 | **2024** | unchanged |
| MSRV (`rust-version`) | 1.85 | **1.85** | unchanged |
| Workspace crate dirs | 27 | **27** | unchanged |
| Virtual-display driver protocol | 6 | **6** | unchanged (minimum accepted still 3) |
| Windows virtual-gamepad channel | 3 | **3** | unchanged |
| Plugin index schema | 1 | **1** | unchanged |
| `api/openapi.json` | 0.27.0 | **0.28.0** | the management API **did** change (two collection deletes, below); the file carries the stamp it was regenerated under, not `0.28.1` |
| gamescope patch level (`+pfhdrN`) | 6 | **7** | 8 patches → 9 (the linger crash); no new capability |
| `@punktfunk/host` (SDK) | 0.1.4 | **0.1.4** | unchanged |
| `@punktfunk/plugin-kit` | 0.4.1 | **0.4.1** | unchanged |
**The `api/openapi.json` stamp is not a per-release counter** and should not be read as one. The
drift test (`openapi_document_is_complete_and_checked_in`) normalizes `info.version` on both sides,
so only the *surface* is gated and a version bump alone never invalidates the snapshot. The table
row says what the file actually says. Regenerating it needs a Linux or Windows host build —
`punktfunk-host` does not compile on macOS.
### ⚠ Breaking changes
**None.** No wire change, no C ABI change, no driver-protocol change, no plugin-contract change.
Three things are worth an embedder's or packager's attention anyway, none of which break a build:
- **The Rust crate gained one public constant.** `punktfunk_core::client::FLUSH_COOLDOWN` was
`pub(crate)`; the host now compares against it rather than against a copy of the number (see the
keyframe-cadence fix below). Addition only.
- **`NativeBridge.nativeStartAudio` takes a third argument** on Android — `isTv`. Detail in the
Android section; this is a JNI signature change, so an out-of-tree caller must pass it.
- **Every Linux packaging channel now ships a second gamescope artifact**, the Vulkan WSI layer,
and a package that carries the compositor without it is *fatal* rather than degraded. If you
repackage `punktfunk-gamescope` downstream, read the gamescope section before rebuilding.
### The management API gains two collection deletes — "unpair all"
Clearing a host's trust store meant one row-level delete per device, each with its own
confirmation. Two new endpoints, one per pairing plane:
```
DELETE /api/v1/clients -> {"unpaired": N}
DELETE /api/v1/native/clients -> {"unpaired": N}
```
They are **not** a loop over the per-fingerprint deletes. Each empties its store in ONE persisted
write, because N deletes would rewrite and atomically rename the store N times and a failure
partway leaves a half-emptied store with nothing saying which half. The two planes are separate
endpoints because they own separate trust stores with separate persistence and separate revocation
duties.
Being collection deletes, they carry the single delete's revocation guarantees across the whole
set: a live session owned by any removed certificate is ended, and on the GameStream side the ENet
control port (UDP 47999) closes, because no pairing is left to hold it open.
**200 with a count, not the single delete's 204/404.** "Unpair everything" is idempotent — an
already-empty store satisfies it — and the count still distinguishes three devices from none.
**Both are admin-token only.** The route-classification gates match on (method, path), so the
roster's plugin-readable `GET` does not carry over to emptying it; both new routes have explicit
rows in the table, like every other pairing-administration route. The native endpoint answers
**503** on a host built without that plane, which is why the console calls only the planes that
actually have a row.
`UnpairAllResult` is the one new schema. `api/openapi.json` is regenerated;
`docs-site/public/openapi.json` is re-synced from it (see **Documentation** at the end).
### The pad-audio "Wireless Controller" speaker hides while no client pad is attached
Field-confirmed (2026-08-14, the same Helldivers 2 reports as below): the per-pad audio endpoint
the Windows host mints — a Steam-Streaming-Speakers instance stamped with a DualSense's name,
container and 4 ch/48 kHz formats, **pre-provisioned at every host start** — is deliberately
indistinguishable from a real DualSense speaker. That disguise is the feature during a pad
session (libScePad titles route haptics audio at it) and a trap the rest of the time: an idle
Helldivers 2 finds the endpoint by identity, engages its DualSense-haptics path against a device
nothing services, and drops to 25 FPS 1% lows — with the host completely idle, no controller
plugged in, and no session ever run. The reporter isolating "the DualSense speaker" and disabling
it in mmsys.cpl restored full performance; that manual remedy is now automatic.
The endpoint now parks **hidden** (`DEVICE_STATE_DISABLED`, via `IPolicyConfig::
SetEndpointVisibility` — the exact call behind mmsys.cpl's Disable) whenever no client pad is
attached: provisioning hides it at startup (and a `PUNKTFUNK_PAD_AUDIO=0` host hides leftovers
from earlier runs), the per-pad streamer shows it for exactly the pad's lifetime — to a game,
indistinguishable from a DualSense arriving and leaving. The devnode, driver binding and stamps
stay put, so the flips raise no PnP traffic and the expensive provisioning still happens once at
boot.
**Operator-visible:** "Speakers (Wireless Controller)" now shows as *disabled* in the Sound
control panel while no client pad is connected — that is the parked state, not a defect. The
`pad-endpoint` devtest grew `show`/`hide` verbs; `tone`/`capture` need a `show` first.
### An idle Windows host no longer owns the box's default microphone
Field report (the second Helldivers 2 one — the first led to v0.28.0's mint-retry fix): with the
host **idle**, a locally played Helldivers 2 tanks to 25 FPS 1% lows, and Windows' own Sound
settings Recording tab goes unresponsive. Root cause: the audio wiring pass asserted *default
recording = the virtual mic's capture side* on **every** pass, including the mic pump's eager
boot pass — and `SetDefaultEndpoint` covers eCommunications, so every game's voice input bound a
virtual microphone whose feeder only runs during a stream. Nothing ever restored it: not session
end, not service stop. Games that hold an always-open voice capture (Helldivers 2 is Wwise +
in-game voice — its own wiki calls the game "finicky with audio devices") stall on that dead
endpoint.
The recording default is now **session-scoped**, exactly like the playback default has always
been: parked on the virtual mic only while a desktop-audio capture is open, the operator's device
remembered (plus an on-disk crash marker, `audio-default-rec.prev`), restored when the capture
closes, recovered at next boot after a crash, and unparked by the uninstaller. A game launched
*during* a stream still records the client's mic; one launched before the stream keeps the
operator's own microphone.
Boxes wedged by earlier builds (which recorded nothing to restore) heal themselves: an idle
wiring pass that finds the default recording sitting on the plan's mic capture moves it back to
the first real microphone.
**Operator-visible:** outside a stream, the default recording device is now whatever you set —
Punktfunk only takes it for the duration of a stream. If you *want* apps to record the client mic
while idle, select "Punktfunk Microphone" manually; the host no longer re-asserts it (idle
re-assertion used to stomp a manual choice within one mic-pump reopen).
### The NixOS module started a second host in root's systemd, which stole the ports from the real one
Found on the first real deployment of `packaging/nix/nixos-module.nix` (NixOS 26.05, punktfunk
0.28.0-nix). The host crash-looped forever on one line:
```
ERROR punktfunk_host: start RTSP server: bind RTSP 48010: Address already in use (os error 98)
```
`systemd.user.*` has no per-user form in NixOS: it installs units into **every** user's systemd
manager. `host.autoStart` then adds them to `default.target` — for every user, including **root**,
whose `user@0.service` springs into existence the moment anybody so much as SSHes in as root. Root's
copy of the host won the race for the fixed ports, and the desktop user's copy could never bind.
The failure is nastier than it sounds because every *other* listener binds first and logs success —
the version banner, mDNS on 47989, the GameStream warning all print normally — so the log reads like
a conflict with some unrelated program. A second copy of *itself*, running as root, is the last
thing anyone looks for. `host.users` did not help: that option only granted `input`/`punktfunk`
group membership and never scoped the units.
Fixed by rendering `ConditionUser=` on all four user units (`punktfunk-host`, `punktfunk-web`,
`punktfunk-web-init`, `punktfunk-scripting`) from `host.users`. Each entry is written `|user` — the
pipe makes it a *triggering* condition, which systemd ORs; plain repeated `ConditionUser=` lines are
ANDed and would have matched nobody. With `host.users` empty the units fall back to
`ConditionUser=!@system`, which still keeps root out while leaving a normal login free to run the
host by hand, as the module header documents.
`packaging/nix/module-check.nix` gained three assertions covering both branches and the fact that
`punktfunk-web-init` keeps its pre-existing (non-triggering) `ConditionPathExists` alongside the new
condition. They run in the `eval` leg of `nix.yml`, and were verified to fail against the unfixed
module before being committed.
### The Steam plugin synced nothing on Windows: its art is in Program Files, the art roots were not
Field report — the plugin installed, the grid stayed empty, and the only clue was one host warn per
sync:
```
plugin:steam sync (fs-change) failed: HostRequestError: PUT /library/provider/steam?store=steam
failed: art.hero: local art must be an image file (…) inside an allowed art root
```
Two independent defects, both fixed here.
**1. Steam's art was never inside an allowed root on Windows.** `art_roots()` defaulted to the users
base (`C:\Users`, from `%PUBLIC%`'s parent), which covers the launchers that install per-user —
Playnite under `%APPDATA%`, Heroic under `%APPDATA%` — but *not* Steam, which installs to
`C:\Program Files (x86)\Steam` and keeps both the art the plugin publishes there:
`appcache\librarycache\<appid>\<hash>\` and each account's `userdata\<id>\config\grid\` overrides.
Every cover the plugin emitted was out of root. This is a v0.28.0 regression: the built-in scanner
the plugin replaced served its covers through the legacy `steam:` art-proxy branch, which never
passed through the H-2 confinement — deleting the scanner routed that art through a gate it had
never been measured against. `art_roots()` now also includes every Steam install root it can find,
from `%ProgramFiles(x86)%` / `%ProgramFiles%` / `%ProgramW6432%` and from HKLM
`Valve\Steam\InstallPath` (so a Steam on another drive is covered too). POSIX needed no equivalent —
every Steam layout there, native and Flatpak, is already under `$HOME`.
This does not weaken the confinement. It exists to stop the host (SYSTEM) reading files the plugin
lane (LocalService) cannot reach itself; the Steam directory is readable by LocalService already, so
nothing there is reachable *because* the host is privileged. The extension, regular-file, magic-byte
and config-dir gates all still apply, so Steam's own `config.vdf` and `ssfn*` credential blobs are
not servable from it — there is a test.
**2. One unservable cover threw away the entire library.** `PUT /library/provider/{p}` validated art
per entry and returned 400 for the whole payload on the first bad value, so a path mismatch cost the
operator *every game from that store*, not a thumbnail — and the plugin, which only ever sees
`HostRequestError`, could not say which. A provider reconcile now **strips** unservable local art and
syncs the rest (`sanitize_art_paths`), logging one aggregated warn naming the count, an example path
and the env var. The invariant the 400 held is unchanged: no unservable path is ever persisted. The
operator's own single-entry custom writes keep the hard 400 — there the path was typed by hand, and
silence would be the wrong answer.
**Operator-visible:** an art-root mismatch no longer fails a sync. If covers are blank where you
expect art, the cue is the host log's `dropped local art the proxy may not serve` line, and the knob
is `PUNKTFUNK_LIBRARY_ART_ROOTS` (which **replaces** the defaults — list every root you need).
### Hyprland/Sway — the wlr-family backends asserted a cursor mode instead of negotiating it
🛑 **Every cursor-forward session on current Hyprland died at `select_sources`** — "pipeline build
failed" and a black client, with `unavailable cursor mode 4` in the portal log.
Hyprland and wlroots both hardcoded portal `CursorMode::Metadata` whenever the session had
negotiated the cursor channel, and never asked the backend what it supports. That is **not** a soft
failure: xdg-desktop-portal's **frontend** validates the requested mode against the backend's
`AvailableCursorModes` and fails the call with `"Unavailable cursor mode %x"` before the backend
ever sees it.
**Measured on glass 2026-08-14, and worse than the report suggested.** Against a live Hyprland
0.56.2 with xdg-desktop-portal-hyprland 1.4.1 and xdg-desktop-portal 1.22.1 — all current —
`AvailableCursorModes` reads **3** (`Hidden|Embedded`) on both the backend impl interface and the
frontend. **xdph does not offer the metadata cursor at all**, so this broke every cursor-forward
session on current Hyprland, not merely on old installs, and **updating the portal would not have
helped.** xdpw is the same from the other end: its `screencast.c` refuses `METADATA` outright.
`pf-capture`'s own portal path has always negotiated (`choose_cursor_mode`); this restates that
ladder in `pf-vdisplay`, which may not depend on `pf-capture`. The downgrade is graceful rather than
merely survivable: with the portal on `Embedded` no `SPA_META_Cursor` arrives, so the host feeds the
cursor channel nothing and a cursor-forward client draws nothing of its own — **one pointer, not
two.**
**`PUNKTFUNK_PORTAL_CURSOR_MODE=auto|hidden|embedded|metadata`** pins the preference for a backend
that advertises a mode it implements badly, which negotiation cannot detect. It is a preference
only: a pin runs the same ladder, so no value can re-create the refused request.
⚠ The module is declared **unconditionally**, so its ladder tests run on every CI leg rather than
only the one that compiles `mod hyprland` — including a Linux-only test pinning our bit values
against ashpd's enum (ashpd answers 4 for `Metadata`, the number in the report), verified
non-vacuous by planting a wrong discriminant.
### Android — the audio plane trusted AAudio, and a TV box that opened a stream it never played was silent for the session
🛑 **Reported from the field: no audio at all on an NVIDIA Shield Android TV, stereo, with the same
host and settings that play fine on an Apple TV.** Video unaffected. Turning off the client's
low-latency mode — which is what gates the forced HDMI mode switch and the `usage=Game` tagging —
changed nothing.
The Android client opens AAudio directly (the Apple client goes through AVAudioEngine, which
reconfigures itself on a route change; that difference is why this was Android-only). Opening
AAudio is a negotiation with a vendor HAL, and this plane treated it as a formality: one Exclusive
attempt, one Shared retry, and everything after the open taken on trust. **Three distinct failures
all presented as "the app has no sound" behind a healthy-looking log**, and none of them was
detected:
- **A configuration that opens but routes nowhere.** Nothing ever checked that the device actually
pulled a sample, so the decode thread would happily decode Opus into a dead stream forever.
- **`request_start` failing.** The old code gave up on the spot instead of trying anything else, so
one unhappy configuration disabled audio for the whole session.
- **A disconnect.** By AAudio's contract a disconnected stream is dead and the only recovery is
close + open a new one. The error callback logged a warning and did nothing else — so an HDMI
mode switch, an AVR re-handshake or any route change meant silence for the rest of the session.
On a TV that is not a rare event: the client itself drives an HDMI mode switch on the video
plane, and the platform's own match-content-frame-rate setting drives more.
The open now walks a **ladder**, every rung has to **prove the device is pulling** before it is
accepted, and a **supervisor** owns the plane for the session and reopens it when the device goes
away (bounded retries across the settling time of a route change, so a reopen landing mid-switch
does not permanently disable audio). The granted rate/channel-count/format are checked against what
was asked for rather than assumed — the realtime callback casts AAudio's buffer to `f32` and writes
`num_frames × channels` of them, so a HAL that disagreed was an out-of-bounds write on the audio
thread, not merely a mistuning.
**Behaviour change on TV boxes: they now start at Shared instead of Exclusive.** Exclusive is
MMAP, the lowest-latency path AAudio has, and the one rung whose routing cannot be verified from
inside the process. The latency it buys here was never actually banked — the jitter-ring depths are
unchanged from the Shared-only era (`JitterTuning::AAUDIO` still primes at 25 ms) — so on a
mains-powered HDMI box the few ms are worth less than not betting the audio plane on it. Phones,
tablets and handhelds are unchanged and still try Exclusive first. If no rung proves itself, the
first one that opened and started is used anyway: the watchdog must never be able to turn working
audio into no audio.
**Embedder-visible:** `NativeBridge.nativeStartAudio` takes a third argument, `isTv`
(`FEATURE_LEANBACK`, the same source the video plane already used).
Three new sysprops bisect all of it on a device that cannot be handed a custom build, alongside the
existing `debug.punktfunk.no_av_sync`: `debug.punktfunk.audio_sharing` (`exclusive`|`shared`),
`debug.punktfunk.audio_perf` (`lowlatency`|`none`) and `debug.punktfunk.audio_reopen` (`0` pins the
old give-up-on-disconnect behaviour). A stream that stops taking samples after it started now says
so at `error` level instead of looking exactly like an app with no sound.
### gamescope — we ship our own Vulkan WSI layer, so a game can reach an HDR10 swapchain (⚠ packager-visible)
🛑 **On essentially every box running a distro gamescope, no game could render HDR at all** — and
nothing said so.
A game nested under gamescope gets an HDR10 swapchain from the FROG WSI layer and from nothing
else: gamescope advertises no runtime colour-management protocol a Mesa/NVIDIA WSI could negotiate
through. That layer speaks `gamescope_swapchain` to the compositor, and when the two disagree the
compositor rejects the client's `swapchain_feedback` and **every Vulkan client dies on a black
screen** with sound and input intact and no error anywhere.
We shipped our own compositor and *not* a layer, on the recorded grounds that the layer is
"version-independent of the compositor binary". It is not — `wsi_layer_matches_our_gamescope()`
exists precisely because it is not — so the host was left guessing from version triples, and that
guess is wrong in both directions. A distro at the same upstream tag that patched the protocol
compares EQUAL and keeps a layer that will black-screen every game; a distro at a different tag
with a byte-identical protocol compares unequal and loses HDR for nothing. **Since we pin a rev,
the second case is the normal one.**
We now build the layer from the same tree at the same rev as the compositor and ship it, so the two
cannot drift and the guess stops being load-bearing. It installs under **our own** name
(`VK_LAYER_PUNKTFUNK_gamescope_wsi`), at our own path, with our own enable/disable variables, so it
coexists with the distro's rather than colliding — the Vulkan loader keys implicit layers on that
name — and the host switches the two independently within one session.
`WsiPlan` resolves three states once per launch (the fallback spawns `--version` probes):
| state | condition | action |
|---|---|---|
| `Ours` | our layer is installed | enable ours, force the distro's off — **both halves, or it is a bug** |
| `DistroKept` | no layer of ours, distro's looks compatible | touch nothing |
| `DistroDisabled` | no layer of ours, distro's untrusted | v0.28.0's behaviour |
That last arm is the fail-safe: a host newer than its gamescope package behaves exactly as it did,
rather than enabling a layer that is not there.
**What packagers must know.** The layer manifest carries an **absolute** `library_path` baked in
at build time, so every channel installs the `.so` at exactly that path: literal
`/usr/lib/punktfunk`**not** `%{_libdir}` (which is `/usr/lib64` on Fedora) and not a Debian
multiarch triplet. Nothing links it by soname (the loader `dlopen`s it by that path), so multilib
has no claim. rpm and nix read the path back **out of the manifest** and fail if it names a file the
package does not install, because a manifest pointing at nothing is the silent shape of this bug.
A missing layer is **fatal in every channel**, not best-effort: a package carrying the compositor
without it looks completely healthy and then silently denies every game an HDR10 swapchain.
The packaging scripts now take `--stage` (the DESTDIR the gamescope build script wrote) instead of
a path to one binary, and CI caches the whole staged tree; the `gs-cache` key already hashes
`packaging/gamescope/**`, so stale caches in the old single-file shape cannot be restored into the
new layout. The manifest rewrite lives in `packaging/gamescope/rewrite-wsi-layer-manifest.py`
rather than a heredoc, because the FHS builds and the Nix store both need it and must rename the
layer identically. **NixOS has no `/usr`**, so the layer lives inside the gamescope derivation and
the host's path is overridable with **`PUNKTFUNK_GAMESCOPE_WSI_LAYER_DIR`**, which the module sets
— the same posture as `PUNKTFUNK_GAMESCOPE_BIN`.
### gamescope — HDR sessions anchored SDR white a stop bright, and never said game HDR was unreachable
🛑 **Field report: Steam's Big Picture UI glaring and over-saturated while HDR game content looked
washed out, on the same stream.** Those are one error.
gamescope maps everything that is not an HDR game — the desktop, the Steam overlay, an SDR title —
into the session's PQ container at `--hdr-sdr-content-nits`, and we passed that flag **only** when
an operator had set `PUNKTFUNK_GAMESCOPE_SDR_NITS`. Unset, gamescope used its own default of
**400**, while every first-party client anchors diffuse white at **203** (BT.2408 reference white;
the Apple presenter hands exactly that to `CAEDRMetadata.hdr10`'s `opticalOutputScale`). The two
ends sat nearly a stop apart, so the UI landed above SDR white and the client's tone-mapper worked
from a reference point the host had never used, flattening the content around it.
**The flag is now always passed, defaulting to 203.** `PUNKTFUNK_GAMESCOPE_SDR_NITS` still
overrides it for anyone who wants a brighter or dimmer desktop — it is the anchor, not a taste
knob. ⭐ Because it is an env var, a field A/B needs **no rebuild**.
Separately, and visible in the same log: the two HDR decisions in a gamescope session were made
independently. `hdr_args()` never consulted `wsi_layer_matches_our_gamescope()`, so when the layer
check fired the session launched **advertising HDR while having made an HDR10 swapchain
unreachable for every game in it** — a title told to render HDR rendered it into an SDR swapchain
and looked washed out, with nothing anywhere saying why. It now warns. The behaviour of the check
itself is deliberately unchanged; the section above is the real fix.
### punktfunk-gamescope `+pfhdr7` — a lingered session no longer dies of its own capture teardown
🛑 **On client disconnect the host keeps the headless gamescope alive so a reconnect resumes the
same session — and gamescope could SIGSEGV in exactly that window, so the kept display was dead and
reconnect silently got a fresh compositor with the game lost.** When the capture consumer leaves,
PipeWire's `remove_buffer` (and the stale-push path in `dispatch_nudge`) destroyed idle buffers on
the **PipeWire thread**; dropping the last `CVulkanTexture` reference there calls into the Vulkan
driver (`vkDestroyImage`/`FreeMemory`/dmabuf fds) while steamcompmgr can still be inside
`vulkan_screenshot` on another buffer of the same 4-buffer pool. On NVIDIA that races to a SIGSEGV
in `CVulkanCmdBuffer::insertBarrier` — timed at stream end, which is why it selectively killed
linger. The journal signature: linger line → coredump → `kept display was dead — recreating`.
Patch 0009 queues those corpses on the PipeWire thread and has steamcompmgr reap them on every
vblank — including while the stream is paused, which is precisely the linger state. Found, fixed
and proven live by **luxus** ([punktfunk-overlay#9](https://github.com/luxus/punktfunk-overlay/issues/9)):
four coredumps on 4K60 HDR + composited cursor, zero after; disconnect/reconnect now reuses the
lingered session. Banner `+pfhdr6``+pfhdr7` (no new capability — but "reconnect lost my game"
triage must be able to read a box's exposure off its banner, the same rule as `+pfhdr5`/`6`).
### Apple — the stats overlay lied three ways, and every host-anchored number with it
🛑 **Two sessions minutes apart on the same wire read `hostnet_p50` 1721 ms, then a physically
impossible 4.4 ms** — host-side encode alone is ~4.7. Three independent defects, all of which
corrupt any measurement taken against a host clock:
- **A frozen clock-offset.** The client consumed the **connect-time** skew offset and cached it —
in a `Stage2Pipeline` field, in a `StreamPump` `let`, and in a `ContentView` closure **capture
list** feeding the hostnet meter and the host/network splitter. The core keeps a *live* estimate
(`punktfunk_connection_clock_offset_now_ns`, ABI v10, re-synced every 60 s and on suspected
wall-clock steps) whose own doc says the connect-time value "silently corrupts every
capture-clock comparison" after an NTP step — **and a VM host steps.**
`PunktfunkConnection.clockOffsetNs` is now the live read (an atomic load behind the FFI), read at
use: per record, per AU, per enqueue. The Swift audio plane's AvSync observation takes the same
live value.
- **Silently trimmed impossible samples.** `LatencyMeter`'s guard (≤ 0 after offset correction)
dropped samples without counting them, so a wrong offset did not invalidate a window — it trimmed
the impossible half of the shifted distribution and presented the surviving tail as a plausible
small number. That is the origin of the historical "0 ms network / 0 ms e2e" readings. Refusals
are now counted and drained **separately from `Stats`** — deliberately, because a fully-poisoned
window drains to `nil` and a count inside `Stats` would vanish with it. The HUD shows an orange
**`clock offset suspect`** line and the stats line grew **`skew_trim=N`**; nonzero means
disregard `e2e`/`hostnet` for that window.
- **`-1` fallbacks printing as `NaN`.** In a `CVarArg` context `cond ? someDouble : -1` does **not**
unify to `Double` — the literal goes in as `Int`, and `%f` reads `Int64(-1)`'s all-ones bit
pattern, which is a quiet NaN. Latent since the 1 Hz stats line existed. All fallbacks are now
typed `-1.0`.
**Any client-side e2e or hostnet figure recorded before this release is suspect** and worth
re-measuring rather than trusted as a baseline.
Two new levers ship with the tvOS present-floor investigation, both env-only:
**`PUNKTFUNK_FRAME_LATENCY`** (float 0…4, default 1) makes the `preferredFrameLatency` ask
adjustable, so an on-device ladder can establish whether the property does anything on tvOS — the
previous "immovable two-refresh floor" verdict rested on a **readback** of a plain read-write
float, which is not a grant. **`PUNKTFUNK_PRESENTER=stage1` now resolves on Release builds** (the
persisted picker stays DEBUG-gated; an env var takes a `devicectl`/Xcode launch to exist, so it is
never a leftover). Stage-1 presents on the hardware video plane rather than through the GPU
compositor — the one rung that can dodge the two-refresh regime — and the field A/B that concluded
otherwise had silently run stage-4, because the gate keyed on build config.
### Apple — two colour faults: an SDR stream shipped untagged, and it forced the TV into HDR10
- **The SDR layer was never tagged.** `configure(hdr:)` guards on `hdr != hdrActive` and
`hdrActive` starts `false`, so a session that is SDR from its first frame matched the initial
state, fell through the guard, and `configureColor` never ran once — the layer kept `make()`'s
bare configuration, which assigns no colour space. An untagged `CAMetalLayer` gets no colour
matching: a BT.709 stream is drawn in the display's native space. Mild oversaturation on a P3 Mac
or iPad; on a tvOS display composited for HDR it also lifts the black floor. ⚠ It also made
`PUNKTFUNK_SDR_COLORSPACE` **dead code on exactly the sessions it exists to fix**, so a field A/B
of that knob would have shown no change.
- **An SDR stream drove an HDR-capable TV into PQ output.** `applyDisplayCriteriaIfNeeded` builds a
synthetic format description hardcoding BT.2020 primaries, ST.2084 and the BT.2020 matrix, then
hands it to `AVDisplayManager` — and its guard checked only that no criteria had been set and that
the user's HDR *setting* was on, never that **the stream** was HDR. That setting defaults to true.
The Apple TV switches HDMI to limited range in its HDR modes, so a set configured for full range
renders code 16 as grey rather than black. Now gated on `connection.isHDR` as well; layout re-runs
it, so a session that flips to HDR mid-stream still picks the mode up.
### Apple — the macOS device-change recovery could answer itself forever (mic on)
**Streaming from a Mac with the microphone enabled cut audio AND input on a ~2.5 s metronome
while video ran untouched** (field, 2026-08-14: a Mac Studio whose default input is a 6-channel
device). The chain: the voice-processing engine cannot start on that mic, every rebuild re-tried
it, and the failed attempt's HAL churn (VPIO builds and tears down an aggregate device) stopped
the healthy fallback engines — which posted the `AVAudioEngineConfigurationChange` that scheduled
the next rebuild. Each ~1.9 s rebuild runs on the main thread, where macOS input capture and
sending live, so input froze on the same beat — and since audio, input and mic share the QUIC
datagram plane while video rides its own socket, the wire signature read as a network fault and
the host's METRONOMIC heuristic pointed at the display stack. Three defenses, layered because no
single one covers every feedback shape:
- **A voice-processing start failure latches per input device** (`CombinedTopologyGate`): a
rebuild goes straight to the split topology instead of re-running a failure that is a property
of the device. A different default input earns exactly one fresh attempt.
- **A configuration change posted by an engine that is RUNNING is the rebuild's own echo, and is
ignored**: an engine stops itself before posting, so a live poster was already restarted.
- **Rebuilds that chain anyway back off exponentially** (`RebuildBackoff`: 0.5 s floor doubling
to a 30 s cap, reset by 10 s of quiet) — an unforeseen loop costs one blip per half-minute
instead of a metronome, and the chaining itself logs a WARN that names the condition.
iOS/tvOS behaviour is untouched (routes are session-managed there; nothing is latched). Until a
client carries this, the field workaround is turning the client microphone off.
**And the engines no longer start on the main thread at all.** An engine start can block on the
audio server for seconds (~1.9 s per attempt in the field case) and macOS captures and sends the
stream's input from the main thread — so even a single legitimate device switch froze input for
the length of the rebuild, loop or no loop. All engine build/start/teardown now runs on a
per-session serial `engineQueue`; the main queue keeps only the trigger bookkeeping (debounce,
backoff, retry ladder), which is cheap by construction. ⚠ Embedder-visible edge:
`SessionAudio.start()` is now asynchronous on macOS too (it always was on iOS/tvOS) — playback is
live shortly after the call, not on return, and `stats` is safe from any thread.
### Apple gamepad UI — a host menu, and About becomes a page
**UP on a saved tile opens Wake / Copy link / Edit… / Forget pairing / Remove.** The desktop and
Android consoles have had this for a while; this is the Apple port, so the three consoles are
learned once. Wiring UP takes the whole vertical axis away from scrolling (down goes inert) — a
horizontal carousel has no vertical travel to spend, and one meaning per direction is what makes
the gesture learnable. **Remove arms on the first press and fires on the second**, disarming if
focus wanders off the row: the touch grid gets a system confirmation dialog, and a thumbstick from
across a room deserves at least as much. Edit reuses `GamepadAddHostView` seeded from the record and
writes a **copy** back through `HostStore.update`, so the fingerprint, MACs, pins and binding the
form never shows survive a rename; it **replaces** the menu rather than stacking on it, keeping the
shell's "depth ≤ 1 by construction" true. A pinned profile card offers only Unpin — it is a
shortcut, not a second host.
**The start-of-stream shortcut banner is retired.** Telling someone the controls for six seconds,
over the stream they just connected to, answers the question at the one moment nobody is asking it
— and it put a composited overlay above the stream to do it. The words are now a catalogue rendered
in an About page you can open, which is also its own section rather than the last row of Interface.
Its remaining fixes: the identity card became a version line under the rows, a zero-radius clip is
still a clip (it cropped the TV's wide icon), and the card ignored the row column.
**Apple console screens read the ink they publish.** A SwiftUI screen cannot read the environment
value it publishes in the same view — so a pale palette stayed white-on-white on Apple TV. Fixed
across every console screen.
### Console UI — Skia sized its function table to the loader, not to what we promised
🛑 **On a Steam Deck the console home died on update**, and in a stream the same failure quietly
cost the stats OSD and capture HUD.
The skia-safe 0.87 → 0.99 move swapped `BackendContext::new` for `new_builder(…, None)` and
recorded the `None` as "byte-for-byte what the removed constructor did". True of the **value**,
false of the **behaviour**: `None` leaves Skia's `fMaxAPIVersion` at its `0` sentinel, and the newer
Skia acts on that sentinel by falling back to **`vkEnumerateInstanceVersion()` — the loader's
ceiling, not ours.** The presenter declares 1.3; a current Mesa answers 1.4 (1.4.321 on SteamOS
3.7, host and inside the flatpak sandbox alike). Skia then validates a 1.4 function table against an
instance that only promised 1.3, `vkGetDeviceProcAddr` returns null for the entry points in
between, and `make_vulkan` hands back `None`. At 0.87 the sentinel was inert because that Skia knew
nothing of Vulkan 1.4 — **which is why this surfaced the moment v0.28.0 landed.**
`run.rs` makes an overlay that cannot init fatal for `--browse`, so the Decky panel's button and the
gamepad-UI library shortcut both failed to open. The presenter now publishes
`SharedDevice::api_version``min(what we declared, what the loader reports)` — and
`SkiaOverlay::init` passes it instead of `None`. ⚠ `pf-presenter`'s `vk` module is
`cfg(any(linux, windows))`, so this was never Deck-specific.
### pf-vkdecode — AV1's "maximum parameters" level is not a level above the ceiling
🛑 **Every AV1 session demoted to D3D11VA** with `stream level (seq_level_idx 31) above the device's
maxLevel (AV1 Std level 23)` — on hardware decoding the stream trivially on the rung it fell
through to.
`seq_level_idx` is a 5-bit field: Annex A defines 0…23 (levels 2.0…7.3), reserves 24…30, and makes
**31 the "maximum parameters" level — the spec's own way of saying the bitstream is not constrained
to a level.** `StdVideoAV1Level` stops at 7.3 = 23, so 31 has no Std code point and the index-coded
comparison that holds across 0…23 says nothing: `31 > 23` is true even of a device that decodes
everything AV1 can name, which is what makes it useless as a capability test. We write no AV1 level
on any host encode path, so whichever sentinel the vendor's encoder defaults to is what the client
must accept. This is the AV1 half of the same defect fixed for H.264/H.265 in v0.28.0, which was
left alone on the premise that no over-declaration had been seen in the field — the reporter's log
from that same day already showed otherwise.
### Client stats — the stage line is a partition again
A field reader added up `host 5.4 · net 0.3 · decode 6.6 · display 1.4` against `e2e 8.1` and asked
why the parts did not sum. Fair question: they sum **without** `decode`.
The stages *are* a per-frame partition of e2e — pts →(host+net)→ received →(decode)→ decoded
→(display)→ displayed — for as long as the `decoded` stamp is a **completion** stamp. On the
synchronous rungs it is. On the **native-Vulkan** rung `receive_frame` returns at *submission*
(~0.1 ms) and the stamp is taken there, so `display` is measured from submit and the GPU decode
happens **inside** it. `host+net` and `display` already tile e2e; the `decode` figure (received →
fence-complete) re-counts the GPU work `display` contains — two figures with one overlap, printed
as though they tiled.
On that rung `decode` now leaves the stage line and gets its own, carrying the two caveats a reader
needs: it is **one sample per window** there, not the p50 every other figure on that line is, and it
is already inside `display`, so adding it double-counts. The synchronous rungs are untouched.
**Deliberately not changed:** the one-sample-per-window design. A per-frame fence wait serialises
the decode pipeline (an APU's 19 ms decode capping a 5120×1440 stream at ~51 fps) and polling
quantises every sample up by a frame interval. The reporting was the defect, not the sampling.
### Host — two warnings that named the wrong subsystem
Both fired in the same 2026-08-13 field log, and both sent an investigation somewhere innocent:
- **"Client keyframe recoveries are METRONOMIC — a periodic host/display disturbance … is the
likely cause"**, at `period_s=2.0`, naming three host subsystems. **2.0 s is the *client's*
`FLUSH_COOLDOWN`.** The receive-backlog guard sheds a standing queue with a flush plus a keyframe
request, rate-limited to one per cooldown, so a client that cannot sustain the stream asks for a
keyframe at exactly that spacing for as long as it stays behind. **Perfect periodicity is the
signature of a fixed software cooldown, not of a physical disturbance.** The host now compares
against `punktfunk_core::client::FLUSH_COOLDOWN` itself rather than a copy of the number, so the
two cannot drift.
- **"The audio encode thread could not keep up — captured audio was DROPPED"**, worst case
`dropped_chunks=11251`. Not one sample anybody wanted was lost. PipeWire negotiated a 128-frame
quantum, so the plane produces 48000/128 = 375 chunks/s and a 30 s window holds exactly 11250 —
a 100 % drop rate at `peak_db=-120.0`, digital silence. Every one of the ten warnings straddled a
**session boundary**, and `dropped_chunks/375` matches the seconds with *no live session* in that
window to within a fraction of a second. The warning no longer fires for idle seconds.
### NixOS — the plugin runner was installed, running, and reported missing
@@ -52,6 +805,51 @@ NixOS ships only `sh` in `/bin`, so `gamelease`'s hand-off test and `pyrowave_re
handshake-rung test failed there for reasons unrelated to the code under test. Both now resolve a
real binary rather than assuming an FHS path.
### Documentation
**`docs-site/public/openapi.json` was stale again, and by the same mechanism as last release.**
v0.28.0 fixed it once (it was five releases behind at `0.21.0`); the scanner-removal regen then
updated `api/openapi.json` alone and it drifted a second time inside that same cycle. It has now
drifted a third time, across the unpair-all endpoints — the docs-site copy was still stamped
`0.27.0` and missing both collection deletes. Re-synced; the two files are byte-identical again.
⚠ **The copy is a documented manual step (`cp api/openapi.json docs-site/public/openapi.json`,
CONTRIBUTING.md) and nothing in CI enforces it.** Three drifts in two release cycles is the
argument for gating it; until something does, **treat the copy as part of regenerating, not as a
follow-up.**
### Linux — the data-plane threads finally get the priority they ask for (⚠ packager-visible)
**On every Linux host to date, `pf_frame::thread_qos`'s per-thread renice was a silent no-op**
it needs CAP_SYS_NICE or a raised RLIMIT_NICE, no packaging channel granted either, and the host
binary can never carry a file capability (KWin identification, the 0.26.0-1 incident). So the
capture/encode and send threads ran at nice 0, and a CPU-saturating burst on the host — a fresh
game launch's shader-compile storm is the canonical one — descheduled them at will. A 2026-08-14
field log showed the result end to end: 5 ms audio datagrams leaving late enough to stutter, the
client's delay signal rising, and ABR cutting a gigabit-Ethernet session to its 5 Mbps floor with
zero packet loss — while the box carried 708 Mbps cleanly minutes later, once the storm passed.
**The renice now falls back to RealtimeKit** (`MakeThreadHighPriorityWithPID`, one blocking
system-bus call per boosted thread) — the same unprivileged broker PipeWire clients use, present
on effectively every desktop install. No capability enters the host's permitted set, so KWin
identification is untouched. Boxes with neither rtkit nor the new limit keep today's best-effort
no-op, one debug line per thread.
**The audio plane is boosted at all for the first time.** The 5 ms Opus capture→encode→send loop,
the PipeWire capture mainloop thread (its `process` callbacks run there — PipeWire's own
`module-rt` only covers data loops we don't use), and the pad-audio streamer now take the same
boost the video threads always asked for. The audio loop is `critical`: a scheduling stall there
is directly audible where a late video frame is one presentation slip.
**Packagers: a new `user@.service.d` drop-in.** rpm/deb/Arch (and the Bazzite sysext, via the
RPM) now ship `packaging/linux/50-punktfunk-nice.conf`
`/usr/lib/systemd/system/user@.service.d/50-punktfunk-nice.conf` (`LimitNICE=-15`), so the direct
`setpriority()` also works where rtkit isn't running. It raises a session *limit*, from the next
login — nothing is reprioritized by itself. The NixOS module instead sets
`security.rtkit.enable = lib.mkDefault true` (rtkit is not a given there). It remains true that
**no channel may ever grant the host binary a file capability** — this change is the sanctioned
route to the same end.
---
## v0.28.0
Generated
+37 -36
View File
@@ -1090,7 +1090,7 @@ dependencies = [
[[package]]
name = "cursor-probe"
version = "0.28.0"
version = "0.29.0"
dependencies = [
"anyhow",
"pf-capture",
@@ -1222,7 +1222,7 @@ dependencies = [
[[package]]
name = "display-disturb"
version = "0.28.0"
version = "0.29.0"
dependencies = [
"pf-win-display",
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
@@ -2343,7 +2343,7 @@ dependencies = [
[[package]]
name = "latency-probe"
version = "0.28.0"
version = "0.29.0"
[[package]]
name = "lazy_static"
@@ -2446,7 +2446,7 @@ dependencies = [
[[package]]
name = "libvpl-sys"
version = "0.28.0"
version = "0.29.0"
dependencies = [
"bindgen",
"cmake",
@@ -2475,7 +2475,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.28.0"
version = "0.29.0"
dependencies = [
"punktfunk-core",
]
@@ -2967,7 +2967,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pf-bitstream"
version = "0.28.0"
version = "0.29.0"
dependencies = [
"cros-codecs",
"tracing",
@@ -2975,7 +2975,7 @@ dependencies = [
[[package]]
name = "pf-capture"
version = "0.28.0"
version = "0.29.0"
dependencies = [
"anyhow",
"ashpd",
@@ -2996,7 +2996,7 @@ dependencies = [
[[package]]
name = "pf-client-core"
version = "0.28.0"
version = "0.29.0"
dependencies = [
"anyhow",
"ash",
@@ -3031,7 +3031,7 @@ dependencies = [
[[package]]
name = "pf-clipboard"
version = "0.28.0"
version = "0.29.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3049,7 +3049,7 @@ dependencies = [
[[package]]
name = "pf-console-ui"
version = "0.28.0"
version = "0.29.0"
dependencies = [
"anyhow",
"ash",
@@ -3071,7 +3071,7 @@ dependencies = [
[[package]]
name = "pf-dxvadec"
version = "0.28.0"
version = "0.29.0"
dependencies = [
"cros-codecs",
"pf-bitstream",
@@ -3081,7 +3081,7 @@ dependencies = [
[[package]]
name = "pf-encode"
version = "0.28.0"
version = "0.29.0"
dependencies = [
"anyhow",
"ash",
@@ -3107,7 +3107,7 @@ dependencies = [
[[package]]
name = "pf-frame"
version = "0.28.0"
version = "0.29.0"
dependencies = [
"anyhow",
"libc",
@@ -3115,11 +3115,12 @@ dependencies = [
"punktfunk-core",
"tracing",
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
"zbus",
]
[[package]]
name = "pf-gpu"
version = "0.28.0"
version = "0.29.0"
dependencies = [
"anyhow",
"pf-host-config",
@@ -3133,11 +3134,11 @@ dependencies = [
[[package]]
name = "pf-host-config"
version = "0.28.0"
version = "0.29.0"
[[package]]
name = "pf-inject"
version = "0.28.0"
version = "0.29.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3166,14 +3167,14 @@ dependencies = [
[[package]]
name = "pf-paths"
version = "0.28.0"
version = "0.29.0"
dependencies = [
"tracing",
]
[[package]]
name = "pf-presenter"
version = "0.28.0"
version = "0.29.0"
dependencies = [
"anyhow",
"ash",
@@ -3188,7 +3189,7 @@ dependencies = [
[[package]]
name = "pf-update"
version = "0.28.0"
version = "0.29.0"
dependencies = [
"serde",
"serde_json",
@@ -3196,7 +3197,7 @@ dependencies = [
[[package]]
name = "pf-update-check"
version = "0.28.0"
version = "0.29.0"
dependencies = [
"anyhow",
"aws-lc-rs",
@@ -3208,7 +3209,7 @@ dependencies = [
[[package]]
name = "pf-vaadec"
version = "0.28.0"
version = "0.29.0"
dependencies = [
"cros-codecs",
"pf-bitstream",
@@ -3217,7 +3218,7 @@ dependencies = [
[[package]]
name = "pf-vdisplay"
version = "0.28.0"
version = "0.29.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3250,7 +3251,7 @@ dependencies = [
[[package]]
name = "pf-vkdecode"
version = "0.28.0"
version = "0.29.0"
dependencies = [
"ash",
"cros-codecs",
@@ -3261,7 +3262,7 @@ dependencies = [
[[package]]
name = "pf-win-display"
version = "0.28.0"
version = "0.29.0"
dependencies = [
"pf-paths",
"punktfunk-core",
@@ -3272,7 +3273,7 @@ dependencies = [
[[package]]
name = "pf-zerocopy"
version = "0.28.0"
version = "0.29.0"
dependencies = [
"anyhow",
"ash",
@@ -3484,7 +3485,7 @@ dependencies = [
[[package]]
name = "punktfunk-cli"
version = "0.28.0"
version = "0.29.0"
dependencies = [
"pf-client-core",
"punktfunk-core",
@@ -3494,7 +3495,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.28.0"
version = "0.29.0"
dependencies = [
"android_logger",
"jni",
@@ -3512,7 +3513,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-linux"
version = "0.28.0"
version = "0.29.0"
dependencies = [
"anyhow",
"async-channel",
@@ -3529,7 +3530,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-session"
version = "0.28.0"
version = "0.29.0"
dependencies = [
"pf-client-core",
"pf-console-ui",
@@ -3543,7 +3544,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-windows"
version = "0.28.0"
version = "0.29.0"
dependencies = [
"async-channel",
"mdns-sd",
@@ -3561,7 +3562,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.28.0"
version = "0.29.0"
dependencies = [
"aes-gcm",
"cbindgen",
@@ -3593,7 +3594,7 @@ dependencies = [
[[package]]
name = "punktfunk-encode-worker"
version = "0.28.0"
version = "0.29.0"
dependencies = [
"pf-encode",
"tracing",
@@ -3602,7 +3603,7 @@ dependencies = [
[[package]]
name = "punktfunk-host"
version = "0.28.0"
version = "0.29.0"
dependencies = [
"aes",
"aes-gcm",
@@ -3672,7 +3673,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.28.0"
version = "0.29.0"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3686,7 +3687,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.28.0"
version = "0.29.0"
dependencies = [
"anyhow",
"ksni",
@@ -3709,7 +3710,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "pyrowave-sys"
version = "0.28.0"
version = "0.29.0"
dependencies = [
"bindgen",
"cmake",
+1 -1
View File
@@ -65,7 +65,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.28.0"
version = "0.29.0"
edition = "2024"
rust-version = "1.85"
license = "MIT OR Apache-2.0"
+5 -1
View File
@@ -5,13 +5,17 @@ machine, so we take security reports seriously and appreciate responsible disclo
## Supported versions
Punktfunk ships on two tracks — **stable** (a `vX.Y.Z` tag; the current line is **0.22.x**) and
Punktfunk ships on two tracks — **stable** (a `vX.Y.Z` tag) and
**canary** (built from `main`). Fixes ship as a new release on those tracks; in practice
we don't backport to older minor versions, so the supported versions are the latest stable release
and the current canary build. If you're on an older build, please check that the issue still
reproduces on the latest stable before reporting it. See
[Release Channels](https://docs.punktfunk.unom.io/docs/channels).
Security fixes are **free of charge**, ship **without undue delay**, and are **separated from
feature updates where feasible**: on the stable track they arrive as patch releases (`vX.Y.Z+1`)
that carry the fix rather than waiting on the next feature release.
## Reporting a vulnerability
**Please report security issues privately by email to security@punktfunk.com.**
+930 -5
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -51,6 +51,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libxdamage-dev libxcomposite-dev libxrender-dev libxext-dev libxxf86vm-dev \
libxtst-dev libx11-dev libxres-dev libxmu-dev libxcursor-dev libxi-dev \
libxfixes-dev libxkbcommon-dev libxkbcommon-x11-dev libcap-dev libdrm-dev \
# x11-xcb is needed by the VULKAN WSI LAYER (layer/meson.build), not by the compositor — so it
# was not missed until v0.28.1 started building the layer beside the binary. Debian is the only
# channel that needs it named: Arch's libx11 and Fedora's libX11-devel both carry x11-xcb.pc
# themselves, while Debian splits it into its own -dev package.
libx11-xcb-dev \
libinput-dev libudev-dev libpipewire-0.3-dev libseat-dev libsdl2-dev \
libluajit-5.1-dev libavif-dev libdecor-0-dev hwdata libglm-dev libbenchmark-dev \
libvulkan-dev libxcb1-dev libxcb-composite0-dev libxcb-xfixes0-dev libxcb-res0-dev \
@@ -66,3 +71,13 @@ RUN set -eux; \
pkg-config --atleast-version=1.23.1 wayland-server \
|| { echo "wayland-server $have < 1.23.1 — the vendored wlroots will not configure" >&2; exit 1; }; \
echo "wayland-server $have — OK"
# The layer's own floor, asserted for the same reason: a missing x11-xcb does not fail the
# COMPOSITOR build, it fails `layer/meson.build` — and the layer is the only route to an HDR10
# swapchain for a nested game, so losing it silently ships a package that looks healthy and denies
# every game HDR. This is exactly how v0.28.1's deb leg broke, one release after the layer was
# added; assert it here so the next dep the layer grows fails at image build, not mid-release.
RUN set -eux; \
pkg-config --exists x11-xcb \
|| { echo "x11-xcb absent — the Vulkan WSI layer will not configure (need libx11-xcb-dev)" >&2; exit 1; }; \
echo "x11-xcb $(pkg-config --modversion x11-xcb) — OK"
+2 -1
View File
@@ -22,7 +22,8 @@ Google TV, budget Amlogic boxes) that otherwise reject a 64-bit-only build as "n
## Get it
Published to **Google Play (Internal Testing)** — join the beta via the
Published to **Google Play (Open Testing)** — join via the
[public opt-in link](https://play.google.com/apps/testing/io.unom.punktfunk) or the
[Discord](https://discord.gg/kaPNvzMuGU). Per-device setup and pairing:
**[docs.punktfunk.unom.io/docs/install-client](https://docs.punktfunk.unom.io/docs/install-client)**.
+4
View File
@@ -142,6 +142,10 @@ dependencies {
// job runs `:app:testDebugUnitTest -PskipRustBuild` (see kit/build.gradle.kts). ---
testImplementation(composeBom)
testImplementation("androidx.compose.ui:ui-test-junit4")
// Deterministic cover art for the library scene: FakeImageLoaderEngine answers the coverflow's
// AsyncImage synchronously with generated posters — no network, no async race under the frozen
// animation clock.
testImplementation("io.coil-kt:coil-test:2.7.0")
debugImplementation("androidx.compose.ui:ui-test-manifest") // the ComponentActivity test host
testImplementation("junit:junit:4.13.2")
// Real `org.json` for the shared-vectors test: the `org.json` inside `android.jar` is a stub
@@ -28,9 +28,14 @@
<!-- Gamepad rumble feedback. -->
<uses-permission android:name="android.permission.VIBRATE" />
<!-- Steam Controller 2 over direct BLE (Sc2BleLink talks Valve's vendor GATT service to the
bonded pad). A RUNTIME permission (NEARBY_DEVICES group); the capture engages only when
already granted — USB capture (wired / Puck dongle) needs no Bluetooth at all. -->
bonded pad). A RUNTIME permission (NEARBY_DEVICES group) from API 31 — MainActivity asks
for it when a BLE-paired SC2 is actually around, and the Controllers screen offers the
grant outright. USB capture (wired / Puck dongle) needs no Bluetooth at all.
Below API 31 the same two operations (the bonded list + connectGatt) are covered by the
install-time legacy permission instead, which BLUETOOTH_CONNECT does NOT imply — without
it every BLE capture on Android 11 and older throws SecurityException. -->
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<!-- We target phone + TV from day one: keep the app installable on TV (no touchscreen) and on
devices without a gamepad. -->
@@ -1,6 +1,5 @@
package io.unom.punktfunk
import android.os.Build
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
@@ -14,6 +13,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
@@ -206,7 +206,9 @@ fun AwaitingApprovalPrompt(gamepadUi: Boolean, hostLabel: String, onCancel: () -
actions = listOf(DialogAction("Cancel", primary = true, onClick = onCancel)),
dismissOnOutsideTap = false,
) {
val deviceName = Build.MODEL ?: "this device"
// MUST be the name the connect actually knocked with (`HostConnect`), or this sends the
// user looking for a row the console does not show.
val label = deviceName(LocalContext.current)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
@@ -222,7 +224,7 @@ fun AwaitingApprovalPrompt(gamepadUi: Boolean, hostLabel: String, onCancel: () -
)
}
PromptText(
"Open the host's console (or web UI) and approve “$deviceName”. It connects " +
"Open the host's console (or web UI) and approve “$label”. It connects " +
"automatically once you approve — no PIN needed.",
gamepadUi,
)
@@ -1,6 +1,5 @@
package io.unom.punktfunk
import android.os.Build
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -31,6 +30,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import io.unom.punktfunk.kit.NativeBridge
@@ -137,7 +137,8 @@ internal fun PairPinDialog(
) {
val scope = rememberCoroutineScope()
var pin by remember(pt) { mutableStateOf("") }
var name by remember(pt) { mutableStateOf(Build.MODEL ?: "Android") }
val context = LocalContext.current
var name by remember(pt) { mutableStateOf(deviceName(context)) }
var pairing by remember(pt) { mutableStateOf(false) }
var err by remember(pt) { mutableStateOf<String?>(null) }
AlertDialog(
@@ -53,6 +53,11 @@ object ConnectErrors {
"on the host."
"wire-version" -> "Client and host versions don't match — update both to the same release."
"busy" -> "The host is busy with another session."
"access-expired" ->
"Your access to this host has expired — ask the host's owner to grant it again."
"launch-not-permitted" ->
"This device's access doesn't include launching games — connect to the desktop, " +
"or ask the host's owner."
else -> null
}
@@ -243,6 +243,15 @@ fun ConnectScreen(
knownHostStore.learnOs(dh.host, dh.port, dh.os)
any = true
}
// And the mgmt port, so a host that moved off 47990 keeps its library once this
// device can no longer see the advert (VPN, routed subnet, multicast-dead Wi-Fi).
val mgmt = dh.mgmtPort
if (mgmt != null &&
knownHostStore.get(dh.host, dh.port)?.let { it.mgmtPort != mgmt } == true
) {
knownHostStore.learnMgmtPort(dh.host, dh.port, mgmt)
any = true
}
}
any
}
@@ -313,13 +322,24 @@ fun ConnectScreen(
// What the stream screen is handed: the settings this connect actually used, plus the HOST's
// clipboard decision (a property of the record, not a global). A host we never saved — a
// connect that failed to pin — falls back to the on default the setting always had.
fun session(handle: Long, record: KnownHost?, profile: StreamProfile?) = ActiveSession(
handle,
settings.effectiveFor(profile),
clipboardSync = record?.clipboardSync ?: true,
profileName = profile?.name,
hostId = record?.id,
)
fun session(handle: Long, record: KnownHost?, profile: StreamProfile?): ActiveSession {
// The session's own Welcome carries where this host serves its library. Save it now: this
// is the only source that does not need an mDNS advert, so it is what makes a host that
// moved off 47990 browsable over a VPN or when it was added by address. 0 = not
// advertised, and learnMgmtPort ignores it.
if (record != null) {
NativeBridge.nativeHostMgmtPort(handle).takeIf { it > 0 }?.let {
knownHostStore.learnMgmtPort(record.address, record.port, it)
}
}
return ActiveSession(
handle,
settings.effectiveFor(profile),
clipboardSync = record?.clipboardSync ?: true,
profileName = profile?.name,
hostId = record?.id,
)
}
// The actual dial (identity already ready). On a TOFU connect (pinHex null), pin the fingerprint
// the host presented (as an unpaired known host) so the next connect goes straight through and it
@@ -12,6 +12,8 @@ import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -54,6 +56,7 @@ import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import io.unom.punktfunk.kit.DsDevice
import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.Sc2BleLink
import io.unom.punktfunk.kit.Sc2Capture
import kotlinx.coroutines.delay
@@ -69,7 +72,7 @@ import kotlinx.coroutines.delay
* to be the same one whichever interface asked.
*/
@Composable
fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
internal fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit, padsOverride: List<PadInfo>? = null) {
BackHandler(onBack = onBack)
var testing by remember { mutableStateOf(false) }
ControllersBody(
@@ -77,6 +80,7 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
scroll = rememberScrollState(),
testing = testing,
onTestingChange = { testing = it },
padsOverride = padsOverride,
// The touch screen holds the probes for its whole life: events are OBSERVED (not consumed)
// while the test is off, which is what keeps the "Last input" line live while browsing.
// Nothing else here wants the pad, so there is no one to hand them to.
@@ -99,7 +103,12 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
* drops out of the probe slots and B is a HOLD (below). Everything reverts the moment it ends.
*/
@Composable
fun ConsoleControllersScreen(gamepadSetting: Int, onBack: () -> Unit, navActive: Boolean = true) {
internal fun ConsoleControllersScreen(
gamepadSetting: Int,
onBack: () -> Unit,
navActive: Boolean = true,
padsOverride: List<PadInfo>? = null,
) {
BackHandler(onBack = onBack)
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
val hazeState = remember { HazeState() }
@@ -139,6 +148,7 @@ fun ConsoleControllersScreen(gamepadSetting: Int, onBack: () -> Unit, navActive:
scroll = scroll,
testing = testing,
onTestingChange = { testing = it },
padsOverride = padsOverride,
// Only while testing: the rest of the time the screen's own nav holds the
// probes, so the "Last input" line is a test-time readout here rather than
// an always-on one. A pad that reaches this screen at all has already
@@ -200,14 +210,17 @@ private fun ControllersBody(
onTestingChange: (Boolean) -> Unit,
observeInput: Boolean,
contentPadding: PaddingValues,
padsOverride: List<PadInfo>? = null,
heading: @Composable () -> Unit,
) {
val context = LocalContext.current
val activity = context as? MainActivity
// Device list, re-read on every hot-plug event.
// Device list, re-read on every hot-plug event. [padsOverride] replaces it wholesale: the
// screenshot harness runs where no InputDevice can exist, and the connected-pad card is the
// point of that shot.
var generation by remember { mutableIntStateOf(0) }
val pads = remember(generation) { Gamepad.pads() }
val pads = padsOverride ?: remember(generation) { Gamepad.pads() }.map(::padInfoOf)
val others = remember(generation) {
InputDevice.getDeviceIds()
.toList()
@@ -363,12 +376,16 @@ private fun ControllersBody(
}
val sc2Probe = remember { Sc2Capture(context) }
val sc2Usb = remember(usbGeneration) { sc2Probe.findUsbDevice() }
val sc2Ble = remember(usbGeneration) {
if (context.checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
) sc2Probe.pairedBleAddress() else null
}
// Answers null without the Bluetooth grant (and logs why) — see Sc2BleLink.
val sc2Ble = remember(usbGeneration) { sc2Probe.pairedBleAddress() }
val sc2Present = sc2Usb != null || sc2Ble != null
// A BLE-paired SC2 cannot be seen at all until Bluetooth is granted, so "no controller
// detected" would be the wrong thing to print at someone who has one paired. This is the
// screen a user opens when a pad is missing, so the grant belongs here — see
// [sc2BluetoothGrantOffered] for when it is worth offering, and the lizard-mode
// InputDevice probe (no permission of its own) for how we word it.
val btPermitted = remember(usbGeneration) { Sc2BleLink.permissionGranted(context) }
val sc2OnBluetooth = remember(usbGeneration) { Gamepad.sc2InputDevicePresent() }
val dsUsb = remember(usbGeneration) {
(context.getSystemService(Context.USB_SERVICE) as android.hardware.usb.UsbManager)
.deviceList.values.firstOrNull {
@@ -389,11 +406,24 @@ private fun ControllersBody(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// After that paragraph on purpose: when nothing was detected, this is the actionable
// half of the same answer — the one pad we are blind to rather than one Android has
// simply classified oddly.
if (
sc2BluetoothGrantOffered(
permissionGranted = btPermitted,
usbSc2 = sc2Usb != null,
sc2Attached = sc2OnBluetooth,
anyPadDetected = pads.isNotEmpty(),
)
) {
Sc2BluetoothRow(attached = sc2OnBluetooth, activity = activity) { usbGeneration++ }
}
// Every real controller is forwarded now (Automatic forwards them all, each on its own
// wire pad index) — not just the first. A joystick-only device Android doesn't classify as
// a gamepad still can't be forwarded (the host wants a gamepad), so gate the badge on it.
pads.forEach { dev ->
PadRow(dev, forwarded = isForwarded(dev), gamepadSetting = gamepadSetting)
pads.forEach { info ->
PadRow(info, gamepadSetting = gamepadSetting)
}
}
@@ -451,6 +481,90 @@ private fun ControllersBody(
}
}
/**
* Whether to offer the Bluetooth grant for a directly-paired Steam Controller 2.
*
* Only when it could change the answer ([permissionGranted] false), and only when there is reason
* to think it would: an SC2 is visibly attached in lizard mode ([sc2Attached] the permission-free
* probe), or nothing was detected at all ([anyPadDetected] false) and a Bluetooth SC2 is precisely
* the pad this client cannot see without the grant. A [usbSc2] is already captured over USB and
* needs no Bluetooth, and someone with working controllers and no sign of an SC2 is shown nothing.
*/
fun sc2BluetoothGrantOffered(
permissionGranted: Boolean,
usbSc2: Boolean,
sc2Attached: Boolean,
anyPadDetected: Boolean,
): Boolean = !permissionGranted && !usbSc2 && (sc2Attached || !anyPadDetected)
/**
* The Bluetooth grant for a directly-paired Steam Controller 2 the card that exists because a
* BLE SC2 is invisible without it.
*
* A wired or Puck SC2 is enumerated over USB with no permission at all, so it shows up in this
* screen either way; the bonded list a BLE one lives in is behind `BLUETOOTH_CONNECT` from API 31
* and answers "nothing is paired" rather than "ask me first" when the permission is missing. Until
* this existed, nothing in the client ever requested it, so a Bluetooth SC2 was silently absent
* everywhere no capture, no controller layout, no forwarding while the same pad over USB
* worked (field report, 2026-08-15).
*
* [attached] distinguishes "we can see one sitting in lizard mode" from "you may have one paired",
* which is the difference between a statement and a guess. [onGranted] re-probes the caller's
* device state; the menu capture is engaged from here too, so the pad starts driving the UI on the
* grant rather than at the next resume.
*/
@Composable
private fun Sc2BluetoothRow(
attached: Boolean,
activity: MainActivity?,
onGranted: () -> Unit,
) {
val context = LocalContext.current
val settingOn = remember { SettingsStore(context).load().sc2Capture }
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) {
activity?.startSc2MenuNav()
onGranted()
}
}
val permission = Sc2BleLink.CONNECT_PERMISSION ?: return
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
if (attached) "Steam Controller 2" else "Steam Controller 2 over Bluetooth",
style = MaterialTheme.typography.bodyLarge,
)
Text(
when {
!settingOn ->
"Passthrough is disabled in Settings — enable \"Steam Controller 2 " +
"passthrough\" to capture it."
attached ->
"Paired over Bluetooth. Punktfunk needs Bluetooth access to capture it — " +
"until then it stays in its built-in keyboard/mouse mode and no game " +
"sees a controller."
else ->
"A Steam Controller 2 paired over Bluetooth can't be detected without " +
"Bluetooth access. Wired and Puck-dongle controllers need no " +
"permission and are already listed above."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (settingOn) {
OutlinedButton(onClick = { launcher.launch(permission) }) {
Text("Grant Bluetooth access")
}
}
}
}
}
/**
* The Steam Controller 2 card capture-side state, since a (claimed or lizard-mode) SC2 never
* appears as a gamepad InputDevice. Shows the transport, whether the capture is live (driving
@@ -675,19 +789,19 @@ private fun DsRow(usbDev: android.hardware.usb.UsbDevice) {
/** One detected gamepad: identity, what it streams as, and a rumble test. */
@Composable
private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
private fun PadRow(info: PadInfo, gamepadSetting: Int) {
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(dev.name, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
if (forwarded) {
Text(info.name, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
if (info.forwarded) {
// Android's own controller number (1-based; 0 = unassigned), shown so a multi-pad
// user can tell which physical pad is which. The stream's wire pad index is
// assigned separately (lowest-free per device) once streaming starts.
val number = dev.controllerNumber
val number = info.controllerNumber
Text(
if (number > 0) "forwarded · player $number" else "forwarded to host",
style = MaterialTheme.typography.labelSmall,
@@ -696,11 +810,11 @@ private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
}
}
Text(
deviceDetail(dev),
info.detail,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val resolved = Gamepad.prefFor(dev)
val resolved = info.resolvedPref
Text(
if (gamepadSetting == Gamepad.PREF_AUTO) {
"Streams as: ${prefLabel(resolved)} (automatic)"
@@ -711,9 +825,8 @@ private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val canRumble = deviceHasVibrator(dev)
if (canRumble) {
OutlinedButton(onClick = { testRumble(dev) }) { Text("Test rumble") }
if (info.canRumble) {
OutlinedButton(onClick = { info.dev?.let(::testRumble) }) { Text("Test rumble") }
} else {
Text(
"No rumble motors reported — host rumble will be silent",
@@ -794,6 +907,32 @@ private fun Group(title: String, content: @Composable ColumnScope.() -> Unit) {
private fun isForwarded(dev: InputDevice): Boolean =
!dev.isVirtual && dev.sources and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD
/**
* Everything [PadRow] renders, decoupled from [InputDevice] so the screenshot harness can compose
* the connected-pad card at all Robolectric enumerates no input devices, and a marketing shot of
* "no controller detected" sells nothing. Production always maps a real device via [padInfoOf];
* [dev] powers the rumble test and is absent only in the harness (the button then no-ops).
*/
internal data class PadInfo(
val name: String,
val detail: String,
val forwarded: Boolean,
val controllerNumber: Int,
val resolvedPref: Int,
val canRumble: Boolean,
val dev: InputDevice? = null,
)
internal fun padInfoOf(dev: InputDevice): PadInfo = PadInfo(
name = dev.name,
detail = deviceDetail(dev),
forwarded = isForwarded(dev),
controllerNumber = dev.controllerNumber,
resolvedPref = Gamepad.prefFor(dev),
canRumble = deviceHasVibrator(dev),
dev = dev,
)
/** Whether the controller reports a rumble motor — via VibratorManager (API 31+) or the legacy Vibrator. */
private fun deviceHasVibrator(dev: InputDevice): Boolean =
if (Build.VERSION.SDK_INT >= 31) {
@@ -0,0 +1,25 @@
package io.unom.punktfunk
import android.content.Context
import android.os.Build
import android.provider.Settings
/**
* The name the user knows this device by what a host shows in its pending-approval list (the web
* console's outstanding-pairings view and the dialog that approves a knock) and files the device
* under once approved.
*
* `Settings.Global.DEVICE_NAME` is the name the user typed in Settings ("Enrico's Pixel", "TV im
* Wohnzimmer"); it is what every other protocol on the network already calls this device. Only when
* it is unset does this fall back to [Build.MODEL], which names the *product* and so reads
* identically on every unit of it two of the same tablet pending approval are indistinguishable.
* Available unconditionally here: `DEVICE_NAME` landed in API 25 and this app's floor is 28.
*/
internal fun deviceName(context: Context): String {
val userNamed = runCatching {
Settings.Global.getString(context.contentResolver, Settings.Global.DEVICE_NAME)
}.getOrNull()
return userNamed?.trim()?.takeIf { it.isNotEmpty() }
?: Build.MODEL?.trim()?.takeIf { it.isNotEmpty() }
?: "Android"
}
@@ -1,6 +1,5 @@
package io.unom.punktfunk
import android.os.Build
import androidx.activity.compose.BackHandler
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
@@ -44,6 +43,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@@ -396,7 +396,8 @@ fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired:
var slot by remember(pt) { mutableIntStateOf(0) } // 0..3 = digit slots, 4 = Pair button
var pairing by remember(pt) { mutableStateOf(false) }
var err by remember(pt) { mutableStateOf<String?>(null) }
val name = remember { Build.MODEL ?: "Android" }
val context = LocalContext.current
val name = remember(context) { deviceName(context) }
fun pair() {
val id = identity ?: return
@@ -1,7 +1,6 @@
package io.unom.punktfunk
import android.content.Context
import android.os.Build
import android.util.Log
import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.NativeBridge
@@ -82,8 +81,8 @@ suspend fun connectToHost(
codecBits, preferredCodec, timeoutMs,
launch,
// The host's approval-list / trust-store label for this device — the same
// Build.MODEL convention the pairing dialogs use for nativePair.
Build.MODEL ?: "Android",
// user-set device name the pairing dialogs offer for nativePair.
deviceName(context),
// Tier-A pad audio: ask for the 0xD1 plane only when a setting would render it, so a
// user with it off does not make the host provision endpoints it will never feed.
settings.padHaptics || settings.padSpeaker,
@@ -59,7 +59,6 @@ import coil.ImageLoader
import coil.compose.AsyncImage
import coil.request.ImageRequest
import io.unom.punktfunk.components.launcherIcon
import io.unom.punktfunk.kit.library.DEFAULT_MGMT_PORT
import io.unom.punktfunk.kit.library.GameEntry
import io.unom.punktfunk.kit.library.LibraryClient
import io.unom.punktfunk.kit.library.LibraryResult
@@ -120,14 +119,16 @@ fun LibraryScreen(
}
val streamSettings = remember(settings, profile) { settings.effectiveFor(profile) }
LaunchedEffect(host.address, host.port, host.fpHex) {
// Keyed on the mgmt port too: a discovery tick can learn it after this screen is composed, and
// the fetch must redo itself against the real port rather than stay on a stale 47990 failure.
LaunchedEffect(host.address, host.port, host.fpHex, host.effectiveMgmtPort) {
state = LibState.Loading
state = withContext(Dispatchers.IO) {
val id = runCatching { obtainIdentity(IdentityStore(context)) }.getOrNull()
?: return@withContext LibState.Message("Identity unavailable — re-pair may be required.")
when (val res = LibraryClient.fetch(
address = host.address,
mgmtPort = DEFAULT_MGMT_PORT,
mgmtPort = host.effectiveMgmtPort,
certPem = id.certPem,
keyPem = id.privateKeyPem,
fpHex = host.fpHex,
@@ -254,8 +255,10 @@ private fun MessageState(text: String) {
)
}
// Internal (not private): the screenshot harness composes the real coverflow with mock games —
// the library screen itself can't be shot, its state comes off the network.
@Composable
private fun Coverflow(
internal fun Coverflow(
games: List<GameEntry>,
loader: ImageLoader,
navActive: Boolean,
@@ -34,6 +34,8 @@ import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.GamepadRouter
import io.unom.punktfunk.kit.Keymap
import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.Sc2BleLink
import io.unom.punktfunk.kit.SessionAccess
import io.unom.punktfunk.kit.link.DeepLinkResult
import io.unom.punktfunk.kit.link.DeepLinks
import io.unom.punktfunk.kit.link.HostResolution
@@ -42,6 +44,9 @@ import io.unom.punktfunk.kit.security.KnownHostStore
/** Broadcast action for the menu-time SC2 USB-permission grant (see [MainActivity.startSc2MenuNav]). */
private const val SC2_MENU_PERMISSION = "io.unom.punktfunk.SC2_MENU_USB_PERMISSION"
/** Request code for the SC2's Bluetooth grant (see [MainActivity.maybeAskSc2BtPermission]). */
private const val REQ_SC2_BLUETOOTH = 0x5C2B
/**
* Keeps ONE window-insets reader alive for as long as the app's UI exists the fix for the menus
* coming back from a stream laid out against the WRONG safe area.
@@ -86,6 +91,16 @@ class MainActivity : ComponentActivity() {
*/
var streamHandle: Long = 0L
/**
* The active session's access-grant mask ([SessionAccess] bits) set with [streamHandle] by
* StreamScreen and kept live by its access poll; back to [SessionAccess.ALL] when the stream
* leaves. Consulted only while streaming: the VK keyboard path below goes inert without
* [SessionAccess.KEYBOARD] (the keys are consumed, not sent the host would drop them, and
* letting them fall through would drive Android navigation under a live stream). Courtesy
* gating; the host enforces regardless.
*/
var streamAccess: Int = SessionAccess.ALL
/**
* Multi-controller router for the active session (built/released by StreamScreen): assigns each
* connected pad a stable wire index, threads it onto every event, declares/removes pads on
@@ -181,6 +196,9 @@ class MainActivity : ComponentActivity() {
private var sc2Receiver: BroadcastReceiver? = null
private var sc2PermissionAsked = false
/** Bluetooth asked once this process — a denial must not re-prompt on every resume. */
private var sc2BtPermissionAsked = false
/** Sony-pad USB grant asked this attach a deny doesn't re-nag until a fresh attach (or the
* Controllers screen's explicit button). */
private var dsPermissionAsked = false
@@ -319,7 +337,8 @@ class MainActivity : ComponentActivity() {
* Engage the menu-time SC2 capture if possible: setting on, not streaming, and a wired/Puck
* pad attached (asking for USB permission at most once per attach [forceAsk] re-arms the
* dialog, for the Controllers screen's explicit grant button) else an already-paired BLE
* controller when BLUETOOTH_CONNECT is granted. Safe to call repeatedly.
* controller, asking for Bluetooth access once if one appears to be attached
* ([maybeAskSc2BtPermission]). Safe to call repeatedly.
*/
fun startSc2MenuNav(forceAsk: Boolean = false) {
if (forceAsk) sc2PermissionAsked = false
@@ -347,10 +366,46 @@ class MainActivity : ComponentActivity() {
),
)
}
dev == null && checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) ==
PackageManager.PERMISSION_GRANTED -> {
dev == null && Sc2BleLink.permissionGranted(this) -> {
cap.pairedBleAddress()?.let { cap.startBle(it) }
}
dev == null -> maybeAskSc2BtPermission()
}
}
/**
* Ask for Bluetooth access when a BLE-paired SC2 looks like it is attached and we cannot see
* it once per process, and never on a device that shows no sign of owning one.
*
* The permission is the whole reason a Bluetooth SC2 used to go unnoticed: the bonded list and
* `connectGatt` both need it from API 31, nothing in the client had ever requested it, and the
* bonded-list call answers an empty list rather than an error when it is missing so the
* capture stood down silently and the console UI never flipped to its controller layout, while
* the same pad over USB worked (field report, 2026-08-15). Asking is gated on
* [Gamepad.sc2InputDevicePresent] because an uncaptured SC2 sits in lizard mode as a
* keyboard/mouse [android.view.InputDevice] visible without any permission at all so the
* prompt reaches the people who have the hardware and nobody else.
*/
private fun maybeAskSc2BtPermission() {
val permission = Sc2BleLink.CONNECT_PERMISSION ?: return // granted at install time here
if (sc2BtPermissionAsked) return
if (!Gamepad.sc2InputDevicePresent()) return
sc2BtPermissionAsked = true
requestPermissions(arrayOf(permission), REQ_SC2_BLUETOOTH)
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray,
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
// Engage immediately on the grant — the pad is already paired, so there is nothing else to
// wait for and the user just told us what they want it for.
if (requestCode == REQ_SC2_BLUETOOTH &&
grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED
) {
startSc2MenuNav()
}
}
@@ -526,10 +581,25 @@ class MainActivity : ComponentActivity() {
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
val handle = streamHandle
if (handle != 0L) {
// A mouse's side buttons, when they arrive key-shaped, are X1/X2 — not navigation.
// Resolved before the gamepad and remote-pointer hooks so neither can claim them as
// its own BACK. See [mouseSideButton] for how a mouse's BACK is told from a pad's or
// a remote's; it answers null for every device that cannot be a mouse, so asking it
// first re-routes nothing else.
mouseSideButton(event)?.let { back ->
when (event.action) {
KeyEvent.ACTION_DOWN ->
if (event.repeatCount == 0) mouseForwarder?.sideButtonKey(back, true)
KeyEvent.ACTION_UP -> mouseForwarder?.sideButtonKey(back, false)
}
return true
}
// Gamepad buttons (incl. DPAD only when truly from a gamepad — else KEYCODE_DPAD_* are
// keyboard arrows and belong to the VK path below).
// keyboard arrows and belong to the VK path below — and BACK, which is how a pad with
// no BUTTON_SELECT scancode delivers its Select: see [Gamepad.padButtonBit], which is
// why this asks it rather than `buttonBit`).
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
val bit = Gamepad.buttonBit(event.keyCode)
val bit = Gamepad.padButtonBit(event.keyCode, event.flags)
if (bit != 0) {
// The router forwards the bit on this device's own wire pad index and tracks held
// state per pad. The emergency-exit chord (Select + Start + L1 + R1) is handled
@@ -540,17 +610,6 @@ class MainActivity : ComponentActivity() {
return true // consumed
}
}
// A mouse's side buttons, when they arrive key-shaped, are X1/X2 — not navigation.
// Resolved before the remote-pointer hook so pointer mode can't eat them as its own
// BACK. See [mouseSideButton] for how a mouse's BACK is told from a remote's.
mouseSideButton(event)?.let { back ->
when (event.action) {
KeyEvent.ACTION_DOWN ->
if (event.repeatCount == 0) mouseForwarder?.sideButtonKey(back, true)
KeyEvent.ACTION_UP -> mouseForwarder?.sideButtonKey(back, false)
}
return true
}
// TV remote-as-pointer sees non-gamepad keys first (SELECT long-press toggles it;
// while active it owns the D-pad/SELECT/PLAY-PAUSE/BACK).
if (!event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
@@ -567,12 +626,13 @@ class MainActivity : ComponentActivity() {
return true
}
when (event.keyCode) {
// Whatever [mouseSideButton] didn't claim. A view-level FALLBACK BACK appears when
// a BUTTON_* press goes unconsumed, and an air-mouse remote stamps its own BACK
// SOURCE_MOUSE; both are duplicates of something already handled, and letting
// either through doubles as Android navigation and yanks the user out of the
// stream. A remote/keyboard BACK is never mouse-sourced, so it still falls through
// to the BackHandler and exits.
// Whatever [mouseSideButton] and the pad branch didn't claim. A view-level FALLBACK
// BACK appears when a BUTTON_* press goes unconsumed, and an air-mouse remote stamps
// its own BACK SOURCE_MOUSE; both are duplicates of something already handled, and
// letting either through doubles as Android navigation and yanks the user out of the
// stream. A remote/keyboard BACK is never mouse-sourced and never gamepad-sourced,
// so it still falls through to the BackHandler and exits — which for a device with
// no pad on it is the documented way out.
KeyEvent.KEYCODE_BACK, KeyEvent.KEYCODE_FORWARD ->
if (event.isFromSource(InputDevice.SOURCE_MOUSE) ||
event.flags and KeyEvent.FLAG_FALLBACK != 0
@@ -591,6 +651,10 @@ class MainActivity : ComponentActivity() {
KeyEvent.ACTION_UP -> false
else -> return super.dispatchKeyEvent(event)
}
// Without the KEYBOARD grant the key path is inert: consumed (so nothing
// drives Android navigation under the stream) but never sent — the host
// would drop it, and the Access chip is what says why. Courtesy gating.
if (streamAccess and SessionAccess.KEYBOARD == 0) return true
// Full-event overload: evdev scancode first (positional under ANY selected
// physical-keyboard layout), keycode fallback — see Keymap docs.
val vk = Keymap.toVk(event)
@@ -44,6 +44,18 @@ class MouseForwarder(
var onRequestCapture: (() -> Unit)? = null
var onReleaseCapture: (() -> Unit)? = null
/**
* Whether this session's access includes the POINTER grant ([io.unom.punktfunk.kit.SessionAccess.POINTER])
* seeded from the Welcome, kept live by StreamScreen's access poll. Without it the mouse
* path goes inert: nothing forwards, and the part that matters the pointer is never
* GRABBED, because a captured mouse that moves nothing is the "my mouse does nothing and
* nobody says why" failure the grants UX exists to prevent (the Access chip says why
* instead). Revocation mid-session releases an existing grab (StreamScreen calls [release]).
* Volatile: set on the main thread, read wherever the dispatch path runs.
*/
@Volatile
var pointerGranted: Boolean = true
/** Live capture state, updated from [android.app.Activity.onPointerCaptureChanged]. */
var captured = false
private set
@@ -59,6 +71,7 @@ class MouseForwarder(
/** Uncaptured mouse events on the TOUCH stream (position while a button is down). */
fun onTouchEvent(ev: MotionEvent): Boolean {
if (!pointerGranted) return true // inert: consumed over the stream, nothing forwards
when (ev.actionMasked) {
MotionEvent.ACTION_DOWN -> {
if (captureWanted && !captured && !userReleased) {
@@ -80,6 +93,7 @@ class MouseForwarder(
/** Uncaptured mouse events on the GENERIC stream (hover motion, wheel, button edges). */
fun onGenericMotion(ev: MotionEvent): Boolean {
if (!pointerGranted) return true // inert: consumed over the stream, nothing forwards
when (ev.actionMasked) {
MotionEvent.ACTION_HOVER_MOVE -> sendAbs(ev)
MotionEvent.ACTION_SCROLL -> wheel(ev)
@@ -98,6 +112,7 @@ class MouseForwarder(
* gesture layer is the touchpad story); returning false leaves those to the framework.
*/
fun onCapturedPointer(ev: MotionEvent): Boolean {
if (!pointerGranted) return true // a revocation is racing the release of the grab
if (!ev.isFromSource(InputDevice.SOURCE_MOUSE_RELATIVE)) return false
when (ev.actionMasked) {
MotionEvent.ACTION_MOVE -> {
@@ -131,7 +146,7 @@ class MouseForwarder(
if (captured) {
userReleased = true
onReleaseCapture?.invoke()
} else {
} else if (pointerGranted) { // never grab a pointer whose input can't land
userReleased = false
onRequestCapture?.invoke()
}
@@ -139,7 +154,7 @@ class MouseForwarder(
/** Auto-engage at stream start (setting on + a mouse actually present). */
fun engageFromStart() {
if (captureWanted && !captured && !userReleased && hasPhysicalMouse()) {
if (pointerGranted && captureWanted && !captured && !userReleased && hasPhysicalMouse()) {
onRequestCapture?.invoke()
}
}
@@ -204,7 +219,9 @@ class MouseForwarder(
* input reader synthesizes them in), so both paths funnel into the same held-set and the
* add/remove guard collapses the pair into a single wire press.
*/
fun sideButtonKey(back: Boolean, down: Boolean) = press(if (back) 4 else 5, down)
fun sideButtonKey(back: Boolean, down: Boolean) {
if (pointerGranted) press(if (back) 4 else 5, down)
}
private fun button(actionButton: Int, down: Boolean) {
val b = when (actionButton) {
@@ -44,6 +44,21 @@ class RemotePointer(
var active = false
private set
/**
* Whether this session's access includes the POINTER grant StreamScreen keeps it live from
* the access poll. Ungranted, the SELECT long-press stops entering pointer mode (a mode whose
* every action the host would drop; the Access chip says why), and a revocation while the
* mode is on leaves it cleanly ([setGranted]). Everything else passes through untouched,
* exactly as when the mode is off the remote stays a remote.
*/
private var granted = true
/** Update the POINTER grant; revoking while pointer mode is on leaves the mode. Main thread. */
fun setGranted(ok: Boolean) {
granted = ok
if (!ok && active) toggle()
}
private val handler = Handler(Looper.getMainLooper())
private val held = mutableSetOf<Int>() // D-pad keycodes currently down
private var moveAccX = 0f
@@ -169,6 +184,7 @@ class RemotePointer(
}
private fun toggle() {
if (!active && !granted) return // never enter a mode whose input can't land
active = !active
if (!active) {
held.clear()
@@ -70,6 +70,7 @@ import io.unom.punktfunk.kit.deviceBodyVibrator
import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.PadSensors
import io.unom.punktfunk.kit.Sc2Capture
import io.unom.punktfunk.kit.SessionAccess
import io.unom.punktfunk.kit.SessionEndReason
import io.unom.punktfunk.kit.VideoDecoders
import io.unom.punktfunk.models.ActiveSession
@@ -102,6 +103,20 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
window?.let { WindowCompat.getInsetsController(it, it.decorView) }
}
// The session's access level (the per-client grants of design/per-client-access.md), the
// courtesy mirror of what the host enforces: seeded from the Welcome's advert here, kept live
// by the 1 Hz poll below (the host's AccessUpdate messages fold latest-wins into the native
// state). Full control + permanent — the only state an old host or an old native lib ever
// reports — gates nothing and draws nothing: today's look, unchanged.
val initialAccess = remember(handle) { NativeBridge.nativeAccessState(handle) }
var accessGrants by remember(handle) {
mutableStateOf(initialAccess?.getOrNull(0) ?: SessionAccess.ALL)
}
// Seconds until this session's access expires (0 = permanent), as last reported natively.
var accessRemaining by remember(handle) {
mutableStateOf(initialAccess?.getOrNull(1) ?: 0)
}
// Start mic only if the user enabled it AND granted RECORD_AUDIO (else the AAudio input fails).
val micWanted = micEnabled && ContextCompat.checkSelfPermission(
context,
@@ -182,6 +197,34 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
NativeBridge.nativeSetMicMuted(handle, muted)
}
// Push a grant mask into every gate that consults one — called at session start (once the
// router/forwarders exist) and again whenever the poll sees the mask change (an AccessUpdate
// revoked or restored something mid-session). A lambda, deliberately not a local fun — this
// codebase has been burned by `::localFun` references in composable scopes. The gates it does
// NOT reach (the Compose-side ones — the touch layer, the IME summon, the banner line, the
// chip) key on `accessGrants` directly and re-run on the state write.
val applyAccess: (Int) -> Unit = { grants ->
activity?.streamAccess = grants
activity?.gamepadRouter?.gamepadGranted = grants and SessionAccess.GAMEPAD != 0
val pointerOk = grants and SessionAccess.POINTER != 0
activity?.mouseForwarder?.let { m ->
m.pointerGranted = pointerOk
// A revocation must also let an existing grab go (and lift held buttons): a captured
// mouse that moves nothing reads as a broken mouse, not a spectator session.
if (!pointerOk) m.release()
}
activity?.remotePointer?.setGranted(pointerOk)
// Mic revoked mid-session: stop the capture — the host detaches its end regardless, and
// an open mic (with the platform's recording indicator lit) feeding a plane the host
// drops would be the worst kind of lie. Not restarted on a re-grant: the host attaches
// the mic service at session setup only, so a fresh session is the honest offer.
if (grants and SessionAccess.MIC == 0 && micRunning) {
releaseMicEffects(micEffects)
NativeBridge.nativeStopMic(handle)
micRunning = false
}
}
// Live decode stats for the HUD. `statsOn` (verbosity != OFF) gates the whole native pipeline:
// the per-frame sampling (nativeSetVideoStatsEnabled — a hidden HUD costs one atomic load per
// frame) AND the 1 s poll loop, which only runs while the overlay is visible. Enabling resets
@@ -243,22 +286,62 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
// open, so this fires only on a genuinely dead peer, never a false positive. Keyed on `handle`, so
// it stops the moment we navigate away (the handle is only freed later, in onDispose).
LaunchedEffect(handle) {
var lastAccessSeq = initialAccess?.getOrNull(2) ?: 0
while (true) {
delay(1000)
// Access first, ended second: a session about to close on its expiry gets its final
// countdown read, which is what lets the ended branch word that close honestly.
NativeBridge.nativeAccessState(handle)?.let { st ->
val grants = st.getOrNull(0) ?: SessionAccess.ALL
val seq = st.getOrNull(2) ?: 0
if (grants != accessGrants) {
accessGrants = grants
applyAccess(grants)
}
accessRemaining = st.getOrNull(1) ?: 0
if (seq != lastAccessSeq) {
lastAccessSeq = seq
// A fresh AccessUpdate close to the deadline is the host's T5 m / T1 m
// courtesy warning — surface it. Grant edits (and a warning's grant echo)
// otherwise just move the chip; a toast per edit would be noise.
if (accessRemaining in 1..330) {
val mins = (accessRemaining + 30) / 60
Toast.makeText(
context,
if (mins <= 1) {
"Access expires in about a minute."
} else {
"Access expires in about $mins minutes."
},
Toast.LENGTH_LONG,
).show()
}
}
}
if (NativeBridge.nativeSessionEnded(handle)) {
// WHY it ended decides what the user is told. This used to show the "host may be
// asleep" line for EVERY ending — including a game the player had just quit and a
// session the host ended on purpose — which reads as a failure report for
// something nobody did wrong. Only a connection that actually died says that now.
val reason = SessionEndReason.fromNative(NativeBridge.nativeEndReason(handle))
when (reason) {
SessionEndReason.LOST ->
when {
// The session died inside the access countdown's final stretch: that IS the
// typed expiry close (ACCESS_EXPIRED), worded with the shared rejection
// sentence rather than the generic host-ended silence. Recognized off the
// countdown because the generic end-reason byte predates the expiry code.
accessRemaining in 1..75 ->
Toast.makeText(
context,
"Your access to this host has expired.",
Toast.LENGTH_LONG,
).show()
reason == SessionEndReason.LOST ->
Toast.makeText(
context,
"Connection lost — the host may be asleep. Wake it to reconnect.",
Toast.LENGTH_LONG,
).show()
SessionEndReason.HOST_ERROR ->
reason == SessionEndReason.HOST_ERROR ->
Toast.makeText(
context,
"The host ended the session with an error.",
@@ -266,10 +349,7 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
).show()
// Deliberate endings — the player quit the game, the host was stopped, or we
// closed it. Leaving the stream IS the feedback; a toast would only add noise.
SessionEndReason.GAME_EXITED,
SessionEndReason.HOST_ENDED,
SessionEndReason.LOCAL,
SessionEndReason.NONE -> {}
else -> {}
}
onSessionEnded(reason)
return@LaunchedEffect
@@ -465,15 +545,32 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
handle,
surfaceWidth = { videoView?.width?.takeIf { it > 0 } ?: decor?.width ?: 1920 },
onActiveChanged = { on -> remotePointerOn = on },
onKeyboardToggle = { keyCapture?.let { it.setImeVisible(!it.imeShown) } },
// The toggle TYPES — summoning also needs the KEYBOARD grant (hiding is free).
onKeyboardToggle = {
keyCapture?.let { v ->
if (v.imeShown || accessGrants and SessionAccess.KEYBOARD != 0) {
v.setImeVisible(!v.imeShown)
}
}
},
)
} else {
null
}
activity?.remotePointer = remote
// Shared clipboard (text v1): only when the user setting is on AND the host has a
// working clipboard service. Protocol-level opt-in + the poll thread live in the sync.
val clip = if (session.clipboardSync && NativeBridge.nativeClipSupported(handle)) {
// Everything the grant gates hang off now exists — apply the session's access level once
// up front (the poll only re-applies on change, and a restricted session is restricted
// from its first event, not from its first poll).
applyAccess(accessGrants)
// Shared clipboard (text v1): only when the user setting is on AND the session's access
// includes the clipboard AND the host has a working clipboard service. Ungranted, the
// host's policy resolution declines everything anyway (grants AND into it); not starting
// the sync is the client-side mirror — no offers announced, no poll thread for a plane
// that cannot move. Applied at session start only, like the host's own coordinator gate.
val clip = if (session.clipboardSync &&
accessGrants and SessionAccess.CLIPBOARD != 0 &&
NativeBridge.nativeClipSupported(handle)
) {
ClipboardSync(context, handle).also { it.start() }
} else {
null
@@ -594,8 +691,11 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
),
)
}
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) ==
PackageManager.PERMISSION_GRANTED -> {
// No USB pad: fall back to a bonded BLE one. The Bluetooth-permission gate lives
// inside pairedBleAddress() (it answers null, and says why, when the grant is
// missing) rather than being restated here — the grant itself is asked for where
// a user can act on it, in the console UI and the Controllers screen.
else -> {
sc2.pairedBleAddress()?.let { addr ->
Log.i("punktfunk", "SC2: no USB pad — using the paired BLE controller $addr")
sc2.startBle(addr)
@@ -699,6 +799,7 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
activity?.remotePointer = null
decor?.pointerIcon = priorPointerIcon
activity?.streamHandle = 0L
activity?.streamAccess = SessionAccess.ALL // grants are per session, like the handle
activity?.requestStreamExit = null
// Back in the menus: the SC2 (if present) resumes driving the console UI.
activity?.startSc2MenuNav()
@@ -816,8 +917,13 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
?: (runCatching { context.display }.getOrNull()?.refreshRate ?: 0f)
.roundToInt(),
)
NativeBridge.nativeStartAudio(handle, lowLatencyMode)
if (micWanted) {
NativeBridge.nativeStartAudio(handle, lowLatencyMode, isTv)
// The MIC grant is read live (a surface recreate re-runs this, and
// the mask may have changed since the last one): without it no
// capture opens — the host never attached this session to its mic
// service, so the platform's recording indicator would announce a
// mic nobody can hear.
if (micWanted && accessGrants and SessionAccess.MIC != 0) {
val sessionId =
NativeBridge.nativeStartMic(handle, initialSettings.echoCancel)
if (initialSettings.echoCancel) {
@@ -881,6 +987,22 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
)
}
}
// The Access chip — what this session is allowed to do, said in the preset vocabulary
// ("Controller only · 1 h 58 m left"), standing for the whole stream. Full control with
// no expiry — every session against an old host, and most against a new one — shows
// NOTHING: the chip exists for the sessions where input silently not landing needs an
// explanation, not as new chrome on everyone's stream. TopEnd, in the shared pill family
// (TopStart is the HUD's, TopCentre the transient cues', BottomCentre the banner's).
val accessChip = when {
accessGrants and SessionAccess.ALL == SessionAccess.ALL && accessRemaining == 0 -> null
accessRemaining > 0 ->
"${SessionAccess.label(accessGrants)} · " +
"${SessionAccess.remainingLabel(accessRemaining)} left"
else -> SessionAccess.label(accessGrants)
}
if (accessChip != null) {
AccessChip(accessChip, Modifier.align(Alignment.TopEnd).padding(12.dp))
}
// "Hold to quit" hint while the gamepad exit chord is armed — the exit debounces on a ~1 s
// hold, so without this cue a couch user reads the (deliberately no-longer-instant) chord as
// broken. Purely visual; it sits above the video and below the gesture layer.
@@ -898,7 +1020,7 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
// Above the video and below the gesture layer: it teaches touches, it must never eat one.
//
// Bottom-centre is the desktop's placement and the only edge left — TopStart is the HUD,
// TopEnd the mic badge, TopCentre the three transient cues — but MotionUnreachableHint
// TopEnd the Access chip, TopCentre the three transient cues — but MotionUnreachableHint
// already owns it, and both of these can be up at t≈0. The banner YIELDS rather than
// stacking or sliding off-centre: the notice reports something broken about THIS session
// and names the setting that fixes it, while the banner repeats shortcuts that will be
@@ -919,8 +1041,13 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
// button — all land on the same BackHandler).
add("Back leaves the stream")
// The tap lives in the pointer touch models only — passthrough gives every
// finger to the host verbatim — and needs a screen to put three fingers on.
if (hasTouch && touchMode != TouchMode.TOUCH) add("three-finger tap for stats")
// finger to the host verbatim — and needs a screen to put three fingers on,
// plus the POINTER grant (without it the gesture layer is not installed).
if (hasTouch && touchMode != TouchMode.TOUCH &&
accessGrants and SessionAccess.POINTER != 0
) {
add("three-finger tap for stats")
}
}
}.joinToString(" · "),
alpha = bannerAlpha,
@@ -951,23 +1078,35 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
// Stylus lane (design/pen-tablet-input.md §7): against a HOST_CAP_PEN host a stylus
// splits out of BOTH touch models onto the pen plane; its heartbeat coroutine keeps a
// stationary held stroke alive (and its cancellation lifts everything on teardown).
val stylus = remember(handle) {
if (NativeBridge.nativeHostSupportsPen(handle)) StylusStream(handle) else null
// The POINTER grant gates the whole touch/stylus capture layer — "don't capture what
// can't land": ungranted, no gesture handler is installed at all (and no pen lane opens),
// rather than fingers being read into events the host will drop. Keyed on the grant so an
// AccessUpdate flipping it mid-session swaps the layer live.
val pointerOk = accessGrants and SessionAccess.POINTER != 0
val stylus = remember(handle, pointerOk) {
if (pointerOk && NativeBridge.nativeHostSupportsPen(handle)) StylusStream(handle) else null
}
if (stylus != null) {
LaunchedEffect(stylus) { stylus.heartbeatLoop() }
}
Box(
videoFit.pointerInput(handle, touchMode) {
when (touchMode) {
TouchMode.TOUCH -> streamTouchPassthrough(handle, stylus)
videoFit.pointerInput(handle, touchMode, pointerOk) {
when {
!pointerOk -> {} // no capture — the Access chip is what says why
touchMode == TouchMode.TOUCH -> streamTouchPassthrough(handle, stylus)
else -> streamTouchInput(
handle,
stylus,
trackpad = touchMode == TouchMode.TRACKPAD,
invertScroll = initialSettings.invertScroll,
onCycleStats = { statsVerbosity = statsVerbosity.next() },
onKeyboard = { show -> keyCapture?.setImeVisible(show) },
// The summon rides the pointer gesture but TYPES — so it also needs the
// KEYBOARD grant (dismissing is always allowed).
onKeyboard = { show ->
if (!show || accessGrants and SessionAccess.KEYBOARD != 0) {
keyCapture?.setImeVisible(show)
}
},
)
}
},
@@ -1031,6 +1170,25 @@ private fun MicChordHint(text: String, modifier: Modifier = Modifier) {
)
}
/**
* The standing Access chip the session's access level in the preset vocabulary, with the live
* countdown when the grant expires ("Controller only · 1 h 58 m left"). Same pill family as the
* other in-stream overlays, sized down a step because it stands for the whole session rather than
* flashing a moment's confirmation. Only composed when there is something to say: a full-control
* permanent session today's normal shows nothing at all.
*/
@Composable
private fun AccessChip(text: String, modifier: Modifier = Modifier) {
Text(
text,
modifier = modifier
.background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(8.dp))
.padding(horizontal = 10.dp, vertical = 5.dp),
color = Color.White,
fontSize = 12.sp,
)
}
/**
* "This pad's gyro can't reach the game" shown briefly when a captured controller with motion
* meets a session whose virtual pad has no motion plane (the X-Box classes have no gyro in their
@@ -0,0 +1,98 @@
package io.unom.punktfunk
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* [sc2BluetoothGrantOffered] is pure table-tested over its inputs.
*
* The rule exists because a Steam Controller 2 paired over Bluetooth is invisible to this client
* until `BLUETOOTH_CONNECT` is granted (the bonded list answers "nothing is paired" rather than
* refusing), and nothing ever asked for it so the pad silently never engaged while the same
* controller over USB worked. The offer has to reach those users without becoming a Bluetooth
* prompt for everyone else, which is the whole content of these assertions.
*/
class Sc2BluetoothGrantTest {
/** The reported case: an SC2 sitting in lizard mode that we cannot capture. */
@Test
fun offeredWhenAnSc2IsAttachedButBluetoothIsNot() {
assertTrue(
sc2BluetoothGrantOffered(
permissionGranted = false,
usbSc2 = false,
sc2Attached = true,
anyPadDetected = false,
),
)
// Still offered next to other working pads — the SC2 is the one we can't reach.
assertTrue(
sc2BluetoothGrantOffered(
permissionGranted = false,
usbSc2 = false,
sc2Attached = true,
anyPadDetected = true,
),
)
}
/**
* The probe reads an SC2's USB identity, which we cannot assume a BLE stack reports. When it
* misses, "no controller detected" is exactly when a blind spot is worth naming.
*/
@Test
fun offeredWhenNothingWasDetectedAtAll() {
assertTrue(
sc2BluetoothGrantOffered(
permissionGranted = false,
usbSc2 = false,
sc2Attached = false,
anyPadDetected = false,
),
)
}
/** Never a prompt for someone with working controllers and no sign of an SC2. */
@Test
fun notOfferedToUsersWithNoSignOfAnSc2() {
assertFalse(
sc2BluetoothGrantOffered(
permissionGranted = false,
usbSc2 = false,
sc2Attached = false,
anyPadDetected = true,
),
)
}
/** Granting it changes nothing that is already captured over USB — wired and Puck alike. */
@Test
fun notOfferedWhenTheSc2IsOnUsb() {
assertFalse(
sc2BluetoothGrantOffered(
permissionGranted = false,
usbSc2 = true,
sc2Attached = true,
anyPadDetected = false,
),
)
}
/** Nothing to ask for once it is held — including on releases that grant it at install time. */
@Test
fun notOfferedOncePermitted() {
for (attached in listOf(true, false)) {
for (pads in listOf(true, false)) {
assertFalse(
sc2BluetoothGrantOffered(
permissionGranted = true,
usbSc2 = false,
sc2Attached = attached,
anyPadDetected = pads,
),
)
}
}
}
}
@@ -34,19 +34,34 @@ class ScreenshotTest {
// cursor via an infinite animation that otherwise keeps Compose perpetually "busy", so
// setContent's wait-for-idle never returns. Frozen, the capture is also deterministic.
/** Full-screen content scenes: the compose root fills the device, so a root capture is the shot. */
private fun shootRoot(name: String, content: @androidx.compose.runtime.Composable () -> Unit) {
/**
* Full-screen content scenes: the compose root fills the device, so a root capture is the
* shot. [statusBar] draws the fake system bar and pushes content below it (see
* [ShotStatusFrame]) off for the immersive surfaces (stream, console shell), which hide
* the real bar too.
*/
private fun shootRoot(
name: String,
statusBar: Boolean = true,
content: @androidx.compose.runtime.Composable () -> Unit,
) {
compose.mainClock.autoAdvance = false
compose.setContent { ShotTheme(content) }
compose.setContent { ShotTheme { if (statusBar) ShotStatusFrame(content) else content() } }
compose.mainClock.advanceTimeBy(800)
compose.onRoot().captureRoboImage("$out/phone-$name.png")
}
/** Dialog scenes: the AlertDialog is a separate window, so capture the whole screen (all windows). */
private fun shootScreen(name: String, content: @androidx.compose.runtime.Composable () -> Unit) {
private fun shootScreen(
name: String,
statusBar: Boolean = true,
content: @androidx.compose.runtime.Composable () -> Unit,
) {
compose.mainClock.autoAdvance = false
compose.setContent { ShotTheme(content) }
compose.mainClock.advanceTimeBy(800)
compose.setContent { ShotTheme { if (statusBar) ShotStatusFrame(content) else content() } }
// 1.6 s, not 0.8: a ModalBottomSheet's entrance spring is still mid-rise at 0.8 s and the
// add-host sheet's Connect button was captured half below the frame.
compose.mainClock.advanceTimeBy(1600)
captureScreenRoboImage("$out/phone-$name.png")
}
@@ -73,25 +88,25 @@ class ScreenshotTest {
@Test
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") // landscape — the stream is immersive
fun stream() = shootRoot("stream") { StreamScene(io.unom.punktfunk.StatsVerbosity.DETAILED) }
fun stream() = shootRoot("stream", statusBar = false) { StreamScene(io.unom.punktfunk.StatsVerbosity.DETAILED) }
@Test
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
fun streamCompact() = shootRoot("stream-compact") { StreamScene(io.unom.punktfunk.StatsVerbosity.COMPACT) }
fun streamCompact() = shootRoot("stream-compact", statusBar = false) { StreamScene(io.unom.punktfunk.StatsVerbosity.COMPACT) }
@Test
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
fun streamNormal() = shootRoot("stream-normal") { StreamScene(io.unom.punktfunk.StatsVerbosity.NORMAL) }
fun streamNormal() = shootRoot("stream-normal", statusBar = false) { StreamScene(io.unom.punktfunk.StatsVerbosity.NORMAL) }
// Both banner texts, in the stream's own landscape geometry — it is bottom-centre, so the
// aspect is load-bearing.
@Test
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
fun streamBannerPad() = shootRoot("stream-banner-pad") { StreamBannerScene(pad = true) }
fun streamBannerPad() = shootRoot("stream-banner-pad", statusBar = false) { StreamBannerScene(pad = true) }
@Test
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
fun streamBannerTouch() = shootRoot("stream-banner-touch") { StreamBannerScene(pad = false) }
fun streamBannerTouch() = shootRoot("stream-banner-touch", statusBar = false) { StreamBannerScene(pad = false) }
// The touch flow is a Material dialog over the host grid (a separate window → shootScreen).
@Test
@@ -114,15 +129,15 @@ class ScreenshotTest {
// The console flow is the full-screen aurora takeover (a root capture).
@Test
fun connectingConsole() = shootRoot("connecting-console") { ConnectConsoleScene() }
fun connectingConsole() = shootRoot("connecting-console", statusBar = false) { ConnectConsoleScene() }
@Test
fun consoleSettings() = shootRoot("console-settings") { ConsoleSettingsScene() }
fun consoleSettings() = shootRoot("console-settings", statusBar = false) { ConsoleSettingsScene() }
/** A PALE palette: the whole UI flips to dark ink on white frost, which only a shot proves. */
@Test
fun consoleSettingsLight() =
shootRoot("console-settings-light") { ConsoleSettingsScene(paletteId = "holo") }
shootRoot("console-settings-light", statusBar = false) { ConsoleSettingsScene(paletteId = "holo") }
/**
* Landscape the orientation the console actually runs in, and a DIFFERENT layout since the
@@ -132,16 +147,16 @@ class ScreenshotTest {
@Test
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
fun consoleSettingsLandscape() =
shootRoot("console-settings-landscape") { ConsoleSettingsScene() }
shootRoot("console-settings-landscape", statusBar = false) { ConsoleSettingsScene() }
// The console home, the screen the living backdrop is most of. The default sdk (36) draws the
// real AGSL MESH field; the paired API-31 shot below draws the blob fallback, so the two
// renderings of the same palette can be compared rather than assumed equivalent.
@Test
fun consoleHome() = shootRoot("console-home") { ConsoleHomeScene() }
fun consoleHome() = shootRoot("console-home", statusBar = false) { ConsoleHomeScene() }
@Test
fun consoleHomeLight() = shootRoot("console-home-light") { ConsoleHomeScene(paletteId = "holo") }
fun consoleHomeLight() = shootRoot("console-home-light", statusBar = false) { ConsoleHomeScene(paletteId = "holo") }
/**
* Landscape the orientation the console UI actually runs in, and the only one wide enough to
@@ -149,7 +164,7 @@ class ScreenshotTest {
*/
@Test
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
fun consoleHomeLandscape() = shootRoot("console-home-landscape") { ConsoleHomeScene() }
fun consoleHomeLandscape() = shootRoot("console-home-landscape", statusBar = false) { ConsoleHomeScene() }
/**
* The API 31/32 field. `RuntimeShader` is API 33+, so everything below it keeps the four
@@ -158,24 +173,46 @@ class ScreenshotTest {
*/
@Test
@Config(sdk = [31], qualifiers = "w360dp-h800dp-xxhdpi")
fun consoleHomeBlobFallback() = shootRoot("console-home-blobs") { ConsoleHomeScene() }
fun consoleHomeBlobFallback() = shootRoot("console-home-blobs", statusBar = false) { ConsoleHomeScene() }
// The two screens the console reached for the first time in WP8.3. Each is shot on a dark AND a
// pale palette, because the console draws them through a ColorScheme derived from the palette's
// ink — and the pale one is the only place a grey-on-pastel slip can show up.
@Test
fun consoleLicenses() = shootRoot("console-licenses") { ConsoleLicensesScene() }
fun consoleLicenses() = shootRoot("console-licenses", statusBar = false) { ConsoleLicensesScene() }
@Test
fun consoleLicensesLight() =
shootRoot("console-licenses-light") { ConsoleLicensesScene(paletteId = "holo") }
shootRoot("console-licenses-light", statusBar = false) { ConsoleLicensesScene(paletteId = "holo") }
@Test
fun consoleControllers() = shootRoot("console-controllers") { ConsoleControllersScene() }
fun consoleControllers() = shootRoot("console-controllers", statusBar = false) { ConsoleControllersScene() }
/**
* The touch presentation, pads connected landscape, like every store frame: the app is
* built for horizontal use, and a portrait capture shows a layout nobody streams in.
*/
@Test
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
fun controllers() = shootRoot("controllers") { ControllersScene() }
/** The console presentation at the same landscape geometry — the store's FEEL THE GAME frame. */
@Test
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
fun consoleControllersLandscape() =
shootRoot("console-controllers-landscape", statusBar = false) { ConsoleControllersScene() }
/**
* The library coverflow with a mock shelf the store's PICK & PLAY frame. Landscape: the
* orientation the coverflow actually runs in, and the only one wide enough for neighbours.
*/
@Test
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
fun library() = shootRoot("library", statusBar = false) { LibraryScene() }
@Test
fun consoleControllersLight() =
shootRoot("console-controllers-light") { ConsoleControllersScene(paletteId = "holo") }
shootRoot("console-controllers-light", statusBar = false) { ConsoleControllersScene(paletteId = "holo") }
@Test
fun trust() = shootScreen("trust") {
@@ -197,4 +234,13 @@ class ScreenshotTest {
HostsScene()
PairDialog()
}
/**
* The add-host sheet (separate window whole-screen capture). Pixel-like geometry, not the
* default 360×800dp: same 1080×2400 px, but at 420 dpi the extra dp headroom is what lets the
* sheet's Connect button the row that carries the resolution promise fit in frame.
*/
@Test
@Config(sdk = [36], qualifiers = "w411dp-h915dp-420dpi")
fun addHost() = shootScreen("add-host") { AddHostScene() }
}
@@ -1,14 +1,35 @@
package io.unom.punktfunk.screenshots
import android.content.Context
import android.content.res.Configuration
import android.graphics.Bitmap
import android.graphics.BlendMode
import android.graphics.Canvas
import android.graphics.LinearGradient
import android.graphics.Paint
import android.graphics.Path
import android.graphics.RadialGradient
import android.graphics.Shader
import android.graphics.Typeface
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BatteryFull
import androidx.compose.material.icons.filled.SignalCellular4Bar
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material3.Icon
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
@@ -35,8 +56,27 @@ import androidx.compose.runtime.CompositionLocalProvider
import io.unom.punktfunk.GamepadHome
import io.unom.punktfunk.GamepadInk
import io.unom.punktfunk.GamepadPalette
import coil.ImageLoader
import coil.test.FakeImageLoaderEngine
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import io.unom.punktfunk.AddHostSheet
import io.unom.punktfunk.ConsoleControllersScreen
import io.unom.punktfunk.ConsoleHeader
import io.unom.punktfunk.ConsoleLegendInset
import io.unom.punktfunk.ConsoleLicensesScreen
import io.unom.punktfunk.ControllersScreen
import io.unom.punktfunk.Coverflow
import io.unom.punktfunk.GamepadAuroraBackground
import io.unom.punktfunk.GamepadHintBar
import io.unom.punktfunk.PadGlyph
import io.unom.punktfunk.PadInfo
import io.unom.punktfunk.consoleLegendInsets
import io.unom.punktfunk.consoleSafeArea
import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.library.Artwork
import io.unom.punktfunk.kit.library.GameEntry
import androidx.compose.ui.platform.LocalConfiguration
import io.unom.punktfunk.GamepadSettingsScreen
import io.unom.punktfunk.HomeTile
import io.unom.punktfunk.LocalGamepadInk
@@ -70,6 +110,51 @@ internal fun ShotTheme(content: @Composable () -> Unit) {
MaterialTheme(colorScheme = BrandDark, content = content)
}
/**
* Robolectric has no system UI, so every capture was missing the status bar and the content sat
* where the bar belongs on the Pixel render the app title collided with the camera punch-hole.
* This frame draws a plausible bar (time left, radios right, the CENTRE left empty for the hole)
* and pushes the scene below it, the same geometry real insets produce. The height mirrors a
* Pixel's tall bar as measured off a real 1344×2992 capture (~145 px 40 dp).
*/
@Composable
internal fun ShotStatusFrame(content: @Composable () -> Unit) {
Column(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
Row(
Modifier.fillMaxWidth().height(40.dp).padding(horizontal = 28.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"21:47",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.9f),
)
Row(
horizontalArrangement = Arrangement.spacedBy(5.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Filled.Wifi, contentDescription = null,
tint = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.9f),
modifier = Modifier.size(15.dp),
)
Icon(
Icons.Filled.SignalCellular4Bar, contentDescription = null,
tint = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.9f),
modifier = Modifier.size(14.dp),
)
Icon(
Icons.Filled.BatteryFull, contentDescription = null,
tint = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.9f),
modifier = Modifier.size(16.dp),
)
}
}
Box(Modifier.weight(1f).fillMaxWidth()) { content() }
}
}
private data class MockHost(
val name: String,
val address: String,
@@ -510,8 +595,8 @@ internal fun ConsoleHomeScene(paletteId: String = "violet") {
* whole risk. Their touch presentation is inked by the app theme, which is always dark, so nothing
* before this could catch light-grey body text stranded on a pastel field.
*
* Robolectric enumerates no input devices, so the controllers scene renders its deterministic
* "nothing connected" state.
* Robolectric enumerates no input devices, so the controllers scenes inject [shotPads] the
* deterministic connected-pads state the store listing needs.
*/
@Composable
internal fun ConsoleLicensesScene(paletteId: String = "violet") =
@@ -520,14 +605,358 @@ internal fun ConsoleLicensesScene(paletteId: String = "violet") =
@Composable
internal fun ConsoleControllersScene(paletteId: String = "violet") =
ConsolePalette(paletteId) {
ConsoleControllersScreen(gamepadSetting = 0, onBack = {}, navActive = false)
// Robolectric enumerates no input devices, so the shot injects the two pads the store
// listing talks about — the empty "no controller detected" state proves the palette but
// sells nothing.
ConsoleControllersScreen(
gamepadSetting = 0, onBack = {}, navActive = false, padsOverride = shotPads(),
)
}
/**
* The touch presentation of the same screen, with the same injected pads. Wrapped in a background
* [Surface]: the activity provides the dark ground in the app, and without one here the content
* color falls back to black-on-white while the cards stay dark.
*/
@Composable
internal fun ControllersScene() =
Surface(color = MaterialTheme.colorScheme.background) {
ControllersScreen(gamepadSetting = 0, onBack = {}, padsOverride = shotPads())
}
/**
* The "Add a host" bottom sheet over the host grid the store's onboarding frame. State is
* hoisted in production (ConnectScreen), so the scene passes a filled-in form directly; the
* mode label mirrors what a paired 120 Hz phone shows on the connect button.
*/
@Composable
internal fun AddHostScene() {
HostsScene()
AddHostSheet(
hostName = "Living Room PC", onHostNameChange = {},
host = "192.168.1.42", onHostChange = {},
port = "9777", onPortChange = {},
connecting = false, modeLabel = "2992×1344@120",
onDismiss = {}, onConnect = { _, _, _ -> },
)
}
/** The two pads the store listing names: DualSense (adaptive triggers, LEDs, rumble) and Xbox. */
internal fun shotPads() = listOf(
PadInfo(
name = "DualSense Wireless Controller",
detail = "054C:0CE6 · gamepad · joystick",
forwarded = true, controllerNumber = 1,
resolvedPref = Gamepad.PREF_DUALSENSE, canRumble = true,
),
PadInfo(
name = "Xbox Wireless Controller",
detail = "045E:0B13 · gamepad · joystick",
forwarded = true, controllerNumber = 2,
resolvedPref = Gamepad.PREF_XBOXONE, canRumble = true,
),
)
/**
* Publish the palette locals `App` would normally provide. A scene that calls a console screen
* directly gets the DEFAULT dark ink without this, and a pale-palette shot would then silently
* prove nothing at all.
*/
/**
* The game-library coverflow (the real [Coverflow] over the real console chrome) with a mock shelf.
* The library screen itself can't be shot its state comes off the network so the scene rebuilds
* the same shell [io.unom.punktfunk.LibraryScreen] draws around it: aurora, header, floating hint
* bar. Cover art is answered synchronously by coil-test's [FakeImageLoaderEngine] with generated
* posters, so the frozen animation clock never races an async load.
*/
@Composable
internal fun LibraryScene(paletteId: String = "violet") = ConsolePalette(paletteId) {
val context = LocalContext.current
val loader = remember { shotLibraryLoader(context) }
val games = remember { shotGames() }
val hazeState = remember { HazeState() }
val landscape =
LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
Box(Modifier.fillMaxSize()) {
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
GamepadAuroraBackground(Modifier.fillMaxSize())
Column(Modifier.fillMaxSize().consoleSafeArea()) {
ConsoleHeader("Living Room PC — Library")
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
Coverflow(games, loader, navActive = false, onLaunch = {})
}
}
}
Box(
Modifier.align(Alignment.BottomStart)
.consoleLegendInsets(landscape)
.padding(ConsoleLegendInset),
) {
GamepadHintBar(
listOf(PadGlyph.hint('A', "Launch"), PadGlyph.hint('B', "Close")),
hazeState = hazeState,
)
}
}
}
/** A believable shelf: four titles with art plus the Steam launcher entry (brand-mark tile). */
private fun shotGames() = listOf(
GameEntry("custom:aurora", "custom", "Aurora Drift", Artwork("shot://art/aurora", null, null)),
GameEntry("steam:starfall", "steam", "Starfall Vale", Artwork("shot://art/starfall", null, null)),
GameEntry("heroic:neon", "heroic", "Neon Circuit", Artwork("shot://art/neon", null, null)),
GameEntry("gog:ember", "gog", "Ember Peaks", Artwork("shot://art/ember", null, null)),
GameEntry("steam:launcher", "steam", "Steam", Artwork(null, null, null), role = "launcher", icon = "steam"),
)
private fun shotLibraryLoader(context: Context): ImageLoader {
val engine = FakeImageLoaderEngine.Builder()
.intercept("shot://art/aurora", poster(context, "AURORA DRIFT", ::drawAurora))
.intercept("shot://art/starfall", poster(context, "STARFALL VALE", ::drawStarfall))
.intercept("shot://art/neon", poster(context, "NEON CIRCUIT", ::drawNeon))
.intercept("shot://art/ember", poster(context, "EMBER PEAKS", ::drawEmber))
.default(ColorDrawable(0xFF221E44.toInt()))
.build()
return ImageLoader.Builder(context).components { add(engine) }.build()
}
// The four shelf posters, drawn procedurally at capture time — the same designs the Apple
// harness draws with CoreGraphics (`ShotPosterArt.swift`), so both listings show the same shelf.
// All geometry below is in a 600×900, y-UP space (matching the CG source); `posterY()` flips it.
private const val POSTER_W = 600
private const val POSTER_H = 900
private fun posterY(v: Float) = POSTER_H - v
/** Deterministic LCG (same constants and seeds as the Swift twin) so every capture is identical. */
private class ShotRand(var state: ULong) {
fun next(): Float {
state = state * 6364136223846793005UL + 1442695040888963407UL
return (state shr 33).toFloat() / (1L shl 31).toFloat()
}
fun range(lo: Float, hi: Float) = lo + next() * (hi - lo)
}
private fun poster(context: Context, title: String, draw: (Canvas) -> Unit): Drawable {
val bmp = Bitmap.createBitmap(POSTER_W, POSTER_H, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bmp)
draw(canvas)
posterTitle(canvas, title)
return BitmapDrawable(context.resources, bmp)
}
/** Vertical gradient over the full canvas; stops bottom-to-top as (location, color). */
private fun sky(canvas: Canvas, stops: List<Pair<Float, Int>>) {
canvas.drawRect(
0f, 0f, POSTER_W.toFloat(), POSTER_H.toFloat(),
Paint(Paint.ANTI_ALIAS_FLAG).apply {
shader = LinearGradient(
0f, POSTER_H.toFloat(), 0f, 0f,
stops.map { it.second }.toIntArray(),
stops.map { it.first }.toFloatArray(),
Shader.TileMode.CLAMP,
)
},
)
}
private fun glowDot(canvas: Canvas, x: Float, y: Float, radius: Float, color: Int) {
canvas.drawCircle(
x, posterY(y), radius,
Paint(Paint.ANTI_ALIAS_FLAG).apply {
shader = RadialGradient(
x, posterY(y), radius, color, color and 0x00FFFFFF, Shader.TileMode.CLAMP,
)
},
)
}
private fun shotAlpha(color: Int, a: Float) = (color and 0x00FFFFFF) or ((a * 255).toInt() shl 24)
/** Three strokes, wide-and-faint to thin-and-bright, in screen blend — the cheap neon glow. */
private fun glowStroke(canvas: Canvas, path: Path, width: Float, color: Int) {
for ((mult, a) in listOf(2.6f to 0.12f, 1.3f to 0.28f, 0.55f to 0.85f)) {
canvas.drawPath(
path,
Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
strokeWidth = width * mult
this.color = shotAlpha(color, a)
blendMode = BlendMode.SCREEN
},
)
}
}
private fun posterTitle(canvas: Canvas, title: String) {
sky(canvas, listOf(0f to shotAlpha(0x000000, 0.55f), 0.22f to shotAlpha(0x000000, 0f)))
canvas.drawText(
title, POSTER_W / 2f, posterY(72f),
Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = shotAlpha(0xFFFFFF, 0.94f)
textSize = 46f
letterSpacing = 5f / 46f
typeface = Typeface.create("sans-serif-condensed", Typeface.BOLD)
textAlign = Paint.Align.CENTER
setShadowLayer(8f, 0f, 2f, shotAlpha(0x000000, 0.6f))
},
)
}
private fun drawAurora(canvas: Canvas) {
sky(canvas, listOf(0f to 0xFF221E5C.toInt(), 0.45f to 0xFF141040.toInt(), 1f to 0xFF0B0830.toInt()))
val rng = ShotRand(11UL)
repeat(48) {
val x = rng.range(0f, 600f)
val y = rng.range(300f, 890f)
val r = rng.range(1.4f, 3.2f)
glowDot(canvas, x, y, r, shotAlpha(0xFFFFFF, rng.range(0.25f, 0.8f)))
}
data class Ribbon(
val base: Float, val amp: Float, val freq: Float,
val phase: Float, val w: Float, val c: Int,
)
for (r in listOf(
Ribbon(700f, 55f, 1.15f, 0.4f, 30f, 0xFF6656F2.toInt()),
Ribbon(615f, 70f, 1.4f, 2.2f, 24f, 0xFF8F7BFF.toInt()),
Ribbon(530f, 45f, 0.95f, 4.1f, 18f, 0xFF35D0C5.toInt()),
)) {
val path = Path()
for (i in 0..60) {
val t = i / 60f
val x = t * 600f
val y = r.base + r.amp * kotlin.math.sin(t * Math.PI.toFloat() * r.freq + r.phase) + 40f * t
if (i == 0) path.moveTo(x, posterY(y)) else path.lineTo(x, posterY(y))
}
glowStroke(canvas, path, r.w, r.c)
}
// A low ridge grounds the scene — without it the poster's bottom half is bare sky.
for ((fill, baseline, rough) in listOf(
Triple(0xFF191345.toInt(), 212f, 30f),
Triple(0xFF0E0A2E.toInt(), 148f, 38f),
)) {
val path = Path()
path.moveTo(0f, posterY(0f))
path.lineTo(0f, posterY(baseline + rng.range(-rough, rough)))
for (i in 1..9) {
val x = i / 9f * 600f
path.lineTo(x, posterY(baseline + rng.range(-rough, rough)))
}
path.lineTo(600f, posterY(0f))
path.close()
canvas.drawPath(path, Paint(Paint.ANTI_ALIAS_FLAG).apply { color = fill })
}
}
private fun drawStarfall(canvas: Canvas) {
sky(
canvas,
listOf(
0f to 0xFF2A0C24.toInt(), 0.35f to 0xFF7A2B58.toInt(),
0.8f to 0xFFE86FA8.toInt(), 1f to 0xFFF7A8C8.toInt(),
),
)
val rng = ShotRand(23UL)
repeat(6) {
val hx = rng.range(60f, 560f)
val hy = rng.range(420f, 840f)
val len = rng.range(90f, 170f)
val dx = kotlin.math.cos(2.15f)
val dy = kotlin.math.sin(2.15f)
val path = Path()
path.moveTo(hx, posterY(hy))
path.lineTo(hx + dx * len, posterY(hy + dy * len))
glowStroke(canvas, path, 4f, 0xFFFFE3EF.toInt())
glowDot(canvas, hx, hy, 11f, shotAlpha(0xFFFFFF, 0.9f))
}
for ((fill, baseline, rough) in listOf(
Triple(0xFF3A1430.toInt(), 300f, 26f),
Triple(0xFF1D0818.toInt(), 216f, 34f),
)) {
val path = Path()
path.moveTo(0f, posterY(0f))
path.lineTo(0f, posterY(baseline))
for (i in 1..8) {
val x = i / 8f * 600f
path.lineTo(x, posterY(baseline + rng.range(-rough, rough)))
}
path.lineTo(600f, posterY(0f))
path.close()
canvas.drawPath(path, Paint(Paint.ANTI_ALIAS_FLAG).apply { color = fill })
}
}
private fun drawNeon(canvas: Canvas) {
sky(canvas, listOf(0f to 0xFF0A2A33.toInt(), 1f to 0xFF04161C.toInt()))
val rng = ShotRand(7UL)
val ring = Path().apply {
addOval(300f - 105f, posterY(560f) - 105f, 300f + 105f, posterY(560f) + 105f, Path.Direction.CW)
}
glowStroke(canvas, ring, 10f, 0xFF35D0C5.toInt())
val gateX = listOf(-105f, 105f, 0f, 0f)
val gateY = listOf(0f, 0f, -105f, 105f)
for (i in 0 until 9) {
var px: Float
var py: Float
if (i < 4) {
px = 300f + gateX[i]
py = 560f + gateY[i]
} else {
px = 40f * kotlin.math.round(rng.range(1f, 14f))
py = 40f * kotlin.math.round(rng.range(1f, 21f))
}
val path = Path()
path.moveTo(px, posterY(py))
var horizontal = rng.next() > 0.5f
repeat(rng.range(3f, 6f).toInt()) {
val step = 40f * kotlin.math.round(rng.range(1f, 4f)) * (if (rng.next() > 0.5f) 1f else -1f)
if (horizontal) px = (px + step).coerceIn(20f, 580f) else py = (py + step).coerceIn(20f, 880f)
path.lineTo(px, posterY(py))
horizontal = !horizontal
}
val color = if (rng.next() > 0.6f) 0xFF7FE8DE.toInt() else 0xFF35D0C5.toInt()
glowStroke(canvas, path, 5f, color)
glowDot(canvas, px, py, 12f, shotAlpha(color, 0.9f))
}
}
private fun drawEmber(canvas: Canvas) {
sky(
canvas,
listOf(
0f to 0xFF200A04.toInt(), 0.3f to 0xFF7A2E12.toInt(),
0.42f to 0xFFEF8F4B.toInt(), 1f to 0xFF2A0E06.toInt(),
),
)
glowDot(canvas, 300f, 385f, 160f, shotAlpha(0xFFC37A, 0.85f))
val rng = ShotRand(41UL)
for ((fill, baseline, rough) in listOf(
Triple(0xFF5A2410.toInt(), 340f, 42f),
Triple(0xFF401708.toInt(), 255f, 56f),
Triple(0xFF200A04.toInt(), 165f, 48f),
)) {
val path = Path()
path.moveTo(0f, posterY(0f))
path.lineTo(0f, posterY(baseline + rng.range(-rough, rough)))
for (i in 1..10) {
val x = i / 10f * 600f
path.lineTo(x, posterY(baseline + rng.range(-rough, rough)))
}
path.lineTo(600f, posterY(0f))
path.close()
canvas.drawPath(path, Paint(Paint.ANTI_ALIAS_FLAG).apply { color = fill })
}
repeat(20) {
val x = rng.range(30f, 570f)
val y = rng.range(180f, 620f)
val r = rng.range(2.5f, 6f)
glowDot(canvas, x, y, r, shotAlpha(0xFFB067, rng.range(0.35f, 0.9f)))
}
}
@Composable
private fun ConsolePalette(paletteId: String, content: @Composable () -> Unit) {
val palette = GamepadPalette.named(paletteId)
@@ -0,0 +1,59 @@
package io.unom.punktfunk.screenshots
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* The same Roborazzi harness as ScreenshotTest, at Android TV geometry: 960×540dp in the
* `television` UI mode at xhdpi (2.0×) = 1920×1080 px the Play Store's 16:9 TV screenshot size,
* captured 1:1 with no resampling. Only the screens that exist on a TV are shot here: the
* gamepad-console shell (what LEANBACK_LAUNCHER opens into) and the in-stream view. Files are
* prefixed `tv-` so the artifact separates the form factors.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [36], qualifiers = "w960dp-h540dp-television-xhdpi")
class TvScreenshotTest {
@get:Rule
val compose = createAndroidComposeRule<ComponentActivity>()
private val out = "build/outputs/roborazzi"
private fun shootRoot(name: String, content: @androidx.compose.runtime.Composable () -> Unit) {
compose.mainClock.autoAdvance = false
compose.setContent { ShotTheme(content) }
compose.mainClock.advanceTimeBy(800)
compose.onRoot().captureRoboImage("$out/tv-$name.png")
}
@Test
fun stream() = shootRoot("stream") { StreamScene(io.unom.punktfunk.StatsVerbosity.COMPACT) }
@Test
fun streamDetailed() =
shootRoot("stream-detailed") { StreamScene(io.unom.punktfunk.StatsVerbosity.DETAILED) }
@Test
fun consoleHome() = shootRoot("console-home") { ConsoleHomeScene() }
@Test
fun consoleSettings() = shootRoot("console-settings") { ConsoleSettingsScene() }
@Test
fun consoleControllers() = shootRoot("console-controllers") { ConsoleControllersScene() }
/** The library coverflow at TV geometry — the store's PICK & PLAY frame for the TV listing. */
@Test
fun library() = shootRoot("library") { LibraryScene() }
@Test
fun connectingConsole() = shootRoot("connecting-console") { ConnectConsoleScene() }
}
+15 -1
View File
@@ -9,7 +9,8 @@ tolerates it being raw JSON *or* base64-encoded JSON.
Usage (upload a new build):
SERVICE_ACCOUNT_JSON='<raw-or-base64 SA key>' \
python3 play-upload.py --package io.unom.punktfunk \
--aab path/to/app-release.aab --track internal --status completed [--no-commit]
--aab path/to/app-release.aab --track beta --also-track alpha \
--status completed [--no-commit]
Usage (promote a build that is already on Play, no rebuild):
python3 play-upload.py --package io.unom.punktfunk \
@@ -164,6 +165,9 @@ def main():
ap.add_argument("--promote-from", metavar="TRACK",
help="with --promote: assert the code is on TRACK, then clear TRACK")
ap.add_argument("--track", default="internal")
ap.add_argument("--also-track", action="append", default=[], metavar="TRACK",
help="assign the same versionCode to this track too, in the same edit "
"(repeatable). Canary uses it to feed open + closed testing at once.")
ap.add_argument("--status", default="completed")
ap.add_argument("--user-fraction", type=float,
help="staged rollout fraction, 0<f<1; required by --status inProgress")
@@ -183,6 +187,11 @@ def main():
sys.exit(f"ERROR: --user-fraction must be strictly between 0 and 1 (got {a.user_fraction})")
if a.aab and not os.path.isfile(a.aab):
sys.exit(f"ERROR: AAB not found: {a.aab}")
for t in a.also_track:
# `--also-track <promote-from>` would assign and clear the same track in one edit;
# whichever PUT lands second silently wins. Refuse the ambiguity instead.
if t in (a.track, a.promote_from):
sys.exit(f"ERROR: --also-track {t} duplicates --track/--promote-from")
notes = load_release_notes(a.release_notes_file, a.release_notes_language) \
if a.release_notes_file else None
@@ -209,6 +218,11 @@ def main():
put_track(app, edit, tok, a.track, [vc], a.status, a.user_fraction, notes)
print(f"assigned versionCode={vc} -> track={a.track} status={a.status}"
+ (f" userFraction={a.user_fraction}" if a.user_fraction is not None else ""))
# Same edit, so one commit (and one Play review) covers every track the code lands on —
# the tracks can never disagree about which canary is current.
for t in a.also_track:
put_track(app, edit, tok, t, [vc], a.status, a.user_fraction, notes)
print(f"assigned versionCode={vc} -> track={t} status={a.status}")
# Same edit as the assignment above, so the code is never active on both tracks at once.
if a.promote_from:
put_track(app, edit, tok, a.promote_from, [], a.status)
@@ -105,9 +105,12 @@ class DeviceGyro(
for (i in 0..2) lastAccel[i] = Gamepad.motionAccelWire(v[i])
}
Sensor.TYPE_GYROSCOPE -> {
// The write gate, per sample: pad 0 must exist (motion never creates a pad)
// and must not be a capture link's (its own IMU is streaming).
val write = router.padPresent(0) && !router.padHasOwnMotion(0)
// The write gate, per sample: sends must be on at all (the forwarding
// preference AND the session's GAMEPAD grant — an AccessUpdate can revoke it
// mid-session), pad 0 must exist (motion never creates a pad) and must not be
// a capture link's (its own IMU is streaming).
val write = router.sendsEnabled() && router.padPresent(0) &&
!router.padHasOwnMotion(0)
if (!write) {
// Stand-down edge: never leave the last angular velocity latched host-side.
if (wasWriting) {
@@ -195,6 +195,29 @@ object Gamepad {
/** First connected gamepad/joystick [InputDevice], or null when none is attached. */
fun firstPad(): InputDevice? = pads().firstOrNull()
/**
* True when a Steam Controller 2 is attached as an ORDINARY [InputDevice] which, for a pad
* this client wants to capture, means an uncaptured one still in lizard mode.
*
* Deliberately not filtered by [isPad]: lizard mode emulates a keyboard and mouse, so an SC2
* is never a gamepad source and every other pad-shaped query in the client steps right past
* it. That is also why this is worth having a wired or Puck SC2 is found by enumerating USB
* (no permission needed), but a BLE-paired one is invisible until `BLUETOOTH_CONNECT` is
* granted, and asking for Bluetooth on the chance that someone might own one is not something
* to put in front of every user. This is the permission-free signal that the pad is genuinely
* there, so the request can be made to the people it helps and to nobody else.
*
* A false negative is survivable by design (the Controllers screen offers the grant outright),
* so this matches only the identities we know rather than reaching for every Valve device a
* Steam Deck's own controller and a classic Steam Controller are not SC2s and must not
* conjure a Bluetooth prompt.
*/
fun sc2InputDevicePresent(): Boolean =
InputDevice.getDeviceIds().asSequence().mapNotNull { InputDevice.getDevice(it) }.any {
it.vendorId == VID_VALVE &&
(it.productId in PID_STEAMCONTROLLER2 || it.productId in PID_STEAMCONTROLLER2_PUCK)
}
/**
* The [GamepadPref] wire byte to send for the user's [setting] (the persisted gamepad index). A
* non-Auto setting is passed through unchanged; "Automatic" ([PREF_AUTO]) resolves to a concrete
@@ -230,6 +253,46 @@ object Gamepad {
else -> 0
}
/**
* The BTN_* bit for one key event from a SOURCE_GAMEPAD device [buttonBit] plus the
* Select-family button of every pad that carries no `BUTTON_SELECT` scancode at all.
*
* Plenty of controllers deliver that button as the plain `KEYCODE_BACK` a remote's Back uses,
* with no `BUTTON_SELECT` behind it: it is the Android-TV shape, where every input device is
* expected to offer Back, and a pad reaches it whether the vendor prints "Back" on the button
* (NVIDIA's SHIELD controller) or "Select"/"View" (most pads in an Android mode). Which one is
* on the couch cannot be told from here, and does not need to be the keycode is what routes.
*
* Read through [buttonBit] alone that button mapped to nothing, so it fell out of the
* streaming branch unconsumed and reached the activity's back stack, which is the
* deliberate-quit exit: ONE press of Select dropped the session and the host logged a client
* quit. `KEYCODE_BACK` is in fact the ONLY keycode that can get there from a pad a mapped
* button is consumed here, anything with a VK is consumed on the keycode path, volume/power go
* to the system, and a FLAG_FALLBACK BACK is swallowed which is what identifies this as the
* cause of such a report without knowing the hardware.
*
* It also meant such a pad could not produce [BTN_BACK] at all, so every shortcut built on
* Select the emergency exit chord this client's own start banner advertises, the mic mute,
* the stats tier was unreachable on exactly the devices whose users have no keyboard.
*
* A pad that DOES carry `BUTTON_SELECT` is unaffected in both directions: it never had the
* bug, and this changes nothing for it.
*
* FLAG_FALLBACK events are excluded: those are the synthetic BACK the framework raises after
* an unconsumed `BUTTON_*` press (a pad reporting L2/R2 as keys, say), not a button anyone
* touched, and forwarding one would put a phantom Select on the wire. `MainActivity` drops
* them on the keycode path for the same reason.
*
* Callers must gate on `SOURCE_GAMEPAD` before asking, exactly as [buttonBit]'s `KEYCODE_DPAD_*`
* rows require: a remote's or keyboard's BACK shares this keycode and has to keep leaving the
* stream for a device with no pad on it, Back IS the documented way out.
*/
fun padButtonBit(keyCode: Int, flags: Int): Int = when {
keyCode != KeyEvent.KEYCODE_BACK -> buttonBit(keyCode)
flags and KeyEvent.FLAG_FALLBACK != 0 -> 0
else -> BTN_BACK
}
/**
* Maps one controller's joystick MotionEvents to axis (+ HATdpad) sends on wire pad index [pad],
* **on change only**. Holds the previous axis/hat state so an unchanged frame emits nothing. One
@@ -51,7 +51,7 @@ class GamepadRouter(
* claimed by keeping a slot the Android input stack shares controllers unlike the USB
* capture links, which `StreamScreen` does not start at all while this is off.
*/
private val forwarding: Boolean = true,
forwarding: Boolean = true,
/**
* Forward raw guide/QAM presses (`Settings.systemButtons` resolved auto = forward on
* Android, where the press reaches the app on most devices; `local` exists for
@@ -70,6 +70,25 @@ class GamepadRouter(
private val guideGesture: Boolean = false,
) {
/** The ctor's forwarding preference, fixed for the session — one term of [forwarding]. */
private val forwardingSetting = forwarding
/**
* Whether this session's access includes the GAMEPAD grant ([SessionAccess.GAMEPAD])
* seeded from the Welcome and kept live by `StreamScreen`'s access poll (an `AccessUpdate`
* can revoke or restore it mid-session, latest-wins). Gates exactly what the forwarding
* preference gates: the wire sends, never the slots the exit/mic/stats chords must keep
* working on a Controller-less access level too (they are local controls that happen to be
* read off pad buttons). The host enforces regardless; this stops the client paying to send
* events that will be dropped. Volatile: the sensor and USB-capture threads read it per
* sample through [forwarding].
*/
@Volatile
var gamepadGranted: Boolean = true
/** Send on the wire at all — the forwarding preference AND the session's GAMEPAD grant. */
private val forwarding: Boolean get() = forwardingSetting && gamepadGranted
/** One forwarded controller: its stable wire pad index, per-device axis state, and held buttons. */
private class Slot(
val index: Int,
@@ -380,6 +399,13 @@ class GamepadRouter(
/** Whether ANY live slot currently holds wire pad [pad]. Read from the phone-gyro thread. */
fun padPresent(pad: Int): Boolean = slots.values.any { it.index == pad }
/**
* Whether wire sends are on at all the forwarding preference AND the session's GAMEPAD
* grant. For the writers that ride the pad planes from OUTSIDE this router (the phone-gyro
* mirror), which must stand down with it. Read from the sensor thread.
*/
fun sendsEnabled(): Boolean = forwarding
/**
* Whether wire pad [pad]'s motion already comes from the controller's OWN IMU either a
* capture-link slot ([ExternalPad] USB DualSense / SC2; synthetic ids are negative
@@ -99,6 +99,17 @@ object NativeBridge {
*/
external fun nativeEndReason(handle: Long): Int
/**
* The session's live access state as `[grants, remainingSecs, updateSeq]`, or `null` on a `0`
* handle. `grants` is a [SessionAccess] bitmask; `remainingSecs` counts down to the access
* expiry (`0` = permanent); `updateSeq` increments once per `AccessUpdate` the host sent
* (latest-wins the state IS the fold, this counter is how a poller tells a fresh T5 m /
* T1 m warning arrived and owes a toast). Seeded from the Welcome's access advert; an old
* host or an old native lib reads as full control, permanent, exactly what such a host
* enforces. Poll ~1 Hz alongside [nativeSessionEnded]. Cheap; safe on the UI thread.
*/
external fun nativeAccessState(handle: Long): IntArray?
/**
* Run the SPAKE2 PIN ceremony, presenting [certPem]/[keyPem]. Returns the host's verified
* fingerprint (64-hex) to persist + pin, or `""` on failure (wrong PIN / MITM / unreachable).
@@ -302,12 +313,17 @@ object NativeBridge {
external fun nativeSetVideoStatsEnabled(handle: Long, enabled: Boolean)
/**
* Start hostclient audio: Opus decode jitter ring AAudio (LowLatency), all in Rust.
* Start hostclient audio: Opus decode jitter ring AAudio, all in Rust.
* [lowLatencyMode] (the experimental toggle) additionally tags the stream usage=Game for the
* HAL's game-audio routing. No-op if already started. Best-effort a failure leaves video
* streaming.
*
* [isTv] steers the AAudio open ladder: a TV box starts at Shared rather than betting the
* audio plane on an Exclusive/MMAP path whose routing we cannot verify from inside the
* process. Passed from `FEATURE_LEANBACK` (same source as [nativeStartVideo]) because the
* native side's own `ro.build.characteristics` check is not answered by every TV device.
*/
external fun nativeStartAudio(handle: Long, lowLatencyMode: Boolean)
external fun nativeStartAudio(handle: Long, lowLatencyMode: Boolean, isTv: Boolean)
/** Stop + join the audio thread and close AAudio, without closing the session. No-op on `0`. */
external fun nativeStopAudio(handle: Long)
@@ -472,6 +488,16 @@ object NativeBridge {
// cross only when the host pastes (a "fetch:" event answered by nativeClipServeText). Host
// copies arrive as "offer:" events, fetched eagerly into the system clipboard.
/**
* The management-API port the host reported in this session's `Welcome` where its game
* library is served or 0 if it advertised none (older host, or no management API).
*
* Persist it on the host record: unlike the mDNS `mgmt` TXT, this arrives over the connection
* we have already authenticated, so it is what makes a host that moved off 47990 browsable
* over a VPN, a routed subnet, or when it was added by address.
*/
external fun nativeHostMgmtPort(handle: Long): Int
/** Whether the host advertised a working shared-clipboard service (HOST_CAP_CLIPBOARD). */
external fun nativeClipSupported(handle: Long): Boolean
@@ -1,5 +1,6 @@
package io.unom.punktfunk.kit
import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
@@ -9,6 +10,8 @@ import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import java.util.UUID
import java.util.concurrent.atomic.AtomicBoolean
@@ -47,16 +50,33 @@ class Sc2BleLink(
@Volatile private var state = State.IDLE
/** Bonded devices that look like a Steam Controller (name heuristic — BLE exposes no PID here). */
fun pairedControllers(): List<BluetoothDevice> = runCatching {
manager.adapter?.bondedDevices.orEmpty().filter { dev ->
val n = runCatching { dev.name }.getOrNull() ?: return@filter false
NAME_HINTS.any { n.contains(it, ignoreCase = true) }
/**
* Bonded devices that look like a Steam Controller (name heuristic BLE exposes no PID here).
*
* Gates on [permissionGranted] itself rather than trusting callers to: without the permission
* `bondedDevices` throws, and the `runCatching` below turns that into an empty list
* indistinguishable from "no controller is paired". A capture that never engaged for want of a
* permission nobody had asked for is exactly the silence this logs its way out of.
*/
fun pairedControllers(): List<BluetoothDevice> {
if (!permissionGranted(context)) {
Log.i(TAG, "BLE controllers not enumerated: $CONNECT_PERMISSION not granted")
return emptyList()
}
}.getOrDefault(emptyList())
return runCatching {
manager.adapter?.bondedDevices.orEmpty().filter { dev ->
val n = runCatching { dev.name }.getOrNull() ?: return@filter false
NAME_HINTS.any { n.contains(it, ignoreCase = true) }
}
}.getOrDefault(emptyList())
}
/** Connect to the bonded controller at [address]. Reports start flowing once READY. */
fun start(address: String): Boolean {
if (!permissionGranted(context)) {
Log.i(TAG, "BLE capture not started: $CONNECT_PERMISSION not granted")
return false
}
val adapter = manager.adapter ?: return false
if (!adapter.isEnabled) return false
val device = runCatching { adapter.getRemoteDevice(address) }.getOrNull() ?: return false
@@ -222,20 +242,50 @@ class Sc2BleLink(
return s.substring(0, 8).toLongOrNull(16)
}
private companion object {
const val TAG = "Sc2BleLink"
companion object {
private const val TAG = "Sc2BleLink"
val VALVE_SERVICE: UUID = UUID.fromString("100f6c32-1735-4313-b402-38567131e5f3")
const val VALVE_UUID_TAIL = "-1735-4313-b402-38567131e5f3"
const val NOTIFY_LOW = 0x100f6c75L
const val NOTIFY_HIGH = 0x100f6c7aL
const val WRITE_LOW = 0x100f6cb5L
const val WRITE_HIGH = 0x100f6cbeL
val CCCD: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
private val VALVE_SERVICE: UUID = UUID.fromString("100f6c32-1735-4313-b402-38567131e5f3")
private const val VALVE_UUID_TAIL = "-1735-4313-b402-38567131e5f3"
private const val NOTIFY_LOW = 0x100f6c75L
private const val NOTIFY_HIGH = 0x100f6c7aL
private const val WRITE_LOW = 0x100f6cb5L
private const val WRITE_HIGH = 0x100f6cbeL
private val CCCD: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
val NAME_HINTS = listOf("Steam Ctrl", "Steam Controller", "SteamController", "Valve")
private val NAME_HINTS =
listOf("Steam Ctrl", "Steam Controller", "SteamController", "Valve")
/** Enough for a state payload (45 B) + ATT header with margin. */
const val DESIRED_MTU = 100
private const val DESIRED_MTU = 100
/**
* The runtime permission this transport needs, or null where the platform grants Bluetooth
* at install time.
*
* From API 31 both operations a capture makes reading the bonded list and `connectGatt`
* sit behind the runtime `BLUETOOTH_CONNECT`. Below it the manifest's legacy `BLUETOOTH`
* (normal-level, granted on install) covers exactly those two, and `BLUETOOTH_CONNECT` is
* not a permission that platform version knows: `checkSelfPermission` answers DENIED for
* it and a request is refused without a dialog. Gating on it unconditionally is therefore
* not merely redundant on old releases it is a permanent refusal, which is what this
* null arm exists to avoid.
*/
val CONNECT_PERMISSION: String? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
Manifest.permission.BLUETOOTH_CONNECT
} else {
null
}
/**
* Whether a BLE capture may run: [CONNECT_PERMISSION] held, or not required on this
* release. Callers that can offer the user a grant ask this first, so the offer appears
* only when it would change something.
*/
fun permissionGranted(context: Context): Boolean {
val permission = CONNECT_PERMISSION ?: return true
return context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
}
}
}
@@ -0,0 +1,60 @@
package io.unom.punktfunk.kit
/**
* Per-client access grants the Kotlin mirror of `punktfunk_core::quic::access` (bit-for-bit;
* `design/per-client-access.md` §3), read per session via [NativeBridge.nativeAccessState].
*
* The host is the only enforcer: everything gated on these bits client-side is courtesy UX over
* the same vocabulary don't capture what can't land (a keyboard that silently does nothing is
* the failure mode this exists to prevent), and say what this session is (the stream's Access
* chip). The user-facing word is **"Access"**; the preset labels are *derived* from the mask,
* never stored, so they can't drift from what the host actually granted.
*/
object SessionAccess {
/** Controller input — gamepad events, rich pad input, pad audio, rumble return. */
const val GAMEPAD = 1 shl 0
/** Pointing input — mouse rel/abs + buttons, scroll, touch, and the pen plane. */
const val POINTER = 1 shl 1
/** Key input — key down/up and IME-committed text. */
const val KEYBOARD = 1 shl 2
/** Shared clipboard (ANDed into the host's clipboard policy). */
const val CLIPBOARD = 1 shl 3
/** Mic injection — the uplink plane + the per-session mic attach. */
const val MIC = 1 shl 4
/** Library launch (`Hello.launch`). */
const val LAUNCH = 1 shl 5
/** Every defined grant — full control, and what an old host's Welcome decodes to. */
const val ALL = GAMEPAD or POINTER or KEYBOARD or CLIPBOARD or MIC or LAUNCH
/**
* The preset name a mask displays as §3.2's rule: three levels people actually reason
* about, "Custom" for any other combination, never a raw bit list.
*/
fun label(grants: Int): String = when (grants and ALL) {
ALL -> "Full control"
GAMEPAD -> "Controller only"
0 -> "View only"
else -> "Custom"
}
/**
* Compact time-left wording for the Access chip ("1 h 58 m", "12 m", "45 s") hours and
* minutes once the span has them, bare seconds only under a minute (the final countdown).
*/
fun remainingLabel(secs: Int): String {
val h = secs / 3600
val m = (secs % 3600) / 60
return when {
h > 0 && m > 0 -> "$h h $m m"
h > 0 -> "$h h"
m > 0 -> "$m m"
else -> "${secs.coerceAtLeast(0)} s"
}
}
}
@@ -19,13 +19,16 @@ data class DiscoveredHost(
val pairingRequired: Boolean = false,
val mac: List<String> = emptyList(), // TXT "mac" (wake-capable NIC MAC(s), for Wake-on-LAN)
val os: String = "", // TXT "os" (OS-identity chain, e.g. "linux/fedora/bazzite"); "" on older hosts
// TXT "mgmt" — the management-API port the library is served on, distinct from `port` (the
// native QUIC plane). null on an older host / older native lib, meaning "assume 47990".
val mgmtPort: Int? = null,
)
/** Field separator the native browse uses inside one record (ASCII Unit Separator). */
private const val FIELD_SEP = '\u001F'
/**
* Parse one record from [NativeBridge.nativeDiscoveryPoll] (`keynameaddrportfppairmacos`),
* Parse one record from [NativeBridge.nativeDiscoveryPoll] (`keynameaddrportfppairmacosmgmt`),
* or null if it's malformed. Fields past the 6th are optional an older native lib omits them
* (`mac` 7th, `os` 8th). Pure unit-tested without Android (see ParseRecordTest). The native side
* already applied the protocol gate and address selection, so this is just field marshaling.
@@ -46,6 +49,9 @@ fun parseHostRecord(record: String): DiscoveredHost? {
mac = if (f.size > 6) f[6].split(",").map { it.trim() }.filter { it.isNotEmpty() }
else emptyList(),
os = if (f.size > 7) sanitizeOsChain(f[7]) else "",
// 9th field, absent on an older native lib. `0` (and anything out of range) means "not
// advertised" → null, and the caller falls back to 47990.
mgmtPort = if (f.size > 8) f[8].toIntOrNull()?.takeIf { it in 1..65535 } else null,
)
}
@@ -32,6 +32,16 @@ data class KnownHost(
* first learned (or forever, against an older host).
*/
val os: String = "",
/**
* The host's management-API port (mDNS `mgmt` TXT), where the game library is served NOT
* [port], which is the native QUIC plane. Learned while online and kept for the same reason as
* [mac] and [os], except this one is load-bearing: a host that moved its mgmt port off 47990
* (the supported way to share a machine with a Sunshine fork, whose web UI owns that port)
* served its library only while mDNS was reachable, because the advert was the sole place the
* real port ever existed. `null` until learned resolve with [effectiveMgmtPort].
* Mirrors the Apple client's `StoredHost.mgmtPort` and the Rust `KnownHost.mgmt_port`.
*/
val mgmtPort: Int? = null,
/** Stable record identity — see the class doc. Minted here for a genuinely new record. */
val id: String = newRecordId(),
/**
@@ -54,7 +64,16 @@ data class KnownHost(
* that no longer exist are dropped when the cards are rendered.
*/
val pinnedProfileIds: List<String> = emptyList(),
)
) {
/**
* Where this host's management API actually is: the port learned from its advert, else 47990.
* The twin of the Apple client's `StoredHost.effectiveMgmtPort` and the Rust
* `KnownHost::effective_mgmt_port`. Resolve through this the constant is the FALLBACK, not
* the answer.
*/
val effectiveMgmtPort: Int
get() = mgmtPort ?: io.unom.punktfunk.kit.library.DEFAULT_MGMT_PORT
}
/**
* Persists trusted hosts the pinned-fingerprint store *and* the saved-hosts list keyed by
@@ -130,6 +149,17 @@ class KnownHostStore(context: Context) {
save(h.copy(os = os))
}
/**
* Learn/refresh a saved host's management-API port from its live advert same contract as
* [learnMac]. This is the one that keeps a moved mgmt port working once mDNS isn't reachable.
*/
fun learnMgmtPort(address: String, port: Int, mgmtPort: Int) {
if (mgmtPort <= 0) return
val h = get(address, port) ?: return
if (h.mgmtPort == mgmtPort) return
save(h.copy(mgmtPort = mgmtPort))
}
/** Forget [host] (the next connect re-pairs / re-TOFUs). */
fun remove(host: KnownHost) {
prefs.edit().remove(host.id).apply()
@@ -180,6 +210,10 @@ class KnownHostStore(context: Context) {
paired = j.optBoolean("paired", false),
mac = j.optString("mac", "").split(",").map { it.trim() }.filter { it.isNotEmpty() },
os = j.optString("os", ""),
// 0 (or absent) = never learned. `optInt` cannot express "missing", hence the sentinel
// rather than a bare default — a record written before this field existed must decode
// to null and fall back to 47990, not to port 0.
mgmtPort = j.optInt("mgmt", 0).takeIf { it > 0 },
// A record without an id can only be one this build wrote before the migration ran, or
// a hand-edited file; minting here keeps the parse total rather than dropping a host.
id = j.optString("id", "").ifEmpty { newRecordId() },
@@ -266,6 +300,7 @@ class KnownHostStore(context: Context) {
.put("paired", host.paired)
.put("mac", host.mac.joinToString(","))
.put("os", host.os)
.put("mgmt", host.mgmtPort ?: 0)
.put("clip", host.clipboardSync)
.put("profile", host.profileId ?: "")
.put("pins", JSONArray(host.pinnedProfileIds))
@@ -0,0 +1,80 @@
package io.unom.punktfunk.kit
import android.view.KeyEvent
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Pure JVM test of [Gamepad.padButtonBit] the streaming branch's gamepad keycode resolution
* (`KeyEvent`'s keycode/flag constants are compile-time-inlined ints, so no Android runtime is
* involved). Run: `./gradlew :kit:testDebugUnitTest`.
*
* The regression it pins is a field report: one press of Select disconnected the session. Plenty
* of pads deliver that button as the plain `KEYCODE_BACK` a remote uses, with no `BUTTON_SELECT`
* scancode behind it so it mapped to nothing, fell out of the gamepad branch unconsumed, and
* reached the activity back stack, which is the deliberate-quit exit. The same gap made
* [Gamepad.BTN_BACK] unreachable on those pads, and with it every shortcut built on Select: the
* exit chord `StreamScreen`'s own start banner advertises, the mic mute, the stats tier.
*
* Which controller the report came from is not knowable from the logs and does not matter:
* `KEYCODE_BACK` is the only keycode that reaches the back stack from a SOURCE_GAMEPAD device, so
* a one-press quit identifies the button's keycode on its own.
*/
class PadButtonBitTest {
/** The report: Select on an Android-TV pad arrives as BACK and must be the Select bit. */
@Test
fun `a pad's BACK is its Select button`() {
assertEquals(Gamepad.BTN_BACK, Gamepad.padButtonBit(KeyEvent.KEYCODE_BACK, 0))
// Same bit either spelling reaches us by — a pad that DOES carry BUTTON_SELECT is unchanged.
assertEquals(
Gamepad.padButtonBit(KeyEvent.KEYCODE_BUTTON_SELECT, 0),
Gamepad.padButtonBit(KeyEvent.KEYCODE_BACK, 0),
)
}
/**
* With Select mapped, the three Select chords are reachable on a pad that has only a BACK
* keycode which is the whole point of the mapping, not a side effect of it. Held-state
* assembly is [GamepadRouter]'s (see `GamepadChordTest`); what is pinned here is that the
* bits a SHIELD can actually produce cover each chord.
*/
@Test
fun `the Select chords are reachable from a BACK-only pad`() {
val select = Gamepad.padButtonBit(KeyEvent.KEYCODE_BACK, 0)
val start = Gamepad.padButtonBit(KeyEvent.KEYCODE_BUTTON_START, 0)
val l1 = Gamepad.padButtonBit(KeyEvent.KEYCODE_BUTTON_L1, 0)
val r1 = Gamepad.padButtonBit(KeyEvent.KEYCODE_BUTTON_R1, 0)
val x = Gamepad.padButtonBit(KeyEvent.KEYCODE_BUTTON_X, 0)
val y = Gamepad.padButtonBit(KeyEvent.KEYCODE_BUTTON_Y, 0)
assertEquals(GamepadRouter.EXIT_CHORD, select or start or l1 or r1)
assertEquals(GamepadRouter.STATS_CHORD, select or x)
assertEquals(GamepadRouter.MIC_CHORD, select or y)
}
/**
* The synthetic BACK the framework raises after an unconsumed `BUTTON_*` press is not a button
* anyone touched forwarding it would put a phantom Select on the wire, and one of those
* landing while Start + L1 + R1 were held would complete the exit chord out of nowhere.
*/
@Test
fun `a fallback BACK is not a button press`() {
assertEquals(0, Gamepad.padButtonBit(KeyEvent.KEYCODE_BACK, KeyEvent.FLAG_FALLBACK))
// Only BACK is filtered on the flag; a real button keeps its bit whatever rides alongside.
assertEquals(
Gamepad.BTN_A,
Gamepad.padButtonBit(KeyEvent.KEYCODE_BUTTON_A, KeyEvent.FLAG_FALLBACK),
)
}
/** Everything else is [Gamepad.buttonBit] verbatim — BACK is the only row this adds. */
@Test
fun `every other keycode is unchanged`() {
for (code in 0..0x400) {
if (code == KeyEvent.KEYCODE_BACK) continue
assertEquals(Gamepad.buttonBit(code), Gamepad.padButtonBit(code, 0))
}
// And BACK is genuinely a new row, not one buttonBit already had.
assertEquals(0, Gamepad.buttonBit(KeyEvent.KEYCODE_BACK))
}
}
@@ -0,0 +1,48 @@
package io.unom.punktfunk.kit
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Pure JVM test of [SessionAccess] the bit values are an ABI contract with
* `punktfunk_core::quic::access` (wire == store == this mirror), and the preset labels are the
* §3.2 naming rule the Access chip renders from: three levels people reason about, "Custom" for
* anything else, derived from the mask so they cannot drift. Run: `./gradlew :kit:testDebugUnitTest`.
*/
class SessionAccessTest {
/** Bit-for-bit the core vocabulary — a reorder here would mislabel every session. */
@Test
fun `bits mirror punktfunk-core`() {
assertEquals(1, SessionAccess.GAMEPAD)
assertEquals(2, SessionAccess.POINTER)
assertEquals(4, SessionAccess.KEYBOARD)
assertEquals(8, SessionAccess.CLIPBOARD)
assertEquals(16, SessionAccess.MIC)
assertEquals(32, SessionAccess.LAUNCH)
assertEquals(0x3F, SessionAccess.ALL)
}
@Test
fun `preset labels derive from the mask`() {
assertEquals("Full control", SessionAccess.label(SessionAccess.ALL))
assertEquals("Controller only", SessionAccess.label(SessionAccess.GAMEPAD))
assertEquals("View only", SessionAccess.label(0))
// Any other combination is Custom — including controller + clipboard, the design's
// media-remote example.
assertEquals(
"Custom",
SessionAccess.label(SessionAccess.GAMEPAD or SessionAccess.CLIPBOARD),
)
assertEquals("Custom", SessionAccess.label(SessionAccess.ALL and SessionAccess.LAUNCH.inv()))
}
@Test
fun `remaining label is compact and never empty`() {
assertEquals("1 h 58 m", SessionAccess.remainingLabel(7130))
assertEquals("2 h", SessionAccess.remainingLabel(7200))
assertEquals("12 m", SessionAccess.remainingLabel(725))
assertEquals("45 s", SessionAccess.remainingLabel(45))
assertEquals("0 s", SessionAccess.remainingLabel(0))
}
}
@@ -47,6 +47,31 @@ class ParseRecordTest {
rec("k", "n", "10.0.0.5", "9777", "", "optional", "", "linux/fedora/bazzite"),
)!!
assertEquals("linux/fedora/bazzite", h.os)
// A record from a native lib predating the 9th field: no mgmt port, so the caller falls
// back to 47990. Absent must read as "unknown", never as port 0.
assertNull(h.mgmtPort)
}
@Test
fun ninthFieldCarriesTheMgmtPort() {
// 47991, not the 47990 default — a host that MOVED its mgmt port is the whole reason this
// field is on the wire, and a test pinned to the default would pass against a hardcode.
val h = parseHostRecord(
rec("k", "n", "10.0.0.5", "9777", "", "optional", "", "linux/arch", "47991"),
)!!
assertEquals(47991, h.mgmtPort)
}
@Test
fun mgmtPortOutOfRangeOrUnparsableReadsAsUnknown() {
// Unauthenticated advert data: 0 (the "not advertised" sentinel the Rust side emits),
// a non-number, and an out-of-range value must all mean "assume the default" rather than
// produce a port the client would then fail to connect to.
val base = arrayOf("k", "n", "10.0.0.5", "9777", "", "optional", "", "linux/arch")
assertNull(parseHostRecord(rec(*base, "0"))!!.mgmtPort)
assertNull(parseHostRecord(rec(*base, "not-a-port"))!!.mgmtPort)
assertNull(parseHostRecord(rec(*base, "70000"))!!.mgmtPort)
assertNull(parseHostRecord(rec(*base, ""))!!.mgmtPort)
}
@Test
File diff suppressed because it is too large Load Diff
@@ -37,6 +37,7 @@ struct OutputReady {
index: usize,
pts_us: u64,
decoded_ns: i128,
decoded_mono_ns: i64,
}
/// Events the async decode loop reacts to. The codec's async-notify callbacks (which run on its
@@ -51,11 +52,12 @@ enum DecodeEvent {
Au(Frame, u32),
/// An input buffer slot freed (index) — we can queue an AU into it.
InputAvailable(usize),
/// A decoded frame is ready (buffer index + echoed pts + the callback-time `decoded` stamp).
/// A decoded frame is ready (buffer index + echoed pts + the callback-time `decoded` stamps).
OutputAvailable {
index: usize,
pts_us: u64,
decoded_ns: i128,
decoded_mono_ns: i64,
},
/// The output format changed — re-check the stream's colour signalling (HDR DataSpace).
FormatChanged,
@@ -126,6 +128,12 @@ pub(super) fn run_async(
// decode stage ends when the frame actually became available — not after the
// channel hop + whatever work the loop coalesces in front of presenting it.
decoded_ns: now_realtime_ns(),
// Its monotonic twin, from the same instant. The stats are REALTIME (they
// fold the host's clock offset in), while the cadence loop and
// `releaseOutputBufferAtTime` are both CLOCK_MONOTONIC — and the loop is fed
// and read in one domain, never converted (`punktfunk_core::phase`: a
// constant offset between domains is what its offset estimator absorbs).
decoded_mono_ns: now_monotonic_ns(),
});
})),
on_format_changed: Some(Box::new(move |_fmt| {
@@ -232,7 +240,7 @@ pub(super) fn run_async(
PresentPriority::Smooth { buffer } => format!("smoothness, buffer {buffer}"),
}
);
Some(Presenter::new(priority))
Some(Presenter::new(priority, mode.refresh_hz))
};
stats.set_presenter_active(presenter.is_some());
// The vsync clock, started LAZILY on the first decoded frame (see `vsync.rs`); its ticks ride
@@ -299,6 +307,7 @@ pub(super) fn run_async(
// codec error; `recovery_flags` carries each AU's user_flags from `dispatch_event` (feed) to
// `present_ready` (present), keyed by the codec-echoed pts.
let mut gate = ReanchorGate::new(client.frames_dropped());
let mut last_arms = gate.arms();
let mut recovery_flags: VecDeque<(u64, u32)> = VecDeque::new();
let mut last_kf_req: Option<Instant> = None;
// Productive (dispatch+feed+present) time between displayed frames; reported to ADPF once one is
@@ -378,6 +387,19 @@ pub(super) fn run_async(
&mut queued_stamps,
&mut gate,
);
// The cadence loop's re-anchor seam. A fresh arm means a loss was detected: the gate is
// about to freeze on the last good picture and the frames that reach the presenter on the
// far side come through a decoder that has just recovered, so the source→presentable delay
// the loop had measured is not the one it will see. Watched by ARM COUNT rather than at
// each `gate.arm` site because those are spread across the dispatcher, the feeder and this
// loop's own backstops, and the count catches every one of them — including the ones
// `feed_ready` raises for an abandoned partial AU.
if gate.arms() != last_arms {
last_arms = gate.arms();
if let Some(p) = presenter.as_mut() {
p.reset_cadence();
}
}
let had_output = !ready.is_empty();
let rendered_before = rendered;
present_ready(
@@ -414,7 +436,7 @@ pub(super) fn run_async(
if let (Some(_), Some(c)) = (p.flush_log(&meter, clock), clock) {
let period = c.panel_period_ns().max(c.period_ns());
if period > 0 {
if let Some(t) = c.next_target(now_monotonic_ns(), 0) {
if let Some(t) = c.next_target(now_monotonic_ns()) {
let mono_now = now_monotonic_ns();
let real_now = now_realtime_ns();
let leads_us: Vec<u64> = arrival_stamps
@@ -737,10 +759,12 @@ fn dispatch_event(
index,
pts_us,
decoded_ns,
decoded_mono_ns,
} => ready.push(OutputReady {
index,
pts_us,
decoded_ns,
decoded_mono_ns,
}),
DecodeEvent::FormatChanged => *fmt_dirty = true,
DecodeEvent::Vsync => *vsync_tick = true,
@@ -998,7 +1022,7 @@ fn present_ready(
for o in ready.drain(..) {
let flags = take_flags(recovery_flags, o.pts_us);
if gate.on_decoded(flags, false, now) == GateVerdict::Present {
let dropped = p.submit(codec, o.index, o.pts_us, o.decoded_ns);
let dropped = p.submit(codec, o.index, o.pts_us, o.decoded_ns, o.decoded_mono_ns);
skipped += dropped;
*discarded += dropped;
} else {
+269 -31
View File
@@ -14,6 +14,12 @@
//! frame timeline (API 33+, via [`super::vsync`]), so the latch phase is deterministic instead
//! of inheriting network + decode jitter. On the 31/32 fallback the release is ASAP —
//! identical to the legacy path — and only the budget prediction uses the measured period.
//! * under the SMOOTHNESS intent only, a **due time** per frame from
//! [`punktfunk_core::phase::CadenceClock`]: the release targets the first timeline at or after
//! the instant the SOURCE's own timestamp says the frame is due, rather than the first one
//! after it happened to decode, so the host's cadence reaches glass instead of the network's
//! (`design/presenter-cadence-rework.md` D1). The latency intent holds no clock at all and
//! stays arrival-driven by construction.
//!
//! The legacy behaviour (release the newest ready buffer immediately, unbudgeted) remains
//! selectable at runtime: `adb shell setprop debug.punktfunk.presenter arrival` — the on-device
@@ -21,6 +27,7 @@
//! (off = the synchronous pre-overhaul loop, no presenter at all).
use ndk::media::media_codec::MediaCodec;
use punktfunk_core::phase::{CadenceClock, CadenceHealth, CadenceTuning};
use std::collections::VecDeque;
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
use std::sync::Mutex;
@@ -103,8 +110,9 @@ const FALLBACK_PERIOD_NS: i64 = 8_333_333;
pub(crate) enum PresentPriority {
/// Newest-wins, release the instant the budget opens. The default.
Latency,
/// A small FIFO (1..=3 frames) drained one per vsync: jitter absorbed at one refresh of
/// added display latency per slot, which the metrics show rather than hide.
/// A small holding store (1..=3 frames) drained on each frame's DUE time from the source's own
/// timestamps: transport jitter absorbed in the cushion the cadence loop sizes from its own
/// measured residual, at a latency the metrics show rather than hide.
Smooth { buffer: usize },
}
@@ -129,6 +137,9 @@ struct HeldFrame {
pts_us: u64,
/// The output callback's `CLOCK_REALTIME` stamp — the pace metric's start (decoded→release).
decoded_ns: i128,
/// When the source says this frame is due, monotonic — [`CadenceClock::due_ns`] folded at
/// submit. `None` under the latency intent, which has no clock.
due_ns: Option<i64>,
}
/// The one-in-flight glass budget.
@@ -287,20 +298,33 @@ fn p50_max_ms(mut v: Vec<u64>) -> (f64, f64) {
}
pub(super) struct Presenter {
/// 0 = newest-wins; 1..=3 = smoothing FIFO capacity.
/// 0 = newest-wins; 1..=3 = the holding store's capacity, a bound against a burst rather than
/// a pacing depth (design §4.3).
fifo_capacity: usize,
frames: VecDeque<HeldFrame>,
/// FIFO preroll: `take` withholds until the buffer filled to capacity once, re-armed on a dry
/// run — the Apple `FrameStore` semantics (headroom never builds without it).
prerolled: bool,
/// The source-cadence loop, and the ONLY thing that makes this presenter anything but
/// arrival-driven. `None` under the latency intent — not "a clock we choose to ignore" but no
/// clock at all, so that path cannot drift into cadence targeting by accident (`design/
/// presenter-cadence-rework.md` §5; the test below is what holds it there).
cadence: Option<CadenceClock>,
/// The source's nominal frame interval — [`CadenceClock`]'s cushion ceiling, which is a design
/// invariant rather than a tunable (a cushion past one whole frame buys smoothness the source
/// cannot supply). From the negotiated stream rate, which cannot change without tearing this
/// loop down.
frame_interval_ns: i64,
inflight: Option<InFlight>,
/// A vsync arrived since the last release — the FIFO's one-per-refresh drain pace.
/// A vsync arrived since the last release — the retry beat for a parked frame, and what the
/// empty-store readout below is counted against.
vsync_tick: bool,
// -- 1 Hz pf-present window, always on --
released: u64,
paced_drops: u64,
no_budget: u64,
forced: u64,
/// Vsync ticks that found the store empty. Under cadence targeting an empty store is the
/// ordinary state between a frame's decode and its due time, so this reads as supply depth
/// rather than as the underflow alarm it was under the retired per-slot drain — the alarm is
/// the loop's own late count.
dry: u64,
/// Pump passes that held a frame back because too many earlier releases were still
/// unconfirmed ([`UNDISPLAYED_CAP`]) — reads 0 on a healthy device, and a climbing value is
@@ -321,7 +345,12 @@ pub(super) struct Presenter {
}
impl Presenter {
pub(super) fn new(priority: PresentPriority) -> Presenter {
/// `source_hz` is the NEGOTIATED STREAM rate, not the panel's — it sizes the cadence loop's
/// cushion ceiling, and the quantity that must not be exceeded is one source frame. 0 (an
/// unnegotiated rate) falls back to [`FALLBACK_PERIOD_NS`], the tighter of the two plausible
/// answers: a ceiling set too low costs smoothness, one set too high costs latency the user
/// never asked for.
pub(super) fn new(priority: PresentPriority, source_hz: u32) -> Presenter {
let pinned = latch_margin_ns();
let (margin_ns, margin_pinned) = match pinned {
Some(ns) => (ns, true),
@@ -336,13 +365,27 @@ impl Presenter {
" (adaptive — widens on latch misses)"
}
);
let frame_interval_ns = match source_hz {
0 => FALLBACK_PERIOD_NS,
hz => 1_000_000_000 / i64::from(hz),
};
// `snapping()`, because every release here goes through the frame-timeline grid: the
// snap-up carries roughly half a refresh of implicit slack, so the cushion is small.
let cadence = matches!(priority, PresentPriority::Smooth { .. }).then(|| {
log::info!(
"presenter: cadence clock on the source's timeline, frame interval {:.2}ms",
frame_interval_ns as f64 / 1e6
);
CadenceClock::new(CadenceTuning::snapping())
});
Presenter {
fifo_capacity: match priority {
PresentPriority::Latency => 0,
PresentPriority::Smooth { buffer } => buffer,
},
frames: VecDeque::new(),
prerolled: false,
cadence,
frame_interval_ns,
inflight: None,
vsync_tick: false,
released: 0,
@@ -359,8 +402,9 @@ impl Presenter {
}
}
/// A vsync pulse from the clock thread's event — the retry tick for a parked frame and the
/// FIFO's drain pace.
/// A vsync pulse from the clock thread's event — the retry tick for a frame parked on a closed
/// budget, and the beat the empty-store readout is counted against. No longer a drain pace:
/// under cadence targeting the due time is what releases a frame.
pub(super) fn on_vsync(&mut self) {
self.vsync_tick = true;
}
@@ -368,13 +412,21 @@ impl Presenter {
/// Accept one decoded, gate-approved output buffer. Newest-wins evicts everything older
/// (released unrendered — the explicit, counted drop); the FIFO evicts its oldest past
/// capacity. Returns how many frames were dropped by the policy (the HUD's `skipped`).
///
/// `decoded_mono_ns` is the monotonic twin of `decoded_ns`, stamped at the same instant on the
/// codec's callback thread: the cadence loop and `releaseOutputBufferAtTime` both live in
/// `CLOCK_MONOTONIC`, while the latency stats live in `CLOCK_REALTIME`, and the loop is fed and
/// read in ONE domain (`phase.rs`: a constant offset between domains is absorbed by the offset
/// estimator, so no conversion belongs anywhere in this path).
pub(super) fn submit(
&mut self,
codec: &MediaCodec,
index: usize,
pts_us: u64,
decoded_ns: i128,
decoded_mono_ns: i64,
) -> u64 {
let due_ns = self.due_at(pts_us, decoded_mono_ns);
let mut dropped = 0u64;
if self.fifo_capacity == 0 {
while let Some(stale) = self.frames.pop_front() {
@@ -386,6 +438,7 @@ impl Presenter {
index,
pts_us,
decoded_ns,
due_ns,
});
if self.fifo_capacity > 0 && self.frames.len() > self.fifo_capacity {
if let Some(stale) = self.frames.pop_front() {
@@ -397,6 +450,61 @@ impl Presenter {
dropped
}
/// When the source says this frame is due, or `None` under the latency intent.
///
/// `pts_us` IS the host's own stamp — it round-trips through the codec's presentation time, so
/// the source timeline needs no plumbing of its own. The µs the codec API quantises it to
/// costs ±0.5 µs of white noise on an 8.3 ms period, and that passes through to the due time
/// like any other variation in the source's cadence: the loop smooths the offset, never the
/// timestamps.
fn due_at(&mut self, pts_us: u64, decoded_mono_ns: i64) -> Option<i64> {
let interval_ns = self.frame_interval_ns;
self.cadence
.as_mut()
.map(|c| c.due_ns(pts_us.saturating_mul(1_000), decoded_mono_ns, interval_ns))
}
/// The earliest present a release may target: SurfaceFlinger's latch lead ahead of now, since
/// a present it cannot latch in time is not a target, and never before the frame's own due
/// time. That second half is the whole of the cadence change — `next_target(max(now, due))`
/// where it used to be `next_target(now)` (design §4.2).
fn not_before_ns(&self, now_mono_ns: i64, due_ns: Option<i64>) -> i64 {
let submit_floor_ns = now_mono_ns + self.margin_ns;
due_ns.map_or(submit_floor_ns, |due| due.max(submit_floor_ns))
}
/// Whether the frame at the head of the smoothing store may leave it yet.
///
/// The store's job under cadence targeting is to HOLD WHAT IS NOT DUE YET (design §4.3): the
/// due time paces, so capacity is a bound against a burst rather than the clock. A frame is
/// releasable once the grid point it aims at is the next one this pump could still submit for
/// — one `grid_period_ns` of reach past the submit margin. Sooner buys nothing, because the
/// release is timed either way and holding keeps the store's own eviction policy live over the
/// frame; later risks the loop's 5 ms housekeeping wake landing inside the submit lead, which
/// costs the frame a whole refresh.
fn head_is_releasable(&self, now_mono_ns: i64, grid_period_ns: i64) -> bool {
let reach_ns = now_mono_ns + self.margin_ns + grid_period_ns;
self.frames
.front()
.is_some_and(|f| f.due_ns.is_none_or(|due| due <= reach_ns))
}
/// Force the cadence loop to re-anchor on the next frame — the discontinuity hook the clock
/// asks its callers for. The Android seam is the re-anchor gate arming: a loss freezes the
/// gate, the decoder recovers behind it, and what reaches this store on the far side comes
/// through a pipeline whose delay is no longer the one the loop measured. Source-timestamp
/// regressions and half-second gaps the loop catches by itself. No-op under latency.
pub(super) fn reset_cadence(&mut self) {
if let Some(c) = self.cadence.as_mut() {
c.reset();
}
}
/// The cadence loop's health for the 1 Hz line, or `None` when there is no loop to read.
fn cadence_health(&self) -> Option<CadenceHealth> {
self.cadence.as_ref().map(CadenceClock::health)
}
/// The present decision point — run on every loop pass (frame arrivals, vsync ticks, and the
/// 5 ms housekeeping wake all land here). Releases AT MOST one frame (the budget). Returns
/// `true` when a frame was released to glass this call.
@@ -423,28 +531,28 @@ impl Presenter {
// pass — frame waiting or not — so its forgiveness timer measures real elapsed time
// rather than how often a frame happened to be ready.
let backlogged = self.unconfirmed_backlog(meter, now_mono_ns);
// The grid a release snaps to, for the store's due-time reach below: the panel's own
// period where it is known — the app's choreographer stream can be down-rated below it
// (see `VsyncShared::next_target`) — and the measured callback period otherwise.
let grid_period_ns = clock
.map(|c| c.panel_period_ns().max(c.period_ns()))
.filter(|&p| p > 0)
.unwrap_or(FALLBACK_PERIOD_NS);
// Pick the frame this pump may release.
let frame = if self.fifo_capacity == 0 {
self.frames.pop_back() // submit() kept it a single slot; back == the newest
} else {
// FIFO: drain exactly one frame per vsync tick, after preroll; a drain tick that
// finds the buffer dry re-arms preroll (the Apple `FrameStore` underflow semantics —
// the previous frame persists on glass, a repeat by omission, while headroom
// rebuilds). Everything is gated on the tick so an idle stream neither counts
// underflows nor churns the preroll flag 200×/s.
if !self.vsync_tick {
return false;
}
if !self.prerolled {
if self.frames.len() < self.fifo_capacity {
return false;
// The smoothing store releases on the DUE time, so there is no drain tick to gate on
// and no preroll beneath it. One-frame-per-slot was itself the defect: at 60 fps on a
// 120 Hz panel it drains at twice supply, the store empties, preroll re-arms, and the
// intervals become 1,3,1,3 where 2,2,2 is the correct answer — the smoothing mode
// juddering by construction at exactly the rate mismatch it exists to smooth (design
// §4.3, D3). Due times one source period apart snap to every second vblank instead.
if !self.head_is_releasable(now_mono_ns, grid_period_ns) {
if self.vsync_tick && self.frames.is_empty() {
self.dry += 1;
}
self.prerolled = true;
}
if self.frames.is_empty() {
self.prerolled = false;
self.dry += 1;
self.vsync_tick = false; // this tick's drain ran (and found nothing)
self.vsync_tick = false; // this tick's evaluation ran (and released nothing)
return false;
}
self.frames.pop_front()
@@ -463,8 +571,11 @@ impl Presenter {
}
return false;
}
// Release: timeline-timed when the clock has one, ASAP otherwise.
let target = clock.and_then(|c| c.next_target(now_mono_ns, self.margin_ns));
// Release: timeline-timed when the clock has one, ASAP otherwise. Under cadence targeting
// the floor is the frame's due time rather than this instant — the source's grid rather
// than the network's.
let target =
clock.and_then(|c| c.next_target(self.not_before_ns(now_mono_ns, frame.due_ns)));
let released = match target {
Some(t) => codec
.release_output_buffer_at_time_by_index(frame.index, t.expected_present_ns)
@@ -549,6 +660,12 @@ impl Presenter {
/// codec-pure queued→decoded time) / `e2e` (capture→decoded, skew-corrected — the wireless
/// A/B headline) / `vsync` (the measured panel period).
///
/// Under the smoothness intent it carries the cadence loop's health as well — `late‰` of all
/// frames folded (a due time already past when the frame became presentable: the direct signal
/// the cushion is too small, and WP8's acceptance criterion), `jitter` (the loop residual's
/// mean absolute deviation, our first honest per-stream jitter number), `cushion` and
/// `reanchors`. Absent under latency, where there is no loop.
///
/// Returns this window's CIRCULAR latch statistics `(vector-mean latch ns mod panel period,
/// coherence ‰)` when a window actually flushed — the phase-lock reporter's v2 error signal
/// (design/phase-locked-capture.md §6; the v1 median was immovable under jitter).
@@ -577,6 +694,21 @@ impl Presenter {
let period_ms = clock.map(|c| c.period_ns() as f64 / 1e6).unwrap_or(0.0);
let panel_ns = clock.map(|c| c.panel_period_ns()).unwrap_or(0);
let (outstanding, _) = meter.outstanding();
// Cumulative over the session rather than this window (the loop's counters survive
// `reset`): `late` is a RATE question, and one second of it is too few frames to read a
// sub-percent criterion off.
let cadence = self
.cadence_health()
.map(|h| {
format!(
" late={}‰ jitterMs={:.2} cushionMs={:.2} reanchors={}",
h.late.saturating_mul(1000) / h.frames.max(1),
h.jitter_ns as f64 / 1e6,
h.cushion_ns as f64 / 1e6,
h.reanchors,
)
})
.unwrap_or_default();
log::info!(
target: "pf.present",
"released={} displays={} paced={} noBudget={} forced={} qDry={} \
@@ -584,7 +716,7 @@ impl Presenter {
paceMs p50={:.2} max={:.2} latchMs p50={:.2} max={:.2} \
feedMs p50={:.2} max={:.2} codecMs p50={:.2} max={:.2} \
e2eMs p50={:.2} max={:.2} circ={:.2}ms coh={} \
vsyncMs={:.2} panelMs={:.2}",
vsyncMs={:.2} panelMs={:.2}{}",
self.released,
displays,
self.paced_drops,
@@ -607,6 +739,7 @@ impl Presenter {
circ.map(|(_, c)| c).unwrap_or(0),
period_ms,
panel_ns as f64 / 1e6,
cadence,
);
self.released = 0;
// Margin adaptation, off the MEASURED latch. A release targets the first grid point past
@@ -671,3 +804,108 @@ pub(super) fn presenter_disabled_by_sysprop() -> bool {
};
n > 0 && &buf[..n as usize] == b"arrival"
}
#[cfg(test)]
mod tests {
use super::*;
const SOURCE_HZ: u32 = 120;
const P: i64 = 8_333_333; // one 120 Hz source interval, ns
/// A host stamping realtime, played out against a monotonic present clock — two different
/// eras, which is the whole reason the loop estimates the offset rather than being told it.
const PTS0_NS: i64 = 1_786_000_000_000_000_000;
const MONO0: i64 = 987_000_000_000;
const PTS0_US: u64 = (PTS0_NS / 1000) as u64;
/// `k`'s pts as the CODEC echoes it — µs, truncated exactly as `feed_ready` queues it — and
/// the monotonic instant it became presentable, with `jitter_ns` of transport noise on the
/// arrival and none on the source stamp.
fn frame(k: i64, jitter_ns: i64) -> (u64, i64) {
(((PTS0_NS + k * P) / 1000) as u64, MONO0 + k * P + jitter_ns)
}
#[test]
fn the_latency_intent_never_consults_the_cadence_clock() {
let mut p = Presenter::new(PresentPriority::Latency, SOURCE_HZ);
for k in 0..600 {
let (pts_us, ready) = frame(k, (k % 11) * 400_000);
assert_eq!(
p.due_at(pts_us, ready),
None,
"latency must produce no due time at all"
);
}
assert!(
p.cadence_health().is_none(),
"latency holds no loop to have health"
);
// …so the target floor is exactly what it was before the clock existed: this instant plus
// SurfaceFlinger's latch lead, and nothing else.
assert_eq!(p.not_before_ns(MONO0, None), MONO0 + p.margin_ns);
}
#[test]
fn the_smooth_intent_puts_every_decoded_frame_through_the_loop() {
let mut p = Presenter::new(PresentPriority::Smooth { buffer: 2 }, SOURCE_HZ);
for k in 0..600 {
let (pts_us, ready) = frame(k, (k % 11) * 400_000);
assert!(p.due_at(pts_us, ready).is_some());
}
let h = p.cadence_health().expect("smooth holds a loop");
assert_eq!(h.frames, 600);
assert_eq!(
h.reanchors, 1,
"only the cold start anchors on a clean trace"
);
}
#[test]
fn a_due_time_ahead_of_now_moves_the_target_and_one_behind_it_does_not() {
let p = Presenter::new(PresentPriority::Smooth { buffer: 2 }, SOURCE_HZ);
let floor = MONO0 + p.margin_ns;
// Due ahead: the release aims at the source's grid, which is the entire change.
assert_eq!(p.not_before_ns(MONO0, Some(floor + P)), floor + P);
// Due already past — a late frame, which the loop deliberately reports unclamped: present
// at the next opportunity, never drag the grid back to the frame.
assert_eq!(p.not_before_ns(MONO0, Some(floor - 5 * P)), floor);
}
#[test]
fn the_store_holds_a_frame_that_is_not_due_yet_and_releases_it_within_reach_of_its_slot() {
let mut p = Presenter::new(PresentPriority::Smooth { buffer: 2 }, SOURCE_HZ);
let due = MONO0 + 4 * P;
p.frames.push_back(HeldFrame {
index: 0,
pts_us: PTS0_US,
decoded_ns: 0,
due_ns: Some(due),
});
assert!(!p.head_is_releasable(MONO0, P), "four refreshes early");
assert!(
!p.head_is_releasable(due - P - p.margin_ns - 1, P),
"one nanosecond outside the submit reach of its own slot"
);
assert!(
p.head_is_releasable(due - P - p.margin_ns, P),
"exactly one panel period of reach, the earliest that still buys nothing to wait"
);
assert!(
p.head_is_releasable(due + 10 * P, P),
"late frames go at once"
);
}
#[test]
fn a_frame_carrying_no_due_time_is_always_releasable() {
// `HeldFrame` is shared with the latency intent, where the due time is always absent, so
// the predicate has to drain on a frame it cannot answer for rather than wedge behind it.
let mut p = Presenter::new(PresentPriority::Smooth { buffer: 2 }, SOURCE_HZ);
p.frames.push_back(HeldFrame {
index: 0,
pts_us: PTS0_US,
decoded_ns: 0,
due_ns: None,
});
assert!(p.head_is_releasable(MONO0, P));
}
}
+21 -14
View File
@@ -93,16 +93,24 @@ impl VsyncShared {
self.panel_period_ns.load(Ordering::Relaxed)
}
/// The release target for a frame submitted at `now`: the earliest stored timeline whose
/// EXPECTED PRESENT is still `margin` away, extrapolated forward by whole periods once the
/// stored set has aged out (timelines refresh once per vsync callback; a frame can decode
/// anywhere inside that window). `None` on the 31/32 fallback — the caller releases ASAP.
/// The release target for a frame that must not be presented before `not_before_ns`: the
/// earliest stored timeline whose EXPECTED PRESENT is past that instant, extrapolated forward
/// by whole periods once the stored set has aged out (timelines refresh once per vsync
/// callback; a frame can decode anywhere inside that window). `None` on the 31/32 fallback —
/// the caller releases ASAP.
///
/// The floor is the CALLER's to compose because two constraints meet in it and only the
/// caller knows the second: SurfaceFlinger's latch lead (`now + margin`, always) and, under
/// cadence targeting, the frame's own due time on the source's timeline. Adding the submit
/// lead here to a due time that is already an absolute present instant would push a frame due
/// just under a grid point onto the next one for some phases and not others — judder rather
/// than latency, which is the defect the due time exists to remove.
///
/// Gated on `expected_present`, NOT the timeline's `deadline`, on purpose: the deadline
/// budgets for GPU rendering the app has yet to submit (`presDeadline` — 11.3 ms on the
/// A024, more than a full 120 Hz period), but a video buffer is already fully rendered —
/// the only real constraint is SurfaceFlinger's own latch lead, which is what the caller's
/// `margin` represents. Targeting by deadline cost every frame an extra refresh of waiting
/// the only real constraint is SurfaceFlinger's own latch lead, which is what the caller
/// folds into the floor. Targeting by deadline cost every frame an extra refresh of waiting
/// (measured: latch p50 ~21 ms vs the ~2-interval floor); a mis-gamble here just means the
/// frame presents one vsync later — exactly what the conservative gate always paid.
///
@@ -110,10 +118,9 @@ impl VsyncShared {
/// at the app's assigned render rate, but the panel latches at its own — when the app is
/// down-rated (60 Hz callbacks on a 120 Hz panel) the reported timelines are a whole panel
/// period apart or more, and pacing to them would cap the video. Pulling the target earlier
/// by whole panel periods (while its present still clears the margin) restores the true
/// grid; when callbacks run at the panel rate the pull condition is never true and this is
/// a no-op.
pub(super) fn next_target(&self, now_ns: i64, margin_ns: i64) -> Option<FrameTimeline> {
/// by whole panel periods (while its present still clears the floor) restores the true grid;
/// when callbacks run at the panel rate the pull condition is never true and this is a no-op.
pub(super) fn next_target(&self, not_before_ns: i64) -> Option<FrameTimeline> {
let mut t = {
let g = self
.timelines
@@ -121,7 +128,7 @@ impl VsyncShared {
.unwrap_or_else(std::sync::PoisonError::into_inner);
let found = g
.iter()
.find(|t| t.expected_present_ns > now_ns + margin_ns)
.find(|t| t.expected_present_ns > not_before_ns)
.copied();
match found {
Some(t) => t,
@@ -132,8 +139,8 @@ impl VsyncShared {
return None;
}
// All stored timelines have passed — step the last one forward whole
// periods until its present clears `now + margin` again.
let behind = (now_ns + margin_ns).saturating_sub(last.expected_present_ns);
// periods until its present clears the floor again.
let behind = not_before_ns.saturating_sub(last.expected_present_ns);
let k = behind / period + 1;
FrameTimeline {
expected_present_ns: last.expected_present_ns + k * period,
@@ -144,7 +151,7 @@ impl VsyncShared {
};
let panel = self.panel_period_ns.load(Ordering::Relaxed);
if panel > 0 {
while t.expected_present_ns - panel > now_ns + margin_ns {
while t.expected_present_ns - panel > not_before_ns {
t.deadline_ns -= panel;
t.expected_present_ns -= panel;
}
+18 -9
View File
@@ -32,7 +32,7 @@ const PROTO: &str = "punktfunk/1";
/// Field separator inside one serialized record (ASCII Unit Separator — never in a field value).
const FIELD_SEP: char = '\u{1f}';
/// One resolved host, serialized to Kotlin as `key␟name␟addr␟port␟fp␟pair␟mac␟os`
/// One resolved host, serialized to Kotlin as `key␟name␟addr␟port␟fp␟pair␟mac␟os␟mgmt`
/// (`␟` = [`FIELD_SEP`]). Records are newline-joined in a poll snapshot; [`Host::encode`] strips
/// the framing bytes from every field so no value can break it. New fields append (the Kotlin
/// parser tolerates both arities), never reorder.
@@ -49,6 +49,10 @@ struct Host {
/// OS-identity chain from the mDNS `os` TXT (`linux/fedora/bazzite`, ...), for the host
/// card's OS icon. Empty if absent (older host).
os: String,
/// Management-API port from the mDNS `mgmt` TXT — where the game library is served, distinct
/// from `port` (the native QUIC plane). `0` if absent. Kotlin persists it on the host record so
/// a host that moved off 47990 keeps its library once mDNS is no longer reachable.
mgmt: u16,
}
impl Host {
@@ -61,7 +65,7 @@ impl Host {
s.replace(['\n', '\r', FIELD_SEP], "")
}
format!(
"{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}",
"{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}",
clean(&self.key),
clean(&self.name),
clean(&self.addr),
@@ -70,6 +74,7 @@ impl Host {
clean(&self.pair),
clean(&self.mac),
clean(&self.os),
self.mgmt,
)
}
}
@@ -193,6 +198,8 @@ fn resolve(info: &ResolvedService) -> Option<Host> {
pair: val("pair"),
mac: val("mac"),
os: val("os"),
// 0 = the host didn't advertise one (older host); Kotlin then falls back to 47990.
mgmt: val("mgmt").parse().unwrap_or(0),
})
}
@@ -213,7 +220,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoverySt
}
/// `NativeBridge.nativeDiscoveryPoll(handle): String` — the current resolved-host snapshot,
/// newline-joined records of `key␟name␟addr␟port␟fp␟pair␟mac␟os` (`␟` = U+001F). Empty string = no hosts /
/// newline-joined records of `key␟name␟addr␟port␟fp␟pair␟mac␟os␟mgmt` (`␟` = U+001F). Empty string = no hosts /
/// `0` handle. Poll ~1 Hz from the UI thread (cheap: a mutex lock + string build).
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll<'local>(
@@ -277,10 +284,11 @@ mod tests {
pair: "required".into(),
mac: "aa:bb:cc:dd:ee:ff".into(),
os: "linux/fedora/bazzite".into(),
mgmt: 47991,
};
let encoded = h.encode();
let fields: Vec<&str> = encoded.split(FIELD_SEP).collect();
assert_eq!(fields.len(), 8);
assert_eq!(fields.len(), 9);
assert_eq!(fields[0], "host-123");
assert_eq!(fields[1], "home-worker-2");
assert_eq!(fields[2], "192.168.1.70");
@@ -289,6 +297,9 @@ mod tests {
assert_eq!(fields[5], "required");
assert_eq!(fields[6], "aa:bb:cc:dd:ee:ff");
assert_eq!(fields[7], "linux/fedora/bazzite");
// A NON-default port on purpose: the whole point of carrying this field is the host that
// moved off 47990, so a test pinned to the default would pass against a hardcoded value.
assert_eq!(fields[8], "47991");
assert!(
!encoded.contains('\n'),
"a record must never contain the record separator"
@@ -308,13 +319,11 @@ mod tests {
pair: "required\n".into(),
mac: "aa:bb\u{1f}cc".into(),
os: "linux\u{1f}evil/arch".into(),
// A numeric field cannot smuggle a separator — it is formatted from a u16, not cleaned.
mgmt: 47991,
};
let encoded = h.encode();
assert_eq!(
encoded.matches(FIELD_SEP).count(),
7,
"exactly eight fields"
);
assert_eq!(encoded.matches(FIELD_SEP).count(), 8, "exactly nine fields");
assert!(!encoded.contains('\n') && !encoded.contains('\r'));
let fields: Vec<&str> = encoded.split(FIELD_SEP).collect();
assert_eq!(fields[0], "kinjected");
@@ -0,0 +1,69 @@
//! The session's access level over JNI — the Android leg of `design/per-client-access.md` §7.
//!
//! One poll shim: Kotlin reads `[grants, remainingSecs, updateSeq]` ~1 Hz (alongside its
//! session-ended watchdog) instead of holding a blocking event thread — access news is a
//! console edit or an expiry warning, a handful per session, and every gate the mask drives
//! re-checks within a second anyway. The connector already folds each mid-session
//! [`punktfunk_core::quic::AccessUpdate`] latest-wins into its live grants/deadline slots;
//! the seq counter here only exists so the poller can tell a FRESH update arrived (the host's
//! T5 m / T1 m warnings owe a toast) without diffing state that a warning doesn't change.
use jni::errors::LogErrorAndDefault;
use jni::objects::{JIntArray, JObject};
use jni::sys::jlong;
use jni::EnvUnowned;
use std::sync::atomic::Ordering;
use std::time::Duration;
use super::SessionHandle;
/// `NativeBridge.nativeAccessState(handle): IntArray?` — the live access state as
/// `[grants, remainingSecs, updateSeq]`; `null` on a `0` handle. `grants` is the
/// `GRANT_GAMEPAD`-family bitmask, seeded from the Welcome's advert (an old host reads as
/// `GRANT_ALL` — full control, today's behavior); `remainingSecs` counts down to the access
/// deadline on the CLIENT's clock (`0` = permanent, clamped to ≥ 1 once a deadline exists so
/// the sentinel can never be reached by counting); `updateSeq` increments once per
/// `AccessUpdate` drained from the connector's event plane. Not android-gated — pure `jni` +
/// connector reads, so it links on the host build too. Cheap; safe on the UI thread.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeAccessState<'local>(
mut env: EnvUnowned<'local>,
_this: JObject<'local>,
handle: jlong,
) -> JIntArray<'local> {
env.with_env(|env| -> jni::errors::Result<JIntArray<'local>> {
if handle == 0 {
return Ok(JIntArray::default());
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
// Drain the event plane into the seq counter. The connector's grants/deadline slots
// are already the latest-wins fold when an event lands — the events carry no state
// this read doesn't get below, they are purely the "something arrived" cue. Zero
// timeout: this is the UI thread's poll, it must never park.
while h.client.next_access_update(Duration::ZERO).is_ok() {
h.access_seq.fetch_add(1, Ordering::Relaxed);
}
let remaining: u64 = match h.client.access_deadline_unix() {
None => 0, // permanent
Some(deadline) => {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
// ≥ 1 once a deadline exists: 0 is the "permanent" sentinel, and a deadline
// already past with the session still up (the host's typed close is in
// flight) must keep reading as "about to end", never flip to "forever".
deadline.saturating_sub(now).max(1)
}
};
let buf: [i32; 3] = [
h.client.access_grants() as i32,
remaining.min(i32::MAX as u64) as i32,
h.access_seq.load(Ordering::Relaxed) as i32,
];
let arr = env.new_int_array(buf.len())?;
arr.set_region(env, 0, &buf)?;
Ok(arr)
})
.resolve::<LogErrorAndDefault>()
}
@@ -50,6 +50,21 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipSupport
client(handle).is_some_and(|h| h.client.host_caps() & HOST_CAP_CLIPBOARD != 0)
}
/// `NativeBridge.nativeHostMgmtPort(handle)` — the management-API port the host reported in this
/// session's `Welcome`, or `0` if it advertised none (older host / no management API).
///
/// Kotlin persists this on the host record, which is what lets the library screen reach a host that
/// moved its mgmt port off 47990 WITHOUT ever having seen an mDNS advert — the VPN / routed-subnet
/// / added-by-address cases, where the `mgmt` TXT the discovery path relies on never arrives.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostMgmtPort(
_env: EnvUnowned,
_this: JObject,
handle: jlong,
) -> jint {
client(handle).map_or(0, |h| jint::from(h.client.mgmt_port()))
}
/// `NativeBridge.nativeClipControl(handle, enabled)` — session-level opt-in/out. Nothing
/// clipboard-related happens on either side until an `enabled: true` crosses.
#[unsafe(no_mangle)]
@@ -315,6 +315,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
pad_audio: Mutex::new(None),
// A fresh session is never muted (mute is per-session UI state, not a setting).
mic_muted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
access_seq: std::sync::atomic::AtomicU32::new(0),
};
Box::into_raw(Box::new(handle)) as jlong
}
+6 -1
View File
@@ -17,6 +17,7 @@
//! TODO(M4 Android stage 1): client→host DualSense rich input (`send_rich_input`), mode
//! renegotiation. Port the remaining orchestration from `clients/linux`.
mod access;
mod clipboard;
mod connect;
mod input;
@@ -25,7 +26,7 @@ mod probe;
use punktfunk_core::client::NativeClient;
use std::panic::AssertUnwindSafe;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
@@ -82,6 +83,10 @@ pub(crate) struct SessionHandle {
/// fresh capture could send an unmuted frame. Per session and never persisted: a new session
/// starts unmuted.
pub mic_muted: Arc<AtomicBool>,
/// Count of `AccessUpdate`s drained from the connector's event plane, bumped by the
/// `nativeAccessState` poll ([`access`]) — how the Kotlin poller tells a fresh update
/// (the host's expiry warnings) arrived without holding a blocking event thread.
pub(crate) access_seq: AtomicU32,
}
struct VideoThread {
+6 -5
View File
@@ -357,10 +357,10 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetVideoSta
})
}
/// `NativeBridge.nativeStartAudio(handle, lowLatencyMode)` — start the Opus→AAudio playback thread.
/// `lowLatencyMode` (the experimental toggle) tags the stream usage=Game for the HAL's game-audio
/// routing. No-op if already started or on a `0` handle. Best-effort: a failure leaves video
/// streaming.
/// `NativeBridge.nativeStartAudio(handle, lowLatencyMode, isTv)` — start the Opus→AAudio playback
/// supervisor. `lowLatencyMode` (the experimental toggle) tags the stream usage=Game for the HAL's
/// game-audio routing; `isTv` steers the AAudio open ladder (see `crate::audio::open_ladder`).
/// No-op if already started or on a `0` handle. Best-effort: a failure leaves video streaming.
#[cfg(target_os = "android")]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
@@ -368,6 +368,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
_this: JObject,
handle: jlong,
low_latency_mode: jboolean,
is_tv: jboolean,
) {
if handle == 0 {
return;
@@ -378,7 +379,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
if guard.is_some() {
return; // already playing
}
match crate::audio::AudioPlayback::start(h.client.clone(), low_latency_mode) {
match crate::audio::AudioPlayback::start(h.client.clone(), low_latency_mode, is_tv) {
Some(p) => *guard = Some(p),
None => log::error!("nativeStartAudio: playback init failed (video unaffected)"),
}
@@ -0,0 +1,18 @@
{
"images" : [
{
"filename" : "about-icon@1x.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "about-icon@2x.png",
"idiom" : "universal",
"scale" : "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

@@ -83,14 +83,6 @@ struct ContentView: View {
/// never covers the video.
@State private var isFullscreen = false
#endif
#if os(macOS) || os(tvOS)
/// Shows the start-of-stream shortcut banner (the Windows client's discoverability
/// pattern): raised on every transition to `.streaming`, dropped by the banner's own
/// 6-second task. Independent of the stats HUD so the keys are discoverable even with
/// statistics off. On tvOS it carries the ONLY exits (hold Back / the pad chord) plus
/// the remote-as-pointer controls, so it must be seen at least once per session.
@State private var showShortcutHint = false
#endif
#if os(iOS)
/// The stats-OFF tier's touch-exit disc window (see the overlay in `stream(captureEnabled:)`
/// the disc must LEAVE the hierarchy so nothing composites over the metal layer).
@@ -347,9 +339,6 @@ struct ContentView: View {
.onChange(of: model.phase) { _, phase in
switch phase {
case .streaming:
#if os(macOS) || os(tvOS)
showShortcutHint = true // the 6 s shortcut banner, per session start
#endif
#if os(iOS)
showTouchExit = true // the off-tier exit disc's 8 s window, per session start
#endif
@@ -365,9 +354,14 @@ struct ContentView: View {
// Persist on the next runloop tick: HostStore is an ObservableObject, and mutating
// its @Published from inside .onChange (a view-update callback) trips SwiftUI's
// "Publishing changes from within view updates". A one-tick delay is imperceptible.
// The session's own Welcome told us where this host's library lives the one
// source that does not need an mDNS advert, so it also covers a host reached by
// address over a VPN. 0 = not advertised; updateMgmtPort ignores it.
let liveMgmtPort = model.connection?.hostMgmtPort
let store = store
DispatchQueue.main.async {
store.markConnected(host.id)
store.updateMgmtPort(host.id, port: liveMgmtPort)
if let approvedFingerprint { store.pin(host.id, fingerprint: approvedFingerprint) }
}
case .idle:
@@ -384,7 +378,11 @@ struct ContentView: View {
#if !os(tvOS)
.focusedSceneValue(\.sessionFocus, SessionFocus(
isStreaming: model.connection != nil,
clipboardAvailable: model.connection?.hostSupportsClipboard == true,
// Host cap AND this device's CLIPBOARD grant (per-client access §7) an
// ungranted session's menu item greys out instead of inviting a refused enable.
clipboardAvailable: model.connection.map {
$0.hostSupportsClipboard && $0.canUseClipboard
} == true,
clipboardOn: model.clipboardEnabled,
toggleClipboard: { model.toggleClipboardSync() },
micAvailable: model.micAvailable,
@@ -826,6 +824,7 @@ struct ContentView: View {
onPaired: handlePaired, waker: waker,
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered,
launchTitle: launchTitle,
wakeOnly: { wakeOnly($0) },
promptActive: consolePromptShowing)
} else {
HomeView(
@@ -845,6 +844,7 @@ struct ContentView: View {
onPaired: handlePaired, waker: waker,
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered,
launchTitle: launchTitle,
wakeOnly: { wakeOnly($0) },
promptActive: consolePromptShowing)
// On tvOS pairing/library normally present from HomeView's navigationDestinations
// which aren't mounted while the gamepad launcher is up. Give the launcher its
@@ -1067,7 +1067,25 @@ struct ContentView: View {
MotionUnreachableBadge()
.transition(.opacity.combined(with: .scale(scale: 0.9)))
}
// The expiry-warning toast (T5 m / T1 m, per-client access §7)
// transient, every platform, every tier: "the pad just died" must
// read as "the evening's access ended" while it can still be fixed.
if captureEnabled, let warning = model.accessWarning {
AccessWarningBadge(text: warning)
.transition(.opacity.combined(with: .scale(scale: 0.9)))
}
#if !os(tvOS)
// The access chip up for the life of a LIMITED session ("Controller
// only · ends in 1 h 58 m"), at every tier and with the overlay off.
// Never mounted for a full-and-permanent session (every old host):
// today's look must not change there. tvOS states it as a line in the
// stats overlay instead (StreamHUDView).
if captureEnabled && model.accessLimited {
AccessChipBadge(
label: model.accessLevel.label,
remainingSecs: model.accessRemainingSecs)
.transition(.opacity.combined(with: .scale(scale: 0.9)))
}
// Shown for as long as the mic is muted, at every stats tier and with the
// overlay off see MicMutedBadge. tvOS has no microphone to mute.
if captureEnabled && model.micMuted {
@@ -1075,34 +1093,20 @@ struct ContentView: View {
.transition(.opacity.combined(with: .scale(scale: 0.9)))
}
#endif
#if os(macOS) || os(tvOS)
// The start-of-stream shortcut banner (Windows-client parity): the
// The start-of-stream shortcut banner used to sit here (macOS/tvOS): the
// platform's reserved controls on a glass pill for the first 6 seconds of
// every session independent of the stats HUD, so the keys are
// discoverable even with statistics off. The banner's own task drops it
// (cancelled cleanly if the session view goes away first). On tvOS it
// carries the ONLY exits Menu/B is swallowed during a session (the
// `.onExitCommand {}` in the tvOS session branch), so the hold gestures
// must be told to the user.
if captureEnabled && showShortcutHint {
Text(shortcutHintText)
.font(.geist(Self.shortcutHintFont, relativeTo: .caption))
.foregroundStyle(.secondary)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.glassBackground(Capsule())
.transition(.opacity)
.task {
try? await Task.sleep(for: .seconds(6))
withAnimation(.easeOut(duration: 0.6)) {
showShortcutHint = false
}
}
}
#endif
// every session. It is now a page you can OPEN About Shortcuts, on
// both the touch and the controller surface (ShortcutsCatalog) because
// a message that shows once, over the stream you have just connected to,
// is unavailable at the moment the question is actually asked. It also
// put a composited overlay above the stream for those 6 seconds, which on
// this path costs a refresh of display latency (see the iOS exit disc's
// note below); the reference page costs nothing during a session.
}
.padding(.bottom, 24)
.animation(.easeOut(duration: 0.2), value: model.micMuted)
.animation(.easeOut(duration: 0.2), value: model.accessWarning)
.animation(.easeOut(duration: 0.2), value: model.accessLimited)
}
#if os(iOS)
// Touch users have no menu / D, so when the HUD's Disconnect button isn't on
@@ -1166,23 +1170,10 @@ struct ContentView: View {
}
#endif
#if os(macOS)
/// The reserved combos, told once per session. The mute segment appears only when the session
/// actually sends a microphone teaching a shortcut for a mic that isn't on would be a lie.
private var shortcutHintText: String {
let base =
"Click the stream to capture · ⌃⌥⇧Q releases the mouse · ⌃⌥⇧D disconnects · ⌃⌥⇧S stats"
return model.micAvailable ? base + " · ⌃⌥⇧A mutes the mic" : base
}
private static let shortcutHintFont: CGFloat = 12
#elseif os(tvOS)
private var shortcutHintText: String {
"Hold the remote's Back button — or L1+R1+Start+Select on a controller — to disconnect"
+ " · Touch surface moves the pointer · press clicks · Play/Pause right-clicks"
+ " · Hold Play/Pause, or Select+X on a controller, for statistics"
}
private static let shortcutHintFont: CGFloat = 22 // read from the couch
#endif
// The two `shortcutHintText` strings that used to live here one per platform, told once per
// session by the banner above are now `ShortcutsCatalog.groups`, which both About pages
// render. The mic line is still conditional there for the same reason it was here: teaching a
// shortcut for a microphone that isn't on would be a lie.
// MARK: - Connect
@@ -1300,6 +1291,9 @@ struct ContentView: View {
if let live = discovery.hosts.first(where: { host.matches($0) }) {
store.updateMacs(host.id, macs: live.macAddresses) // learn on every platform
store.updateOsChain(host.id, chain: live.osChain) // ditto for the card's OS mark
// ...and the mgmt port, so the library keeps working against a host that moved it once
// this device can no longer see the advert (VPN, routed subnet, multicast-dead Wi-Fi).
store.updateMgmtPort(host.id, port: live.mgmtPort)
} else if autoWakeEnabled, PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty {
// Auto-wake only: fire the up-front packet so a genuinely-asleep host is booting while the
// dial times out. With auto-wake off, connects go straight through (no packet).
@@ -1358,6 +1352,7 @@ struct ContentView: View {
guard !model.isBusy else { return }
let host = StoredHost(
name: d.name, address: d.host, port: d.port,
mgmtPort: d.mgmtPort,
macAddresses: d.macAddresses.isEmpty ? nil : d.macAddresses,
osChain: d.osChain.isEmpty ? nil : d.osChain)
store.add(host)
@@ -30,10 +30,10 @@ struct AddHostSheet: View {
@State private var pinnedIDs: Set<String>
@ObservedObject private var profiles = ProfileStore.shared
#endif
#if os(macOS)
/// Share the clipboard with this host (macOS sessions only; design
/// clipboard-and-file-transfer.md §5.3). Off by default; honored only when the host
/// advertises the capability at connect.
#if !os(tvOS)
/// Share the clipboard with this host (design clipboard-and-file-transfer.md §5.3). Off by
/// default; honored only when the host advertises the capability at connect. Absent on tvOS,
/// which has no pasteboard to share.
@State private var clipboardSync: Bool
#endif
#if os(tvOS)
@@ -72,7 +72,7 @@ struct AddHostSheet: View {
_port = State(initialValue: Int(existing?.port ?? 9777))
let stored = existing?.macAddresses ?? []
_mac = State(initialValue: (stored.isEmpty ? suggestedMacs : stored).joined(separator: ", "))
#if os(macOS)
#if !os(tvOS)
_clipboardSync = State(initialValue: existing?.clipboardSync ?? false)
#endif
#if !os(tvOS)
@@ -144,7 +144,7 @@ struct AddHostSheet: View {
#if os(iOS)
.textInputAutocapitalization(.never)
#endif
#if os(macOS)
#if !os(tvOS)
Toggle("Share clipboard with this host", isOn: $clipboardSync)
#endif
profileRows
@@ -200,11 +200,11 @@ struct AddHostSheet: View {
}
#if os(iOS)
/// Four fields + the action row a touch taller than the 3-field add sheet used to be. The
/// edit sheet's profile rows are the only thing that can outgrow it, and they say by how much;
/// a single fixed number is what clipped them.
/// Four fields, the clipboard toggle, and the action row. The edit sheet's profile rows are
/// the only thing that can outgrow it, and they say by how much; a single fixed number is what
/// clipped them.
private var sheetHeight: CGFloat {
var height: CGFloat = 392
var height: CGFloat = 392 + 44 // the fields and action row, plus the clipboard toggle
if showsProfileRows {
height += 116 // the Profile picker and its footnote
height += 96 + CGFloat(profiles.profiles.count) * 44 // the pins, their header + footer
@@ -282,7 +282,7 @@ struct AddHostSheet: View {
host.address = address.trimmingCharacters(in: .whitespaces)
host.port = UInt16(clamping: port)
host.macAddresses = Self.parseMacs(mac)
#if os(macOS)
#if !os(tvOS)
// nil when off: the key stays absent from the saved JSON (forward-compat, and "never
// opted in" and "opted out" read the same off).
host.clipboardSync = clipboardSync ? true : nil
@@ -28,6 +28,18 @@ struct GamepadAddHostView: View {
/// Whether this screen owns the controller false while the shell is mid-transition or the
/// connect takeover is up (see GamepadSettingsView's twin).
var controllerActive = true
/// Non-nil this screen is EDITING that saved host rather than registering a new one: the
/// fields start on its values and `onAdd` receives it back with only name/address/port
/// changed, so the fingerprint, pins, binding and MACs it carries survive the edit. A
/// re-typed address is the whole point of the screen (a host that moved), so nothing here
/// re-derives identity from it that is the trust store's job, not this form's.
///
/// Declared after the closures for the same trailing-closure reason as `close`, and it is a
/// plain value besides, so it can never capture one.
var editingHost: StoredHost?
/// One-shot seed guard: `@State` cannot be initialised from a property without a custom init,
/// and a custom init would break every existing trailing-closure call site.
@State private var seeded = false
#if os(iOS)
/// `.compact` in a landscape phone window tighter chrome so the keyboard tray still fits.
@@ -60,12 +72,15 @@ struct GamepadAddHostView: View {
.safeAreaInset(edge: .top, spacing: 0) {
VStack(alignment: .leading, spacing: gamepadHeaderSpacing(compact: compact)) {
// Leading, like every gamepad heading and no close chrome (B is the exit).
Text("Add Host")
Text(editingHost == nil ? "Add Host" : "Edit Host")
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(ink.fg)
if !compact {
Text("Hosts on this network appear automatically — add one by address "
+ "for everything else.")
Text(editingHost == nil
? "Hosts on this network appear automatically — add one by address "
+ "for everything else."
: "Rename this host, or point it at a new address — its pairing and "
+ "pinned cards are kept.")
.font(.geist(metrics.detailFont, relativeTo: .caption))
.foregroundStyle(ink.fg(0.55))
.multilineTextAlignment(.leading)
@@ -101,6 +116,17 @@ struct GamepadAddHostView: View {
.onChange(of: port) { _, value in
if value.count > 5 { port = String(value.prefix(5)) }
}
// Seed the fields from the host being edited, exactly once: re-seeding on a later appear
// (the shell re-mounts a layer when the app returns from the background) would silently
// throw away whatever had been typed.
.onAppear {
guard !seeded else { return }
seeded = true
guard let host = editingHost else { return }
name = host.name
address = host.address
port = String(host.port)
}
#if !os(tvOS)
// The visible close is gone (a gamepad UI exits with B) this keeps a hardware
// keyboard's Esc and the macOS sheet's cancel working without chrome.
@@ -205,7 +231,9 @@ struct GamepadAddHostView: View {
Row(id: "name", label: "Name", value: name, placeholder: "Optional — e.g. Living Room"),
Row(id: "address", label: "Address", value: address, placeholder: "IP or hostname"),
Row(id: "port", label: "Port", value: port, placeholder: "9777"),
Row(id: "add", label: "Add Host", isAction: true),
Row(
id: "add", label: editingHost == nil ? "Add Host" : "Save Changes",
isAction: true),
]
}
@@ -264,10 +292,21 @@ struct GamepadAddHostView: View {
openKeyboard("address")
return
}
onAdd(StoredHost(
name: name.trimmingCharacters(in: .whitespaces),
address: address.trimmingCharacters(in: .whitespaces),
port: UInt16(port) ?? 9777))
let typedName = name.trimmingCharacters(in: .whitespaces)
let typedAddress = address.trimmingCharacters(in: .whitespaces)
let typedPort = UInt16(port) ?? 9777
if var host = editingHost {
// Mutate a COPY of the stored record rather than building a fresh one: everything
// this form does not show the pinned fingerprint, WoL MACs, pinned profile
// cards, the default binding, `addedAt` has to survive a rename.
host.name = typedName
host.address = typedAddress
host.port = typedPort
onAdd(host)
} else {
onAdd(StoredHost(
name: typedName, address: typedAddress, port: typedPort))
}
performClose()
default:
openKeyboard(id)
@@ -48,6 +48,13 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
var onTertiary: (() -> Void)?
/// B back/dismiss; nil disables it (e.g. the root launcher has nowhere to go back to).
var onBack: (() -> Void)?
/// UP the focused item's own menu (the launcher's host options). Wiring it takes the whole
/// VERTICAL axis away from scrolling: up opens the menu and down goes inert, rather than up
/// meaning "menu" while down still stepped the strip. A horizontal carousel has no vertical
/// travel to spend, and the desktop and Android consoles both read the axis this way one
/// meaning per direction is what makes the gesture learnable across the three of them.
/// nil leaves up/down as a second way to step (what every carousel without a menu still does).
var onUp: (() -> Void)?
/// L1/R1 jump this many items at once (clamped to the ends); 0 disables the shoulders.
var shoulderJump: Int = 0
/// Whether this carousel currently owns controller input. A presenting screen (e.g. the host
@@ -301,6 +308,17 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
// The poll carries only the buttons focus has no concept of: Y/X, the screen actions.
input.onSecondary = onSecondary
input.onTertiary = onTertiary
// UP is the one direction the poll may also read here, and ONLY to open the menu it
// never calls `step`, so it cannot double-move against the focus engine. Routing it
// through `.onMoveCommand` instead was the obvious alternative and the wrong one: that
// stream is 4-way and its interception is input-source-dependent on real hardware (see
// GamepadMenuList's tvOS note), so claiming up there risks left/right focus with it.
// Nothing sits above the strip for the engine to move to, so this direction is free.
if let onUp {
input.onMove = { direction in
if direction == .up { onUp() }
}
}
#else
input.onMove = { move($0) }
input.onConfirm = { activate() }
@@ -312,6 +330,14 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
}
private func move(_ direction: GamepadMenuInput.Direction) {
// With a menu wired, vertical is the menu's axis, not a second scroll axis see `onUp`.
if let onUp {
switch direction {
case .up: return onUp()
case .down: return
case .left, .right: break
}
}
let forward = direction == .right || direction == .down
step(by: forward ? 1 : -1, clampAtEnds: false)
}
@@ -94,6 +94,10 @@ struct GamepadHomeView: View {
/// Launch a library title on a host the in-place library layer's activate path (iOS; the
/// cover/sheet presentations wire ContentView's `launchTitle` into LibraryView themselves).
let launchTitle: (LibraryTarget, String) -> Void
/// Wake a host WITHOUT connecting (ContentView's `wakeOnly`) the host menu's Wake row. The
/// tile's own A already wakes-and-connects; this is the other half, for bringing a machine up
/// to look at it rather than to stream from it right now.
let wakeOnly: (StoredHost) -> Void
/// A console prompt (GamepadPromptView) is up over the home it polls the same controller, so
/// this screen must stand down for as long as it is. Same handoff contract as the connect
/// takeover and the shell's own layers; without it the carousel keeps scrolling underneath the
@@ -122,6 +126,11 @@ struct GamepadHomeView: View {
@State private var selection: GamepadHomeTarget?
@State private var showSettings = false
@State private var showAddHost = false
/// The card whose options menu is up (UP on a saved tile) see GamepadHostOptionsView.
@State private var hostOptionsTarget: HostOptionsTarget?
/// The host being edited. Set from the options menu, which closes itself as it opens this so
/// the two are never stacked depth stays 1, which is what `GamepadScreen` assumes.
@State private var editTarget: StoredHost?
/// The console's input drop: true for the transition's 0.26 s, during which NO layer polls
/// the controller a double-tapped A can't push two screens, and the held button that
/// caused the change is long released before the next poller starts (whose own
@@ -204,19 +213,37 @@ struct GamepadHomeView: View {
// shell's layers above ARE the presentation.
#if os(macOS)
.sheet(isPresented: $showSettings) {
GamepadSettingsView(store: store)
GamepadSettingsView(store: store, micAvailable: model.micAvailable)
.frame(width: 720, height: 640)
}
.sheet(isPresented: $showAddHost) {
GamepadAddHostView { store.add($0) }
.frame(width: 660, height: 620)
}
// Shorter than the forms above: a menu is five rows, and a sheet sized for a settings
// screen would be mostly empty field under them.
.sheet(item: $hostOptionsTarget) { target in
hostOptionsView(target, active: true)
.frame(width: 620, height: 460)
}
.sheet(item: $editTarget) { host in
editHostView(host, active: true)
.frame(width: 660, height: 620)
}
.frame(minWidth: 640, minHeight: 420)
#elseif os(tvOS)
.fullScreenCover(isPresented: $showSettings) { GamepadSettingsView(store: store) }
.fullScreenCover(isPresented: $showSettings) {
GamepadSettingsView(store: store, micAvailable: model.micAvailable)
}
.fullScreenCover(isPresented: $showAddHost) {
GamepadAddHostView { store.add($0) }
}
.fullScreenCover(item: $hostOptionsTarget) { target in
hostOptionsView(target, active: true)
}
.fullScreenCover(item: $editTarget) { host in
editHostView(host, active: true)
}
#endif
}
@@ -264,6 +291,10 @@ struct GamepadHomeView: View {
// can be raised from ON TOP of the library (launching a title on an unpaired host), where
// it has to win. Backing out of it reveals whatever it interrupted.
if let host = pairingTarget { return .pair(host) }
// Editing leads the menu that raised it: the menu clears itself on the way, so the two are
// never both set, and if they somehow were, the screen the user asked for last should win.
if let host = editTarget { return .editHost(host) }
if let target = hostOptionsTarget { return .hostOptions(target) }
if showSettings { return .settings }
if showAddHost { return .addHost }
if let shelf = libraryTarget { return .library(shelf) }
@@ -280,12 +311,17 @@ struct GamepadHomeView: View {
GamepadSettingsView(
store: store,
close: { if !transitioning { showSettings = false } },
controllerActive: active)
controllerActive: active,
micAvailable: model.micAvailable)
case .addHost:
GamepadAddHostView(
onAdd: { store.add($0) },
close: { if !transitioning { showAddHost = false } },
controllerActive: active)
case .hostOptions(let target):
hostOptionsView(target, active: active)
case .editHost(let host):
editHostView(host, active: active)
case .pair(let host):
GamepadPairView(
host: host,
@@ -417,6 +453,7 @@ struct GamepadHomeView: View {
onActivate: { $0.activate() },
onSecondary: { openLibraryForSelected() },
onTertiary: { showSettings = true },
onUp: { openOptionsForSelected() },
isActive: homeOwnsController
) { tile, entrance in
hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight), entrance: entrance)
@@ -472,6 +509,14 @@ struct GamepadHomeView: View {
glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library",
action: { openLibraryForSelected() }))
}
// Only a saved card has a menu, so the cell appears only where the press does something
// the same honesty rule the Library cell above follows. A direction, not a button, so it
// is a plain arrow rather than a `buttonGlyph` (see the settings screen's "Adjust").
if case .saved = selected?.id {
hints.append(.init(
glyph: "arrow.up", text: "Options",
action: { openOptionsForSelected() }))
}
hints.append(.init(
glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Settings",
action: { showSettings = true }))
@@ -506,8 +551,10 @@ struct GamepadHomeView: View {
filled: true,
// A pinned card reaches the library too, and gets its OWN shelf: browsing is
// this card's connect with a title picked first, not a host-level action like
// wake or forget.
hasLibrary: true,
// wake or forget. Gated on a pinned identity: the library plane's MgmtTransport
// accepts any cert for an unpinned host, so an unpaired host must not expose a
// library affordance a LAN MITM could answer. security-review 2026-08-15 #8.
hasLibrary: host.pinnedSHA256 != nil,
osChain: host.osChain,
canWake: autoWakeEnabled && PunktfunkConnection.wakeOnLANAvailable
&& !online && !host.wakeMacs.isEmpty,
@@ -546,6 +593,60 @@ struct GamepadHomeView: View {
/// `HostCardView`-only action never offered on `DiscoveredCardView`. A pinned card opens its
/// own shelf: the selection already names which card Y was pressed on, and that card's profile
/// is what its launches run with.
/// The host menu, built once for all three presentations (the iOS shell layer, the macOS
/// sheet, the tvOS cover) so the actions can't drift between them.
///
/// Edit REPLACES this menu rather than stacking on it `hostOptionsTarget` is cleared as
/// `editTarget` is set which is the desktop console's `Nav::Replace` and what keeps the
/// shell's "depth 1 by construction" claim true.
@ViewBuilder
private func hostOptionsView(_ target: HostOptionsTarget, active: Bool) -> some View {
let host = target.host
GamepadHostOptionsView(
host: host,
pinnedProfile: target.profile,
isOnline: discovery.advertises(host) || store.probedOnline.contains(host.id),
canWake: autoWakeEnabled && PunktfunkConnection.wakeOnLANAvailable
&& !host.wakeMacs.isEmpty,
onEdit: {
guard !transitioning else { return }
hostOptionsTarget = nil
editTarget = host
},
onWake: { wakeOnly(host) },
onForgetPairing: { store.forgetIdentity(host) },
onRemove: { store.remove(host) },
onUnpin: {
guard let profile = target.profile else { return }
store.setPinned(host.id, profileID: profile.id, pinned: false)
},
close: { if !transitioning { hostOptionsTarget = nil } },
controllerActive: active)
}
/// The add-host form in edit mode. `store.update` writes the record back by id, so the
/// fingerprint, MACs, pins and binding the form never shows are preserved.
@ViewBuilder
private func editHostView(_ host: StoredHost, active: Bool) -> some View {
GamepadAddHostView(
onAdd: { store.update($0) },
close: { if !transitioning { editTarget = nil } },
controllerActive: active,
editingHost: host)
}
/// UP on a saved tile opens that card's menu. Only SAVED hosts have one: a discovered-but-
/// unsaved host is not ours to rename or remove, and the two action tiles have nothing to
/// offer the same `HostOptionsScreen::available` gate the desktop console applies.
private func openOptionsForSelected() {
guard case .saved(let id, let profileID) = selection,
let host = store.hosts.first(where: { $0.id == id })
else { return }
hostOptionsTarget = HostOptionsTarget(
host: host,
profile: profileID.flatMap { pid in profiles.profiles.first { $0.id == pid } })
}
private func openLibraryForSelected() {
guard libraryEnabled, case .saved(let id, let profileID) = selection,
let host = store.hosts.first(where: { $0.id == id })
@@ -0,0 +1,321 @@
// A saved host's own actions Wake, Copy link, Edit, Forget pairing, Remove reached with UP on
// its carousel tile. The console's answer to the overflow menu the touch grid hangs off every host
// card (HostCardView's context menu), and the Apple port of `pf-console-ui`'s HostOptionsScreen.
//
// Until now the gamepad UI could add a host and connect to one, and that was all: a renamed machine
// or a host typed in with a fat-fingered address stayed wrong forever, because the only surface
// that could edit or remove one was the touch UI. The tile is where a host is, so the tile is where
// its actions belong.
//
// UP is the gesture because the carousel is horizontal left/right are spoken for and up is free
// and because the desktop console and the Android console already do exactly this, so the three are
// learned once. A pinned profile card offers only Unpin: it is a shortcut, not a second host, and
// offering to remove the host from it would blur precisely the distinction a pin exists to draw.
//
// Vocabulary note: this screen says "Forget pairing" and "Remove host" where the desktop console
// says one word, "Forget". The console has only the one action; Apple has both (HostCardView calls
// them `onForget` = drop the pinned fingerprint and `onRemove` = delete the record), and two
// different actions cannot share a name on the surface that offers both. The touch card's words
// win over the other consoles' here a user meets both Apple surfaces, and only one of them is
// cross-platform.
import PunktfunkKit
import SwiftUI
#if os(iOS) || os(macOS) || os(tvOS)
/// Which card the menu was opened on. Carries the host BY VALUE for the same reason the screen
/// does the carousel is rebuilt on every discovery pass, and a target that re-resolved itself
/// could hand "Remove" a different host than the one the user was looking at.
struct HostOptionsTarget: Identifiable {
let host: StoredHost
/// Non-nil a pinned profile card rather than the host's own tile.
var profile: StreamProfile?
/// Keyed on the CARD, not the host: a host and each of its pinned cards open different menus,
/// and sharing an id would let one stand in for another mid-transition (the same rule
/// `GamepadScreen.library` follows).
var id: String { "\(host.id.uuidString)-\(profile?.id ?? "")" }
}
struct GamepadHostOptionsView: View {
/// Resolved from the stored palette, NOT from `\.gamepadInk` this screen publishes that
/// value itself and so sits above its own copy (see `GamepadInk.stored`).
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
private var ink: GamepadInk { .stored(paletteID) }
@Environment(\.gamepadMetrics) private var metrics
@Environment(\.displayBottomInset) private var displayBottomInset
@Environment(\.gamepadHostedInShell) private var hostedInShell
@Environment(\.dismiss) private var dismiss
/// The host this menu was opened on, BY VALUE. Discovery rewrites the carousel on every
/// service pass; holding an index or a live lookup would let the menu retarget itself onto
/// whichever host slid into that slot, and "Remove" must never be able to do that.
let host: StoredHost
/// Non-nil opened on a pinned profile card rather than the host's own tile.
var pinnedProfile: StreamProfile?
/// Whether the host is reachable right now decides whether Wake is worth offering.
var isOnline = false
/// Whether waking is possible at all (the setting is on, WoL is available, a MAC is known).
var canWake = false
let onEdit: () -> Void
let onWake: () -> Void
/// Drop the pinned fingerprint the host stays saved, and the next connect re-pairs.
let onForgetPairing: () -> Void
/// Delete the saved record outright.
let onRemove: () -> Void
let onUnpin: () -> Void
var close: (() -> Void)?
var controllerActive = true
#if os(iOS)
@Environment(\.verticalSizeClass) private var vSizeClass
private var compact: Bool { vSizeClass == .compact }
#else
private let compact = false
#endif
/// Removing is the one action here with no undo, so its row ARMS on the first press and only
/// fires on the second. The touch grid removes behind a system confirmation dialog; a console
/// is driven by a thumbstick from across a room, which is a good reason to be at least as
/// strict as it is, and none at all to be looser.
@State private var armed = false
@State private var copied = false
@State private var focusID: String?
private enum Action: String {
case wake
case copyLink
case edit
case forgetPairing
case remove
case unpin
case cancel
}
var body: some View {
GamepadMenuList(
items: rows,
focusID: $focusID,
onActivate: { run($0.action) },
onBack: { performClose() },
isActive: controllerActive
) { row, focused in
rowView(row, focused: focused)
.frame(maxWidth: metrics.rowMaxWidth)
.padding(.horizontal, 24)
}
.frame(maxWidth: .infinity)
.safeAreaInset(edge: .top, spacing: 0) {
VStack(alignment: .leading, spacing: gamepadHeaderSpacing(compact: compact)) {
Text(title)
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(ink.fg)
.lineLimit(1)
if !compact {
Text("\(host.address):\(String(host.port))")
.font(.geistFixed(metrics.detailFont, .medium))
.foregroundStyle(ink.fg(0.55))
}
}
.padding(.horizontal, 24)
.padding(.top, gamepadTitleTopPadding(compact: compact))
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
.frame(maxWidth: .infinity, alignment: .leading)
.background { GamepadTrayBlur(edge: .top) }
}
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 8) {
Text(detail)
.font(.geist(metrics.detailFont, relativeTo: .caption))
.foregroundStyle(ink.fg(0.55))
.lineLimit(2, reservesSpace: true)
.animation(.smooth(duration: 0.2), value: focusID)
GamepadHintBar(hints: hints)
}
.padding(.leading, compact ? 12 : 18)
.padding(.trailing, 22)
.padding(
.bottom,
gamepadLegendBottomPadding(
compact ? 12 : 18, tier: metrics.tier, displayBottom: displayBottomInset))
.padding(.top, compact ? 6 : 10)
.frame(maxWidth: .infinity, alignment: .leading)
.background { GamepadTrayBlur(edge: .bottom) }
}
.background {
if !hostedInShell { GamepadFormBackground() }
}
.gamepadPaletteInk()
// Moving the focus off the armed Remove row disarms it: an arming that outlives the row it
// was made on is a trap, and the thumb that wandered away is exactly the hesitation the
// two-press rule exists to catch.
.onChange(of: focusID) { _, id in
if id != Action.remove.rawValue { armed = false }
}
#if !os(tvOS)
.background {
Button("Cancel") { performClose() }
.keyboardShortcut(.cancelAction)
.buttonStyle(.plain)
.frame(width: 0, height: 0)
.opacity(0)
.accessibilityHidden(true)
}
#endif
}
private var title: String {
pinnedProfile.map { "\(host.displayName) · \($0.name)" } ?? host.displayName
}
// MARK: - Rows
private struct Row: Identifiable {
let action: Action
let label: String
var icon: String
var isDestructive = false
var id: String { action.rawValue }
}
private var rows: [Row] {
// A pinned card is a shortcut, not a host: everything host-level is deliberately absent.
if pinnedProfile != nil {
return [
Row(action: .unpin, label: "Unpin card", icon: "pin.slash"),
Row(action: .copyLink, label: copied ? "Copied" : "Copy link", icon: "link"),
Row(action: .cancel, label: "Cancel", icon: "xmark"),
]
}
var list: [Row] = []
// Waking a host that is already answering would just sit there counting seconds.
if canWake, !isOnline {
list.append(Row(action: .wake, label: "Wake host", icon: "power"))
}
list.append(Row(action: .copyLink, label: copied ? "Copied" : "Copy link", icon: "link"))
list.append(Row(action: .edit, label: "Edit\u{2026}", icon: "pencil"))
// Only a paired host has a pairing to drop.
if host.pinnedSHA256 != nil {
list.append(Row(
action: .forgetPairing, label: "Forget pairing", icon: "lock.open"))
}
list.append(Row(
action: .remove,
label: armed ? "Remove host \u{2014} press again" : "Remove host",
icon: "trash", isDestructive: true))
list.append(Row(action: .cancel, label: "Cancel", icon: "xmark"))
return list
}
/// The explainer under the list the same band the settings screen uses, and the only place a
/// destructive action can say what it will actually do before it is pressed.
private var detail: String {
switch rows.first(where: { $0.id == focusID })?.action {
case .wake:
return "Send a Wake-on-LAN packet and wait for this host to answer."
case .copyLink:
return "Copy a punktfunk:// link to this host — paste it anywhere to connect."
case .edit:
return "Rename this host or change its address. Pairing and pinned cards are kept."
case .forgetPairing:
return "Drop the stored fingerprint. The host stays saved and the next connect "
+ "pairs again."
case .remove:
return armed
? "Press again to remove — this cannot be undone."
: "Delete this host, its pairing and its pinned cards from this device."
case .unpin:
return "Remove this profile's card. The profile itself and the host are untouched."
case .cancel, .none:
return ""
}
}
private var hints: [GamepadHint] {
[
.init(
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Select",
action: { if let id = focusID, let row = rows.first(where: { $0.id == id }) {
run(row.action)
} }),
.init(
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back",
action: { performClose() }),
]
}
// MARK: - Actions
private func run(_ action: Action) {
switch action {
case .wake:
onWake()
performClose()
case .copyLink:
LinkClipboard.copy(
DeepLink.forHost(host, profile: pinnedProfile?.id).urlString)
// No toast machinery on this surface the row says so itself, which is the same
// acknowledgement in the place the user is already looking.
withAnimation(.smooth(duration: 0.2)) { copied = true }
case .edit:
onEdit()
case .forgetPairing:
onForgetPairing()
performClose()
case .remove:
guard armed else {
withAnimation(.smooth(duration: 0.2)) { armed = true }
return
}
onRemove()
performClose()
case .unpin:
onUnpin()
performClose()
case .cancel:
performClose()
}
}
private func performClose() {
if let close { close() } else { dismiss() }
}
// MARK: - Row rendering
private func rowView(_ row: Row, focused: Bool) -> some View {
let m = metrics
// The destructive row wears the warning colour only once ARMED: red on a row that still
// needs a second press reads as "this already happened".
let danger = row.isDestructive && armed
return HStack(spacing: 14) {
Image(systemName: row.icon)
.font(.system(size: m.iconFont))
.foregroundStyle(
danger ? GamepadInk.warningRed : (focused ? ink.accent : ink.fg(0.55)))
.frame(width: m.iconWidth)
Text(row.label)
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
.foregroundStyle(danger ? GamepadInk.warningRed : ink.fg)
.lineLimit(1)
Spacer(minLength: 12)
}
.padding(.horizontal, m.rowHPad)
.padding(.vertical, m.rowVPad)
.consoleGlass(
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous),
tint: focused ? (danger ? GamepadInk.warningRed.opacity(0.3) : ink.accent(0.30)) : nil,
interactive: focused)
.overlay {
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous)
.strokeBorder(
danger ? GamepadInk.warningRed.opacity(0.7) : ink.fg(focused ? 0.28 : 0.06),
lineWidth: 1)
}
.scaleEffect(focused ? 1.0 : 0.98)
.animation(.smooth(duration: 0.18), value: focused)
.animation(.smooth(duration: 0.18), value: armed)
}
}
#endif
@@ -88,6 +88,11 @@ struct GamepadInk: Equatable, Sendable {
/// The online pip deliberately NOT palette-derived: a status colour must not change
/// meaning with the wallpaper (the console's rule; this is its `ONLINE_GREEN` verbatim).
static let onlineGreen = Color(red: 0.20, green: 0.84, blue: 0.29)
/// An armed destructive action (the host menu's Remove). Palette-independent for exactly the
/// same reason as the pip above, and the more strongly so: the one colour on this UI that
/// means "this does not come back" cannot be allowed to drift toward the wallpaper on a warm
/// palette, or read as a highlight on a red one.
static let warningRed = Color(red: 0.94, green: 0.28, blue: 0.26)
}
private struct GamepadInkKey: EnvironmentKey {
@@ -21,6 +21,8 @@ import SwiftUI
enum GamepadScreen: Identifiable {
case settings
case addHost
case hostOptions(HostOptionsTarget)
case editHost(StoredHost)
case pair(StoredHost)
case library(LibraryTarget)
@@ -28,6 +30,10 @@ enum GamepadScreen: Identifiable {
switch self {
case .settings: return "settings"
case .addHost: return "addHost"
// Keyed on the CARD (host + pinned profile), for the same reason the library is keyed on
// the shelf see `HostOptionsTarget.id`.
case .hostOptions(let target): return "hostOptions-\(target.id)"
case .editHost(let host): return "editHost-\(host.id.uuidString)"
case .pair(let host): return "pair-\(host.id.uuidString)"
// Keyed on the SHELF, not the host: a host and each of its pinned cards open different
// libraries, and sharing an id would let one stand in for another mid-transition.
@@ -39,7 +45,7 @@ enum GamepadScreen: Identifiable {
/// (`Bg::Form` in the console); the library keeps the launcher's full aurora.
var isForm: Bool {
switch self {
case .settings, .addHost, .pair: return true
case .settings, .addHost, .hostOptions, .editHost, .pair: return true
case .library: return false
}
}
@@ -268,7 +268,12 @@ struct HomeView: View {
let selection: ProfileSelection = pinned.map { .profile($0.id) } ?? .inherit
// and browsing is that same connect with a title picked first, so a pinned card opens its
// OWN shelf: every launch off it carries the card's profile rather than the host's binding.
let onBrowseLibrary: (() -> Void)? = libraryEnabled
// Gated on a pinned identity, not just the feature toggle: the library plane's
// MgmtTransport trust-on-first-use accepts ANY cert for a pin-less host (self-signed, no
// SAN system trust is bypassed), so browsing an unpinned host lets a LAN MITM serve a
// forged catalog and harvest the device's pairing identity. Pair first, exactly as the
// stream path already refuses an unpinned connect. security-review 2026-08-15 finding 8.
let onBrowseLibrary: (() -> Void)? = (libraryEnabled && host.pinnedSHA256 != nil)
? { libraryTarget = LibraryTarget(host: host, profile: selection) }
: nil
return HostCardView(
@@ -24,7 +24,7 @@ struct LibraryCoverflowView: View {
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
private var ink: GamepadInk { .stored(paletteID) }
let games: [GameEntry]
let artLoader: LibraryArtLoader?
let artLoader: (any LibraryArtSource)?
var onLaunch: ((String) -> Void)?
/// Button B (back) dismisses the library screen. No touch equivalent needed here (the toolbar
/// Close button already covers that); this is what makes gamepad-only exit possible.
@@ -74,7 +74,7 @@ struct LibraryView: View {
@State private var errorText: String?
/// Cover-art loader (the same paired identity + host pinning as the list fetch, reused across
/// every poster in the grid). Built alongside `games` in `load()`; dropped on disappear.
@State private var artLoader: LibraryArtLoader?
@State private var artLoader: (any LibraryArtSource)?
#if os(iOS) || os(macOS)
/// The plain grid's hardware-keyboard cursor (a game id), and the grid width the column count
/// is derived from. nil until the first arrow press, so a touch user never sees a selection
@@ -357,6 +357,17 @@ struct LibraryView: View {
loading = false
return
}
// Beyond the client identity, require the HOST's pinned fingerprint. MgmtTransport accepts
// ANY cert for a pin-less host (self-signed, no SAN system trust is bypassed), so browsing
// one lets a LAN MITM serve a forged catalog and harvest this device's mTLS identity. A host
// can hold a client identity yet no host pin (abandoned pairing, or after "Forget
// Identity"), so this is a distinct check. security-review 2026-08-15 finding 8.
guard current.pinnedSHA256 != nil else {
games = []
errorText = "Pair with this host before browsing its library."
loading = false
return
}
do {
// `launchersFirst` groups launcher entries ahead of titles once, here, so the grid and
// the gamepad coverflow both inherit the D4 ordering.
@@ -409,7 +420,7 @@ private struct LibraryBackCatcher: View {
/// (portrait header hero) and finally a text placeholder.
private struct GameCard: View {
let game: GameEntry
let artLoader: LibraryArtLoader?
let artLoader: (any LibraryArtSource)?
/// The hardware-keyboard cursor is on this tile drawn as an accent ring, since the plain
/// grid has no other way to say "Return launches THIS one".
var selected = false
@@ -70,7 +70,7 @@ private extension Image {
struct PosterImage: View {
let candidates: [URL]
let title: String
let loader: LibraryArtLoader?
let loader: (any LibraryArtSource)?
/// The entry's brand-mark token (`GameEntry.iconToken`), when it has one. A launcher tile ships
/// no cover art by design, so for those the mark IS the poster see `placeholder`.
var icon: String?
@@ -16,6 +16,7 @@
// can wait for layout instead of guessing with a fixed sleep.
#if DEBUG
import PunktfunkKit
import SwiftUI
#if os(macOS)
import AppKit
@@ -43,6 +44,17 @@ enum ScreenshotMode {
/// readiness ping for the capture script.
struct ScreenshotHostView: View {
let scene: ShotScene
init(scene: ShotScene) {
self.scene = scene
// Pin the palette for the capture. The aurora screens read the LIVE `uiPalette` default,
// and a reused Simulator (or a dev Mac) carries whatever was last picked there the
// Apple TV set once shipped out on a sunset palette that a test device had persisted.
// Idempotent, and only ever runs in shot mode (this view exists behind that gate).
UserDefaults.standard.set(
ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_PALETTE"] ?? "violet",
forKey: DefaultsKey.uiPalette)
}
#if os(iOS)
@Environment(\.horizontalSizeClass) private var hSizeClass
@Environment(\.verticalSizeClass) private var vSizeClass
@@ -35,6 +35,11 @@ enum ShotScenes {
ShotScene(name: "05-settings", orientation: .natural, colorScheme: .dark) {
AnyView(ShotSettings())
},
// 0610 are the iOS/macOS console-shell block below; the library is cross-platform
// (tvOS renders the same coverflow), hence the number above that range.
ShotScene(name: "11-library", orientation: .landscape, colorScheme: .dark) {
AnyView(ShotLibrary())
},
]
#if os(iOS) || os(macOS)
// The gamepad-mode console screens (no tvOS native focus engine there). Dev-only shots
@@ -68,6 +73,13 @@ enum ShotScenes {
ShotScene(name: "09f-wake-timed-out-modal", orientation: .natural, colorScheme: .dark) {
AnyView(ShotConnect(kind: .timedOut, gamepadUI: false))
},
// FEEL THE GAME the controller test panel with injected pads. Gated with the
// console block because ControllerTestView doesn't build on tvOS, not because it
// is a console screen. Landscape like the rest of the store set: the app is built
// for horizontal use, so the two pads sit as side-by-side columns (see the scene).
ShotScene(name: "12-controllers", orientation: .landscape, colorScheme: .dark) {
AnyView(ShotControllers())
},
]
#endif
scenes.append(ShotScene(name: "10-edithost", orientation: .natural, colorScheme: .dark) {
@@ -193,6 +205,29 @@ enum ShotMock {
#endif
}
/// A believable shelf for the library coverflow. Decoded rather than constructed:
/// `GameEntry`'s memberwise init is internal to PunktfunkKit, and Codable is its public
/// construction surface. The `shot://art/` posters are answered by [`ShotPosterArt.source`]
/// (drawn at capture time), so the shot stays offline; the Steam launcher entry stays artless
/// by design and renders its brand mark.
static let games: [GameEntry] = {
let json = """
[
{"id": "custom:aurora", "store": "custom", "title": "Aurora Drift",
"art": {"portrait": "shot://art/aurora"}},
{"id": "steam:starfall", "store": "steam", "title": "Starfall Vale",
"art": {"portrait": "shot://art/starfall"}},
{"id": "heroic:neon", "store": "heroic", "title": "Neon Circuit",
"art": {"portrait": "shot://art/neon"}},
{"id": "gog:ember", "store": "gog", "title": "Ember Peaks",
"art": {"portrait": "shot://art/ember"}},
{"id": "steam:launcher", "store": "steam", "title": "Steam", "art": {},
"role": "launcher", "icon": "steam"}
]
"""
return (try? JSONDecoder().decode([GameEntry].self, from: Data(json.utf8))) ?? []
}()
/// A plausible-looking 32-byte SHA-256 for the trust card / pin lock glyphs.
static let fingerprint = hostFingerprint(0)
@@ -230,6 +265,19 @@ private struct ShotHome: View {
}
}
// MARK: - Library
/// The library coverflow with the mock shelf the store listing's PICK & PLAY frame. The real
/// `LibraryCoverflowView`, no network: `ShotPosterArt` answers the mock entries' art immediately,
/// so the cards swing in already carrying posters (the entrance waits on art settling).
private struct ShotLibrary: View {
var body: some View {
LibraryCoverflowView(
games: ShotMock.games, artLoader: ShotPosterArt.source,
onLaunch: { _ in }, onDismiss: {}, controllerActive: false)
}
}
// MARK: - Gamepad-mode console screens (dev-only glass preview)
#if os(iOS) || os(macOS)
@@ -244,7 +292,8 @@ private struct ShotGamepadHome: View {
store: store, model: model, discovery: discovery,
libraryTarget: .constant(nil), pairingTarget: .constant(nil),
onPaired: { _, _ in }, waker: waker,
connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in })
connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in },
wakeOnly: { _ in })
}
}
@@ -303,12 +352,68 @@ private struct ShotConnect: View {
store: store, model: model, discovery: discovery,
libraryTarget: .constant(nil), pairingTarget: .constant(nil),
onPaired: { _, _ in }, waker: waker,
connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in })
connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in },
wakeOnly: { _ in })
} else {
ShotHome()
}
}
}
// MARK: - Controllers (the pads the store listing names)
/// The FEEL THE GAME frame: the controller test panel rendering the two pads the listing talks
/// about. A GCController cannot be constructed, so the panel draws injected `ShotPad`s the
/// DualSense leads with the feedback surface (adaptive-trigger effects, rumble backend, lightbar
/// + player LEDs), the Xbox pad carries the input readout, frozen mid-game.
private struct ShotControllers: View {
var body: some View {
#if os(macOS)
// The panel is a window-modal sheet in the app float it at sheet width over the
// dimmed host grid, the way the other mac sheet shots read.
ZStack {
ShotHome().blur(radius: 24).overlay(Color.black.opacity(0.45))
ControllerTestView(shotPads: Self.pads)
.frame(width: 500, height: 840)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(radius: 40, y: 16)
}
#else
// Landscape canvas: one column per pad, so neither story is cut by the short height
// the DualSense feedback surface left, the Xbox live-input readout right.
HStack(spacing: 0) {
ControllerTestView(shotPads: [Self.pads[0]])
ControllerTestView(shotPads: [Self.pads[1]])
}
#endif
}
/// Transport/battery/player ride in `detail` the panel has no dedicated battery row.
/// Each pad shows a different half of the panel: the DualSense skips the input card (the
/// effect grid is the marketing point), the Xbox pad skips rumble and shows the readout.
static let pads: [ControllerTestView.ShotPad] = [
.init(
name: "DualSense Wireless Controller",
detail: "Bluetooth · 85% · Player 1",
isDualSense: true, hasAdaptiveTriggers: true, hasLight: true,
rumbleBackend: "DualSense HID · Bluetooth"),
.init(
name: "Xbox Wireless Controller",
detail: "Bluetooth · 60% · Player 2",
isDualSense: false, hasAdaptiveTriggers: false, hasLight: false,
input: .init(
leftStick: .init(x: -0.31, y: 0.54),
rightStick: .init(x: 0.72, y: -0.16),
leftTrigger: 0.08, rightTrigger: 0.62,
buttons: [
("A", true), ("B", false), ("X", false), ("Y", false),
("LB", false), ("RB", true), ("L3", false), ("R3", false),
("Menu", false), ("Opts", false),
("", false), ("", false), ("", false), ("", false),
])),
]
}
#endif
// MARK: - Edit host (add/edit sheet with the Wake-on-LAN MAC field)
@@ -0,0 +1,264 @@
// Procedural cover art for the screenshot shelf. The store's library frames used to render the
// deterministic text-placeholder posters (`artLoader: nil`), which read as an empty library next
// to the Android listing's populated one. These four posters are drawn with CoreGraphics at
// capture time no bundled assets, nothing in a release build, and the same designs the Android
// harness draws in Canvas, so the two listings show the same shelf.
#if DEBUG
import CoreText
import Foundation
import ImageIO
import PunktfunkKit
import UniformTypeIdentifiers
/// A canned `LibraryArtSource`: poster bytes by URL, no network. What the screenshot shelf hands
/// the real coverflow in place of the paired-host loader.
struct ShotArtSource: LibraryArtSource {
let fixtures: [String: Data]
func data(for url: URL) async throws -> Data {
guard let data = fixtures[url.absoluteString] else {
throw CocoaError(.fileNoSuchFile)
}
return data
}
func close() async {}
}
enum ShotPosterArt {
/// Art for `ShotMock.games` keyed by the `shot://art/` URLs those entries carry.
static let source = ShotArtSource(fixtures: [
"shot://art/aurora": poster("AURORA DRIFT", draw: drawAurora),
"shot://art/starfall": poster("STARFALL VALE", draw: drawStarfall),
"shot://art/neon": poster("NEON CIRCUIT", draw: drawNeon),
"shot://art/ember": poster("EMBER PEAKS", draw: drawEmber),
])
private static let W = 600
private static let H = 900
// MARK: - Canvas plumbing
private static func poster(_ title: String, draw: (CGContext) -> Void) -> Data {
let space = CGColorSpace(name: CGColorSpace.sRGB)!
let ctx = CGContext(
data: nil, width: W, height: H, bitsPerComponent: 8, bytesPerRow: 0,
space: space, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!
draw(ctx)
drawTitle(ctx, title)
let image = ctx.makeImage()!
let out = NSMutableData()
let dest = CGImageDestinationCreateWithData(
out, UTType.png.identifier as CFString, 1, nil)!
CGImageDestinationAddImage(dest, image, nil)
CGImageDestinationFinalize(dest)
return out as Data
}
private static func rgb(_ hex: UInt32, _ alpha: CGFloat = 1) -> CGColor {
CGColor(
srgbRed: CGFloat((hex >> 16) & 0xff) / 255,
green: CGFloat((hex >> 8) & 0xff) / 255,
blue: CGFloat(hex & 0xff) / 255, alpha: alpha)
}
/// Vertical gradient over the full canvas; `stops` bottom-to-top as (location, color).
private static func sky(_ ctx: CGContext, _ stops: [(CGFloat, CGColor)]) {
let gradient = CGGradient(
colorsSpace: CGColorSpace(name: CGColorSpace.sRGB)!,
colors: stops.map(\.1) as CFArray,
locations: stops.map(\.0))!
ctx.drawLinearGradient(
gradient, start: .zero, end: CGPoint(x: 0, y: CGFloat(H)), options: [])
}
private static func glowDot(
_ ctx: CGContext, at center: CGPoint, radius: CGFloat, color: CGColor
) {
let clear = color.copy(alpha: 0)!
let gradient = CGGradient(
colorsSpace: CGColorSpace(name: CGColorSpace.sRGB)!,
colors: [color, clear] as CFArray, locations: [0, 1])!
ctx.drawRadialGradient(
gradient, startCenter: center, startRadius: 0,
endCenter: center, endRadius: radius, options: [])
}
/// Stroke `path` three times, wide-and-faint to thin-and-bright, in screen blend the cheap
/// neon-glow trick every one of these posters leans on.
private static func glowStroke(
_ ctx: CGContext, _ path: CGPath, width: CGFloat, color: CGColor
) {
ctx.saveGState()
ctx.setBlendMode(.screen)
ctx.setLineCap(.round)
ctx.setLineJoin(.round)
for (mult, alpha) in [(2.6, 0.12), (1.3, 0.28), (0.55, 0.85)] {
ctx.addPath(path)
ctx.setLineWidth(width * mult)
ctx.setStrokeColor(color.copy(alpha: alpha)!)
ctx.strokePath()
}
ctx.restoreGState()
}
private static func drawTitle(_ ctx: CGContext, _ title: String) {
// A soft floor behind the caption keeps it legible over any art.
sky(ctx, [(0, rgb(0x000000, 0.55)), (0.22, rgb(0x000000, 0))])
let font = CTFontCreateWithName("HelveticaNeue-CondensedBold" as CFString, 46, nil)
let text = NSAttributedString(string: title, attributes: [
.font: font, .kern: 5, .foregroundColor: rgb(0xFFFFFF, 0.94),
] as [NSAttributedString.Key: Any])
let line = CTLineCreateWithAttributedString(text)
let bounds = CTLineGetBoundsWithOptions(line, [])
ctx.saveGState()
ctx.setShadow(offset: CGSize(width: 0, height: -2), blur: 8, color: rgb(0x000000, 0.6))
ctx.textPosition = CGPoint(x: (CGFloat(W) - bounds.width) / 2, y: 72)
CTLineDraw(line, ctx)
ctx.restoreGState()
}
/// Deterministic LCG so every capture draws the identical poster.
private struct Rand {
var state: UInt64
mutating func next() -> CGFloat {
state = state &* 6364136223846793005 &+ 1442695040888963407
return CGFloat(state >> 33) / CGFloat(UInt64(1) << 31)
}
mutating func in_(_ lo: CGFloat, _ hi: CGFloat) -> CGFloat { lo + next() * (hi - lo) }
}
// MARK: - The four posters
private static func drawAurora(_ ctx: CGContext) {
sky(ctx, [(0, rgb(0x221E5C)), (0.45, rgb(0x141040)), (1, rgb(0x0B0830))])
var rng = Rand(state: 11)
for _ in 0..<48 {
let p = CGPoint(x: rng.in_(0, 600), y: rng.in_(300, 890))
glowDot(ctx, at: p, radius: rng.in_(1.4, 3.2), color: rgb(0xFFFFFF, rng.in_(0.25, 0.8)))
}
let ribbons: [(base: CGFloat, amp: CGFloat, freq: CGFloat, phase: CGFloat, w: CGFloat, c: UInt32)] = [
(700, 55, 1.15, 0.4, 30, 0x6656F2),
(615, 70, 1.4, 2.2, 24, 0x8F7BFF),
(530, 45, 0.95, 4.1, 18, 0x35D0C5),
]
for r in ribbons {
let path = CGMutablePath()
for i in 0...60 {
let t = CGFloat(i) / 60
let p = CGPoint(
x: t * 600,
y: r.base + r.amp * sin(t * .pi * r.freq + r.phase) + 40 * t)
if i == 0 { path.move(to: p) } else { path.addLine(to: p) }
}
glowStroke(ctx, path, width: r.w, color: rgb(r.c))
}
// A low ridge grounds the scene without it the poster's bottom half is bare sky.
for (fill, baseline, rough) in [
(rgb(0x191345), CGFloat(212), CGFloat(30)),
(rgb(0x0E0A2E), CGFloat(148), CGFloat(38)),
] {
let path = CGMutablePath()
path.move(to: CGPoint(x: 0, y: 0))
path.addLine(to: CGPoint(x: 0, y: baseline + rng.in_(-rough, rough)))
for i in 1...9 {
let x = CGFloat(i) / 9 * 600
path.addLine(to: CGPoint(x: x, y: baseline + rng.in_(-rough, rough)))
}
path.addLine(to: CGPoint(x: 600, y: 0))
path.closeSubpath()
ctx.setFillColor(fill)
ctx.addPath(path)
ctx.fillPath()
}
}
private static func drawStarfall(_ ctx: CGContext) {
sky(ctx, [(0, rgb(0x2A0C24)), (0.35, rgb(0x7A2B58)), (0.8, rgb(0xE86FA8)), (1, rgb(0xF7A8C8))])
var rng = Rand(state: 23)
for _ in 0..<6 {
let head = CGPoint(x: rng.in_(60, 560), y: rng.in_(420, 840))
let len = rng.in_(90, 170)
let dir = CGVector(dx: cos(2.15), dy: sin(2.15)) // ~123° up-left tails
let path = CGMutablePath()
path.move(to: head)
path.addLine(to: CGPoint(x: head.x + dir.dx * len, y: head.y + dir.dy * len))
glowStroke(ctx, path, width: 4, color: rgb(0xFFE3EF))
glowDot(ctx, at: head, radius: 11, color: rgb(0xFFFFFF, 0.9))
}
for (fill, baseline, rough) in [
(rgb(0x3A1430), CGFloat(300), CGFloat(26)),
(rgb(0x1D0818), CGFloat(216), CGFloat(34)),
] {
let path = CGMutablePath()
path.move(to: CGPoint(x: 0, y: 0))
path.addLine(to: CGPoint(x: 0, y: baseline))
for i in 1...8 {
let x = CGFloat(i) / 8 * 600
path.addLine(to: CGPoint(x: x, y: baseline + rng.in_(-rough, rough)))
}
path.addLine(to: CGPoint(x: 600, y: 0))
path.closeSubpath()
ctx.setFillColor(fill)
ctx.addPath(path)
ctx.fillPath()
}
}
private static func drawNeon(_ ctx: CGContext) {
sky(ctx, [(0, rgb(0x0A2A33)), (1, rgb(0x04161C))])
var rng = Rand(state: 7)
let ring = CGPath(
ellipseIn: CGRect(x: 300 - 105, y: 560 - 105, width: 210, height: 210), transform: nil)
glowStroke(ctx, ring, width: 10, color: rgb(0x35D0C5))
for i in 0..<9 {
// Right-angle traces on a 40 px grid, some feeding out of the ring's four gates.
var p = i < 4
? CGPoint(x: 300 + [-105, 105, 0, 0][i], y: 560 + [0, 0, -105, 105][i])
: CGPoint(x: 40 * (rng.in_(1, 14)).rounded(), y: 40 * (rng.in_(1, 21)).rounded())
let path = CGMutablePath()
path.move(to: p)
var horizontal = rng.next() > 0.5
for _ in 0..<Int(rng.in_(3, 6)) {
let step = 40 * rng.in_(1, 4).rounded() * (rng.next() > 0.5 ? 1 : -1)
p = horizontal ? CGPoint(x: min(max(p.x + step, 20), 580), y: p.y)
: CGPoint(x: p.x, y: min(max(p.y + step, 20), 880))
path.addLine(to: p)
horizontal.toggle()
}
let color = rng.next() > 0.6 ? rgb(0x7FE8DE) : rgb(0x35D0C5)
glowStroke(ctx, path, width: 5, color: color)
glowDot(ctx, at: p, radius: 12, color: color.copy(alpha: 0.9)!)
}
}
private static func drawEmber(_ ctx: CGContext) {
sky(ctx, [(0, rgb(0x200A04)), (0.3, rgb(0x7A2E12)), (0.42, rgb(0xEF8F4B)), (1, rgb(0x2A0E06))])
glowDot(ctx, at: CGPoint(x: 300, y: 385), radius: 160, color: rgb(0xFFC37A, 0.85))
var rng = Rand(state: 41)
for (fill, baseline, rough) in [
(rgb(0x5A2410), CGFloat(340), CGFloat(42)),
(rgb(0x401708), CGFloat(255), CGFloat(56)),
(rgb(0x200A04), CGFloat(165), CGFloat(48)),
] {
let path = CGMutablePath()
path.move(to: CGPoint(x: 0, y: 0))
path.addLine(to: CGPoint(x: 0, y: baseline + rng.in_(-rough, rough)))
for i in 1...10 {
let x = CGFloat(i) / 10 * 600
path.addLine(to: CGPoint(x: x, y: baseline + rng.in_(-rough, rough)))
}
path.addLine(to: CGPoint(x: 600, y: 0))
path.closeSubpath()
ctx.setFillColor(fill)
ctx.addPath(path)
ctx.fillPath()
}
for _ in 0..<20 {
let p = CGPoint(x: rng.in_(30, 570), y: rng.in_(180, 620))
glowDot(ctx, at: p, radius: rng.in_(2.5, 6), color: rgb(0xFFB067, rng.in_(0.35, 0.9)))
}
}
}
#endif
@@ -250,18 +250,39 @@ final class SessionModel: ObservableObject {
private var audio: SessionAudio?
private var gamepadCapture: GamepadCapture?
private var gamepadFeedback: GamepadFeedback?
#if os(macOS)
#if !os(tvOS)
/// The live session's clipboard bridge (design/clipboard-and-file-transfer.md §5) created
/// by `beginStreaming` when the per-host toggle is on and the host advertises
/// `HOST_CAP_CLIPBOARD`; stopped (off-main, drain joined) in `disconnect`.
private var clipboardSync: ClipboardSync?
#endif
/// Whether clipboard sync is live (host-acked `ClipState.enabled`) drives the Stream menu
/// item's title and the settings footnote. Always false off-macOS.
/// item's title and the settings footnote. Always false on tvOS, which has no pasteboard.
@Published private(set) var clipboardEnabled = false
/// The host's last `ClipState.reason` (`CLIP_REASON_*`) why an enable was refused
/// (backend unavailable / policy disabled / ); 0 = OK.
@Published private(set) var clipboardReason: UInt8 = 0
// MARK: - Per-client access (design/per-client-access.md §7)
/// The session's access preset, derived live from the grants mask (§3.2 the label is
/// never stored). `.fullControl` against every old host and for every full-grant device,
/// so nothing below changes today's look there.
@Published private(set) var accessLevel: PunktfunkConnection.AccessLevel = .fullControl
/// Seconds until this session's access expires; `0` = permanent. Ticks down at the 1 Hz
/// stats cadence the chip's countdown renders straight from it.
@Published private(set) var accessRemainingSecs: UInt32 = 0
/// Anything about this session's access differs from full-and-permanent the visibility
/// gate for the chip (and the tvOS stats-overlay line). False = today's look, untouched.
@Published private(set) var accessLimited = false
/// The transient expiry-warning toast ("Access ends in 5 m") non-nil for a few seconds
/// around the T5 m / T1 m marks the host also warns at via `AccessUpdate`.
@Published private(set) var accessWarning: String?
/// One-shot latches for the two warning marks (reset per session).
private var accessWarned5m = false
private var accessWarned1m = false
/// Auto-dismiss for `accessWarning` held so a newer warning replaces a pending clear.
private var accessWarningTimer: Task<Void, Never>?
#if os(tvOS)
/// Siri Remote host pointer while streaming (touch surface moves, press = left click,
/// Play/Pause = right click) + the remote's deliberate exit (hold Back 1 s). See
@@ -566,7 +587,9 @@ final class SessionModel: ObservableObject {
#if os(tvOS)
return false // no app-accessible microphone SessionAudio never opens an uplink either
#else
guard settings.micEnabled else { return false }
// The session's grants must include MIC (per-client access §7 hide the mic UI when
// ungranted; a mute button over a mic the host drops would be a lie twice over).
guard settings.micEnabled, connection?.canUseMic != false else { return false }
switch AVCaptureDevice.authorizationStatus(for: .audio) {
case .authorized, .notDetermined: return true
default: return false // denied / restricted there is no uplink to mute
@@ -613,6 +636,71 @@ final class SessionModel: ObservableObject {
audio?.setMicMuted(micMuted || isBackgrounded)
}
// MARK: - Per-client access (chip state + expiry warnings)
/// Refresh the published access state from the connection's LIVE grants + countdown
/// called by the 1 Hz stats tick, which is also what makes a mid-session `AccessUpdate`
/// (a console edit) reach the chip and the capture gates within a second. The equality
/// guards keep a full-and-permanent session (every old host) from publishing anything.
private func updateAccessState() {
guard let conn = connection else { return }
let grants = conn.accessGrants
let level = PunktfunkConnection.AccessLevel(grants: grants)
let remaining = conn.accessExpiresInSeconds
if accessLevel != level { accessLevel = level }
if accessRemainingSecs != remaining { accessRemainingSecs = remaining }
let limited = level != .fullControl || remaining != 0
if accessLimited != limited { accessLimited = limited }
// A mid-session edit that removed BOTH input classes releases an engaged capture:
// holding a frozen cursor and swallowed keys over input the host now drops is
// exactly the "keyboard does nothing and nobody says why" failure §7 exists to
// prevent. (Engage is gated at the stream views; this is the live-revoke half.)
if mouseCaptured,
grants & (PunktfunkConnection.grantPointer | PunktfunkConnection.grantKeyboard) == 0 {
NotificationCenter.default.post(name: .punktfunkReleaseCapture, object: nil)
}
// The T5 m / T1 m warning toasts (§7). Derived from the countdown CROSSING the
// marks rather than from the AccessUpdate messages alone: the host's warnings
// re-anchor the same countdown, so this shows them when they arrive AND still fires
// on plain clock progress if a warning datagram never lands. One shot each; an edit
// that extends the deadline back above a mark re-arms it.
guard remaining != 0 else { return }
if remaining > 300 {
accessWarned5m = false
accessWarned1m = false
} else if remaining > 60 {
accessWarned1m = false
if !accessWarned5m {
accessWarned5m = true
showAccessWarning("Access ends in \(Self.accessCountdown(remaining))")
}
} else if !accessWarned1m {
accessWarned1m = true
accessWarned5m = true
showAccessWarning("Access ends in under a minute")
}
}
/// Put one warning toast up for a few seconds (the motion hint's pattern: last one wins,
/// its timer restarts, teardown cancels a pending clear).
private func showAccessWarning(_ text: String) {
accessWarning = text
accessWarningTimer?.cancel()
accessWarningTimer = Task { [weak self] in
try? await Task.sleep(for: .seconds(Self.motionHintSeconds))
guard !Task.isCancelled else { return }
self?.accessWarning = nil
}
}
/// "1 h 58 m" / "12 m" / "45 s" the countdown wording the chip and the warnings share.
static func accessCountdown(_ secs: UInt32) -> String {
let s = Int(secs)
if s >= 3600 { return "\(s / 3600) h \((s % 3600) / 60) m" }
if s >= 60 { return "\(s / 60) m" }
return "\(s) s"
}
/// Follow a live stats-overlay cycle (S, the three-finger tap, the Stream menu). Those
/// surfaces write the GLOBAL setting as they always have; this moves the session's own tier
/// with it, so cycling still works in a session a profile put on a different tier.
@@ -658,6 +746,16 @@ final class SessionModel: ObservableObject {
motionHintTimer?.cancel()
motionHintTimer = nil
motionUnreachableKind = nil
// Access state is per-session: back to the invisible full-and-permanent default, and
// no warning latch may carry into the next stream (same discipline as the mic mute).
accessWarningTimer?.cancel()
accessWarningTimer = nil
accessWarning = nil
accessLevel = .fullControl
accessRemainingSecs = 0
accessLimited = false
accessWarned5m = false
accessWarned1m = false
let audio = self.audio
self.audio = nil
// Gamepad capture is main-actor (releases held buttons on the wire while the
@@ -670,7 +768,7 @@ final class SessionModel: ObservableObject {
#endif
let feedback = gamepadFeedback
gamepadFeedback = nil
#if os(macOS)
#if !os(tvOS)
let clipboard = clipboardSync
clipboardSync = nil
#endif
@@ -683,8 +781,11 @@ final class SessionModel: ObservableObject {
Task.detached {
audio?.stop()
feedback?.stop()
#if os(macOS)
clipboard?.stop() // disables sync on the wire while the connection is still up
#if !os(tvOS)
// Disables sync on the wire while the connection is still up and on iOS pulls a
// host offer the user has not pasted yet down to real bytes, which needs that
// connection, so it must stay ahead of the close below.
clipboard?.stop()
#endif
// Deliberate user quit tell the host to skip the keep-alive linger (must precede close).
if deliberate { conn.disconnectQuit() }
@@ -694,7 +795,7 @@ final class SessionModel: ObservableObject {
Task.detached {
audio?.stop()
feedback?.stop()
#if os(macOS)
#if !os(tvOS)
clipboard?.stop()
#endif
}
@@ -732,6 +833,10 @@ final class SessionModel: ObservableObject {
let name = activeHost?.displayName ?? "host"
// WHY it ended, asked while the connection is still up `disconnect` tears it down.
let reason = conn.sessionEndReason
// A typed mid-session rejection outranks the coarse reason: an access-expiry close
// (per-client access §4) files under `.hostError` there, and "ended with an error"
// is the wrong sentence for "your access expired".
let rejection = conn.endRejection
// Where a game exit sends us: back into the library this title was launched from, so the
// next one is a tap away. Only for a launch that CAME from the library a game exiting in
// a plain desktop session has no library to return to.
@@ -741,6 +846,11 @@ final class SessionModel: ObservableObject {
// without naming one, which is what that launch effectively browsed.
let shelf = launchedShelf ?? activeHost.map { LibraryTarget(host: $0) }
disconnect(deliberate: false) // host/network ended it keep the linger for a reconnect
if let rejection {
// The shared typed-rejection wording ("Your access to this host has expired").
errorMessage = "\(name): \(rejection.userMessage)"
return
}
switch reason {
case .gameExited:
// The player quit their own game. Not a failure, and they are probably after the next
@@ -795,7 +905,9 @@ final class SessionModel: ObservableObject {
speakerUID: settings.speakerUID,
micUID: settings.micUID,
micChannel: settings.micChannel,
micEnabled: settings.micEnabled,
// Deny-at-setup for an ungranted mic (per-client access §5): no MIC bit, no
// uplink at all a capture the host would only drop is pure privacy downside.
micEnabled: settings.micEnabled && conn.canUseMic,
echoCancel: settings.echoCancel,
// The A/V sync reference: `endToEnd` is captureon-glass, the one figure that says
// where the picture actually IS, and the audio ring steers its depth to land with it.
@@ -831,11 +943,13 @@ final class SessionModel: ObservableObject {
let feedback = GamepadFeedback(connection: conn, manager: .shared)
feedback.start()
gamepadFeedback = feedback
#if os(macOS)
#if !os(tvOS)
// Shared clipboard: opt-in per host AND host-advertised (older hosts / operator-disabled
// hosts never see a ClipControl). Same trust gate as audio nothing is announced
// hosts never see a ClipControl) AND granted to this device (per-client access §5
// without the bit the host would refuse with CLIP_REASON_NOT_PERMITTED anyway; not
// asking keeps the UI honest). Same trust gate as audio nothing is announced
// during the trust prompt.
if activeHost?.clipboardSync == true, conn.hostSupportsClipboard {
if activeHost?.clipboardSync == true, conn.hostSupportsClipboard, conn.canUseClipboard {
startClipboardSync(conn)
}
#endif
@@ -847,7 +961,7 @@ final class SessionModel: ObservableObject {
#endif
}
#if os(macOS)
#if !os(tvOS)
/// Create + start the session's clipboard bridge and route its host acks into the published
/// UI state. `ClipboardSync.start()` sends the enable; the host's `.state` answer flips
/// `clipboardEnabled` (or leaves it false with a `clipboardReason` the UI can explain).
@@ -866,16 +980,16 @@ final class SessionModel: ObservableObject {
/// Flip clipboard sync mid-session (the Stream menu). Off on requires the host cap; on
/// off tears the bridge down (off-main the drain join must not block the main actor) and
/// tells the host, which drops any selection we own there. No-op off-macOS or while idle.
/// tells the host, which drops any selection we own there. No-op on tvOS or while idle.
func toggleClipboardSync() {
#if os(macOS)
#if !os(tvOS)
guard let conn = connection, phase == .streaming else { return }
if let sync = clipboardSync {
clipboardSync = nil
clipboardEnabled = false
clipboardReason = 0
Task.detached { sync.stop() }
} else if conn.hostSupportsClipboard {
} else if conn.hostSupportsClipboard, conn.canUseClipboard {
startClipboardSync(conn)
}
#endif
@@ -892,6 +1006,9 @@ final class SessionModel: ObservableObject {
// success; this only fires after the timeout.
self.resizeIndicator.tick(now: Date().timeIntervalSinceReferenceDate)
self.resizing = self.resizeIndicator.active
// Access chip + expiry warnings: the same tick that drives every other live
// readout also walks the countdown and picks up mid-session grant edits.
self.updateAccessState()
let (frames, bytes, total) = self.meter.drain()
self.fps = frames
self.mbps = Double(bytes) * 8 / 1_000_000
@@ -22,8 +22,7 @@ import SwiftUI
/// `.focusedSceneValue` so the Scene-level commands can drive it.
struct SessionFocus {
var isStreaming: Bool
/// The connected host advertises `HOST_CAP_CLIPBOARD` (gates the Share Clipboard item
/// macOS-only UI, but the fact is platform-neutral).
/// The connected host advertises `HOST_CAP_CLIPBOARD` (gates the Share Clipboard item).
var clipboardAvailable: Bool
/// Clipboard sync is live (host-acked) drives the item's Stop/Share title.
var clipboardOn: Bool
@@ -78,14 +77,15 @@ struct StreamCommands: Commands {
}
.keyboardShortcut("a", modifiers: [.control, .option, .shift])
.disabled(session?.isStreaming != true || session?.micAvailable != true)
#if os(macOS)
// Mid-session clipboard flip (design/clipboard-and-file-transfer.md §5.3). Greyed
// when the host doesn't advertise the cap (older host / operator policy off).
// when the host doesn't advertise the cap (older host / operator policy off). On iPad
// there is no menu bar to show it in, but a hardware keyboard still reaches it.
Button(session?.clipboardOn == true ? "Stop Sharing Clipboard" : "Share Clipboard") {
session?.toggleClipboard()
}
.keyboardShortcut("c", modifiers: [.control, .option, .shift])
.disabled(session?.isStreaming != true || session?.clipboardAvailable != true)
#if os(macOS)
// Toggle the window's fullscreen. F is the macOS-standard fullscreen combo; here it's
// explicit so it's discoverable AND survives capture while streaming the stream view
// swallows keys, so InputCapture's monitor detects the same combo and posts the same
@@ -96,6 +96,21 @@ struct StreamHUDView: View {
.lineLimit(1)
}
}
#if os(tvOS)
// The session's access level (per-client access §7). tvOS carries it HERE, as a
// stats-overlay line, instead of the floating chip the pointer platforms wear a
// couch surface where every extra overlay competes with the picture keeps the
// fact with the other session facts. Absent for full-and-permanent sessions
// (every old host): today's overlay must not change there.
if model.accessLimited {
Text(model.accessRemainingSecs == 0
? "access \(model.accessLevel.label.lowercased())"
: "access \(model.accessLevel.label.lowercased()) · ends in "
+ SessionModel.accessCountdown(model.accessRemainingSecs))
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.secondary)
}
#endif
if model.endToEndValid {
// Stage-2: the end-to-end headline (captureon-glass, measured directly, skew-
// corrected) "(same-host clock)" when the host didn't answer the skew
@@ -210,15 +225,20 @@ struct StreamHUDView: View {
// Capture hint, shown only until input is captured how to grab it. The RELEASE
// shortcut is intentionally not surfaced in the overlay (it lives on the Stream menu
// and, on macOS, the start-of-stream banner), keeping the HUD uncluttered while playing.
// Both hints are additionally gated on the session's grants ALLOWING a capture
// (per-client access §7): inviting a Controller-only or View-only session to
// "capture input" the host would only drop is the lie the grants advert exists
// to prevent. Read live off the connection a re-render lands with the model's
// access churn.
#if os(macOS)
if !model.mouseCaptured {
if !model.mouseCaptured, connection.canSendPointer || connection.canSendKeyboard {
Text("Click the stream to capture input")
.font(.geist(11, relativeTo: .caption2))
.foregroundStyle(.secondary)
}
#elseif os(iOS)
// Touch always plays directly; (hardware keyboard) captures kb/mouse.
if !model.mouseCaptured {
if !model.mouseCaptured, connection.canSendPointer || connection.canSendKeyboard {
Text("⌘⎋ captures keyboard & mouse")
.font(.geist(11, relativeTo: .caption2))
.foregroundStyle(.secondary)
@@ -361,6 +381,68 @@ struct MotionUnreachableBadge: View {
}
}
#if !os(tvOS)
/// The session's access chip (per-client access §7) "Controller only · ends in 1 h 58 m".
/// Rides over the stream for the life of a LIMITED session, at every stats tier and with the
/// overlay off entirely, in the badges' glass language: what this session may do (and for how
/// long) is not a statistic, and a guest whose keyboard does nothing deserves the why on
/// screen. Never mounted for full-and-permanent sessions today's look does not change.
/// (tvOS states the same fact as a stats-overlay line instead a chip would fight the couch
/// UI's single-focus rule.)
struct AccessChipBadge: View {
let label: String
/// Seconds until access expires; `0` = permanent (the chip then shows the level alone).
let remainingSecs: UInt32
var body: some View {
HStack(spacing: 7) {
Image(systemName: "lock.fill")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.white.opacity(0.75))
Text(remainingSecs == 0
? label
: "\(label) · ends in \(SessionModel.accessCountdown(remainingSecs))")
.font(.geist(12, .medium, relativeTo: .caption))
.foregroundStyle(.white.opacity(0.9))
}
.padding(.horizontal, 14)
.padding(.vertical, 8)
.glassBackground(Capsule())
.environment(\.colorScheme, .dark) // reads over any frame, like the resize overlay
.accessibilityElement(children: .combine)
.accessibilityLabel(
remainingSecs == 0
? "Access level: \(label)"
: "Access level: \(label), ends in \(SessionModel.accessCountdown(remainingSecs))")
}
}
#endif
/// The expiry-warning toast (per-client access §7): the host's T5 m / T1 m `AccessUpdate`
/// warnings, surfaced briefly in the badge stack every platform, tvOS included (unlike the
/// chip, a warning is worth a moment of couch overlay; it is how "the pad just died" becomes
/// "the evening's access ended, ask for more").
struct AccessWarningBadge: View {
let text: String
var body: some View {
HStack(spacing: 7) {
Image(systemName: "clock.badge.exclamationmark")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.yellow)
Text(text)
.font(.geist(12, .medium, relativeTo: .caption))
.foregroundStyle(.white.opacity(0.9))
}
.padding(.horizontal, 14)
.padding(.vertical, 8)
.glassBackground(Capsule())
.environment(\.colorScheme, .dark) // reads over any frame, like the resize overlay
.accessibilityElement(children: .combine)
.accessibilityLabel(text)
}
}
#if !os(tvOS)
/// The muted-microphone badge the mute STATE, as opposed to the buttons that flip it. It rides
/// over the stream whenever the mic is muted, INDEPENDENT of the stats overlay (which the user
@@ -28,6 +28,10 @@ struct AboutView: View {
#if !os(tvOS)
@State private var showAcknowledgements = false
/// The in-session controls. They used to announce themselves in a 6-second banner at the start
/// of every stream; that banner is gone, so this page is where they live now including for
/// touch users on a Mac, who saw it too.
@State private var showShortcuts = false
#endif
var body: some View {
@@ -44,6 +48,9 @@ struct AboutView: View {
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
}
Section {
shortcutsRow
}
Section {
linkRow("Documentation", systemImage: "book", url: Destination.docs)
linkRow("Community", systemImage: "bubble.left.and.bubble.right",
@@ -63,6 +70,21 @@ struct AboutView: View {
// A SHEET, not a push on iPad the settings detail column is deliberately not a
// NavigationStack (an inner one doubles the title bar), so a NavigationLink from here
// pushed into a context with no back button and stranded the licenses on screen.
// A sheet for the same reason Acknowledgements is one see that modifier's note on the
// iPad detail column not being a NavigationStack.
.sheet(isPresented: $showShortcuts) {
NavigationStack {
ShortcutsView(micAvailable: ShortcutsCatalog.micPlausible)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { showShortcuts = false }
}
}
}
#if os(macOS)
.frame(width: 560, height: 460)
#endif
}
.sheet(isPresented: $showAcknowledgements) {
NavigationStack {
AcknowledgementsView()
@@ -135,6 +157,24 @@ struct AboutView: View {
.foregroundStyle(.primary)
}
private var shortcutsRow: some View {
Button {
showShortcuts = true
} label: {
HStack {
Label("Shortcuts", systemImage: "command")
Spacer(minLength: 8)
Image(systemName: "chevron.right")
.font(.footnote.weight(.semibold))
.foregroundStyle(.tertiary)
.accessibilityHidden(true)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.foregroundStyle(.primary)
}
private var acknowledgementsRow: some View {
Button {
showAcknowledgements = true
@@ -168,6 +208,11 @@ struct AboutView: View {
tvAddress("Community", Destination.community)
tvAddress("Source code", Destination.source)
}
// Both push here: this page really is inside a navigation stack on tvOS, which is
// the case the sheets above exist to work around elsewhere.
NavigationLink("Shortcuts") {
ShortcutsView(micAvailable: false) // tvOS has no app-accessible mic
}
NavigationLink("Acknowledgements") { AcknowledgementsView() }
Text("Punktfunk's source is open under MIT or Apache-2.0.")
.font(.geist(20, relativeTo: .caption))
@@ -219,21 +264,39 @@ struct AppIconView: View {
var body: some View {
Group {
if let icon = Self.bundleIcon {
icon.image
.resizable()
.interpolation(.high)
.aspectRatio(contentMode: .fit)
// iOS ships the icon UNMASKED the springboard applies the rounded shape at
// draw time, so used raw it is a hard-cornered square. macOS bakes its own
// shape (and margins) into the image, and clipping that would cut into it.
.clipShape(RoundedRectangle(
cornerRadius: icon.needsMask ? side * Self.iOSCornerRatio : 0,
style: .continuous))
// The mask is applied ONLY where it is wanted. A `cornerRadius: 0` RoundedRectangle
// is not a no-op it still clips to the layout frame, which crops any art whose
// aspect ratio isn't the frame's (the TV's 400x240 icon lost its ends to it).
// iOS ships the icon UNMASKED the springboard applies the rounded shape at draw
// time, so used raw it is a hard-cornered square. macOS bakes its own shape (and
// margins) into the image, and clipping that would cut into it.
if icon.needsMask {
icon.image
.resizable()
.interpolation(.high)
.aspectRatio(contentMode: .fit)
.clipShape(RoundedRectangle(
cornerRadius: side * Self.iOSCornerRatio, style: .continuous))
} else {
icon.image
.resizable()
.interpolation(.high)
.aspectRatio(contentMode: .fit)
}
} else {
monogram
}
}
// tvOS's icon is a 400×240 rectangle, not a squircle framing it square would letterbox
// it inside a box two thirds empty. `side` means HEIGHT there, and the width follows the
// real 5:3 art. A MAX frame rather than a fixed one: with a fixed width the image cannot
// shrink when its row is tight, so it overflows and is clipped by whatever is above it
// instead `.fit` inside a max frame gives back the whole icon, just smaller.
#if os(tvOS)
.frame(maxWidth: side * (400.0 / 240.0), maxHeight: side)
#else
.frame(width: side, height: side)
#endif
.accessibilityHidden(true) // the app's name is the next line
}
@@ -267,7 +330,14 @@ struct AppIconView: View {
else { return nil }
return (Image(uiImage: image), true)
#else
return nil // tvOS: layered icons have no single image to load
// tvOS ships the icon as a parallax image STACK (Back/Circle1/Circle2/Front), which has
// no single image to load which is why this used to return nil and every About page on
// the TV drew the "P" monogram instead of the app's own mark. `AboutAppIcon` is those
// four layers flattened into one asset, generated from the SAME art the stack uses so it
// cannot drift into being a second, subtly different icon. Already masked and composited,
// so it needs no rounding of ours.
guard let image = UIImage(named: "AboutAppIcon") else { return nil }
return (Image(uiImage: image), false)
#endif
}
}
@@ -4,6 +4,11 @@
// physical pad (no host needed), so the rendering paths a session uses can be confirmed
// on-device. Driven by PunktfunkKit's `ControllerTester`, which reuses the real renderers.
//
// Every card renders a plain value model (`ShotPad` / `InputSnapshot`) that the live path samples
// out of the real pad each timeline tick. A GCController cannot be constructed, and the App Store
// screenshot harness needs this panel with pads the capture machine doesn't have ShotScenes
// injects them via `shotPads` (the same seam Android's ControllersScreen grew for its capture).
//
// tvOS is excluded for now (it has no segmented picker / the panel wants a pointer-style
// layout); macOS + iOS/iPadOS cover the validation need.
@@ -14,10 +19,63 @@ import SwiftUI
@MainActor
struct ControllerTestView: View {
/// What one panel section says about a pad, as plain values. The live path flattens the
/// active `DiscoveredController` into one; the screenshot harness hands the panel pads that
/// were never connected. `input`/`rumbleBackend` are the harness's section knobs (nil hides
/// that card) the live path always shows both, fed from the live pad and tester.
struct ShotPad: Identifiable {
let name: String
/// The header's second line. Production shows the GC product category; a shot packs
/// transport/battery/player facts into it (the panel has no dedicated battery row).
let detail: String
let isDualSense: Bool
let hasAdaptiveTriggers: Bool
let hasLight: Bool
var input: InputSnapshot? = nil
var rumbleBackend: String? = nil
var id: String { name }
}
/// One frame of the input readout. The live path samples the real `GCExtendedGamepad` into
/// one of these on every 30 Hz tick; the harness writes a mid-game frame by hand.
struct InputSnapshot {
struct Stick {
var x: Float
var y: Float
var pressed = false
}
struct Touch {
/// Finger position in GC's -1...1 axes; nil = lifted. (GC snaps a lifted finger to
/// exactly (0, 0), so a real (0, 0) contact is indistinguishable anyway.)
var primary: CGPoint?
var secondary: CGPoint?
var clicked = false
}
struct Motion {
var gyro: SIMD3<Double>
var accel: SIMD3<Double>
}
var leftStick: Stick
var rightStick: Stick
var leftTrigger: Float = 0
var rightTrigger: Float = 0
/// Grid order; label pressed.
var buttons: [(String, Bool)]
var touchpad: Touch?
var motion: Motion?
}
@Environment(\.dismiss) private var dismiss
@ObservedObject private var gamepads = GamepadManager.shared
@StateObject private var tester = ControllerTester()
/// Screenshot-harness injection nil (the app) renders the live active pad.
private let shotPads: [ShotPad]?
init(shotPads: [ShotPad]? = nil) {
self.shotPads = shotPads
}
@State private var heavyOn = false
@State private var lightOn = false
@State private var intensity = 0.75
@@ -62,12 +120,12 @@ struct ControllerTestView: View {
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if let active = gamepads.active {
header(active)
inputCard
rumbleCard()
triggerCard(active)
extrasCard(active)
if let shotPads {
ForEach(shotPads) { pad in
shotPanel(pad)
}
} else if let active = gamepads.active {
livePanel(active)
} else {
ContentUnavailableView(
"No controller",
@@ -81,9 +139,10 @@ struct ControllerTestView: View {
}
}
.frame(minWidth: 420, minHeight: 540)
.onAppear { tester.target(gamepads.active?.controller) }
.onDisappear { tester.stop() }
.onAppear { if shotPads == nil { tester.target(gamepads.active?.controller) } }
.onDisappear { if shotPads == nil { tester.stop() } }
.onChange(of: gamepads.active?.id) { _, _ in
guard shotPads == nil else { return }
heavyOn = false
lightOn = false
playerLED = -1
@@ -91,16 +150,53 @@ struct ControllerTestView: View {
}
}
// MARK: Panels
@ViewBuilder
private func livePanel(_ active: GamepadManager.DiscoveredController) -> some View {
let pad = Self.describe(active)
header(pad)
liveInputCard
rumbleCard(backend: tester.rumbleBackend, health: tester.rumbleHealth)
triggerCard(pad)
extrasCard(pad)
}
/// An injected pad's cards, in the live panel's order. The adaptive-trigger card is skipped
/// outright for a pad without them the live path's "needs a DualSense" hint is a diagnosis,
/// and a capture has nothing to diagnose.
@ViewBuilder
private func shotPanel(_ pad: ShotPad) -> some View {
header(pad)
if let input = pad.input {
card("Input") { inputReadout(input) }
}
if let backend = pad.rumbleBackend {
rumbleCard(backend: backend, health: nil)
}
if pad.hasAdaptiveTriggers {
triggerCard(pad)
}
extrasCard(pad)
}
/// The live pad, flattened to what the panel renders about it.
private static func describe(_ c: GamepadManager.DiscoveredController) -> ShotPad {
ShotPad(
name: c.name, detail: c.productCategory, isDualSense: c.isDualSense,
hasAdaptiveTriggers: c.hasAdaptiveTriggers, hasLight: c.hasLight)
}
// MARK: Header
private func header(_ c: GamepadManager.DiscoveredController) -> some View {
private func header(_ pad: ShotPad) -> some View {
HStack(spacing: 10) {
Image(systemName: c.isDualSense ? "playstation.logo" : "gamecontroller.fill")
Image(systemName: pad.isDualSense ? "playstation.logo" : "gamecontroller.fill")
.font(.title2)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 2) {
Text(c.name).font(.geist(17, .semibold, relativeTo: .headline))
Text(c.productCategory).font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
Text(pad.name).font(.geist(17, .semibold, relativeTo: .headline))
Text(pad.detail).font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
}
Spacer()
}
@@ -108,13 +204,13 @@ struct ControllerTestView: View {
// MARK: Input
private var inputCard: some View {
private var liveInputCard: some View {
card("Input") {
// Poll the live controller at 30 Hz no handlers installed, so nothing else's
// capture is disturbed.
TimelineView(.periodic(from: .now, by: 1.0 / 30.0)) { _ in
if let gp = gamepads.active?.controller.extendedGamepad {
inputReadout(gp, controller: gamepads.active?.controller)
inputReadout(Self.snapshot(gp, controller: gamepads.active?.controller))
} else {
Text("Not an extended gamepad").foregroundStyle(.secondary)
}
@@ -122,40 +218,82 @@ struct ControllerTestView: View {
}
}
/// One readout frame off the live pad.
private static func snapshot(
_ g: GCExtendedGamepad, controller: GCController?
) -> InputSnapshot {
var buttons: [(String, Bool)] = [
("A", g.buttonA.isPressed), ("B", g.buttonB.isPressed),
("X", g.buttonX.isPressed), ("Y", g.buttonY.isPressed),
("LB", g.leftShoulder.isPressed), ("RB", g.rightShoulder.isPressed),
("L3", g.leftThumbstickButton?.isPressed ?? false),
("R3", g.rightThumbstickButton?.isPressed ?? false),
("Menu", g.buttonMenu.isPressed),
("Opts", g.buttonOptions?.isPressed ?? false),
("", g.dpad.up.isPressed), ("", g.dpad.down.isPressed),
("", g.dpad.left.isPressed), ("", g.dpad.right.isPressed),
]
let tp = touchpad(g)
if let tp { buttons.append(("Pad", tp.button.isPressed)) }
return InputSnapshot(
leftStick: .init(
x: g.leftThumbstick.xAxis.value, y: g.leftThumbstick.yAxis.value,
pressed: g.leftThumbstickButton?.isPressed ?? false),
rightStick: .init(
x: g.rightThumbstick.xAxis.value, y: g.rightThumbstick.yAxis.value,
pressed: g.rightThumbstickButton?.isPressed ?? false),
leftTrigger: g.leftTrigger.value, rightTrigger: g.rightTrigger.value,
buttons: buttons,
touchpad: tp.map {
.init(primary: finger($0.primary), secondary: finger($0.secondary),
clicked: $0.button.isPressed)
},
motion: controller?.motion.map { m -> InputSnapshot.Motion in
let a = totalAccel(m)
return .init(
gyro: .init(m.rotationRate.x, m.rotationRate.y, m.rotationRate.z),
accel: .init(a.0, a.1, a.2))
})
}
private static func finger(_ pad: GCControllerDirectionPad) -> CGPoint? {
let x = pad.xAxis.value, y = pad.yAxis.value
// GC snaps a lifted finger to exactly (0, 0).
return (x == 0 && y == 0) ? nil : CGPoint(x: CGFloat(x), y: CGFloat(y))
}
@ViewBuilder
private func inputReadout(_ g: GCExtendedGamepad, controller: GCController?) -> some View {
private func inputReadout(_ s: InputSnapshot) -> some View {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .top, spacing: 20) {
stick("L", x: g.leftThumbstick.xAxis.value, y: g.leftThumbstick.yAxis.value,
pressed: g.leftThumbstickButton?.isPressed ?? false)
stick("R", x: g.rightThumbstick.xAxis.value, y: g.rightThumbstick.yAxis.value,
pressed: g.rightThumbstickButton?.isPressed ?? false)
stick("L", s.leftStick)
stick("R", s.rightStick)
VStack(spacing: 8) {
triggerBar("L2", value: g.leftTrigger.value)
triggerBar("R2", value: g.rightTrigger.value)
triggerBar("L2", value: s.leftTrigger)
triggerBar("R2", value: s.rightTrigger)
}
}
buttonGrid(g)
if let tp = Self.touchpad(g) {
buttonGrid(s.buttons)
if let tp = s.touchpad {
touchpadView(tp)
}
if let m = controller?.motion {
if let m = s.motion {
motionReadout(m)
}
}
}
private func stick(_ label: String, x: Float, y: Float, pressed: Bool) -> some View {
private func stick(_ label: String, _ s: InputSnapshot.Stick) -> some View {
VStack(spacing: 4) {
ZStack {
Circle().stroke(Color.secondary.opacity(0.3))
Circle()
.fill(pressed ? Color.accentColor : Color.secondary)
.fill(s.pressed ? Color.accentColor : Color.secondary)
.frame(width: 12, height: 12)
.offset(x: CGFloat(x) * 22, y: CGFloat(-y) * 22) // GC y is +up
.offset(x: CGFloat(s.x) * 22, y: CGFloat(-s.y) * 22) // GC y is +up
}
.frame(width: 56, height: 56)
Text("\(label) \(sgn(x)),\(sgn(y))").font(.caption2.monospaced()).foregroundStyle(.secondary)
Text("\(label) \(sgn(s.x)),\(sgn(s.y))").font(.caption2.monospaced()).foregroundStyle(.secondary)
}
}
@@ -175,20 +313,8 @@ struct ControllerTestView: View {
.frame(width: 150)
}
private func buttonGrid(_ g: GCExtendedGamepad) -> some View {
var items: [(String, Bool)] = [
("A", g.buttonA.isPressed), ("B", g.buttonB.isPressed),
("X", g.buttonX.isPressed), ("Y", g.buttonY.isPressed),
("LB", g.leftShoulder.isPressed), ("RB", g.rightShoulder.isPressed),
("L3", g.leftThumbstickButton?.isPressed ?? false),
("R3", g.rightThumbstickButton?.isPressed ?? false),
("Menu", g.buttonMenu.isPressed),
("Opts", g.buttonOptions?.isPressed ?? false),
("", g.dpad.up.isPressed), ("", g.dpad.down.isPressed),
("", g.dpad.left.isPressed), ("", g.dpad.right.isPressed),
]
if let tp = Self.touchpad(g) { items.append(("Pad", tp.button.isPressed)) }
return LazyVGrid(
private func buttonGrid(_ items: [(String, Bool)]) -> some View {
LazyVGrid(
columns: Array(repeating: GridItem(.flexible(), spacing: 6), count: 5), spacing: 6
) {
ForEach(items.indices, id: \.self) { i in
@@ -203,12 +329,9 @@ struct ControllerTestView: View {
}
}
private func touchpadView(
_ tp: (primary: GCControllerDirectionPad, secondary: GCControllerDirectionPad,
button: GCControllerButtonInput)
) -> some View {
private func touchpadView(_ tp: InputSnapshot.Touch) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text("Touchpad\(tp.button.isPressed ? " — click" : "")")
Text("Touchpad\(tp.clicked ? " — click" : "")")
.font(.geist(11, relativeTo: .caption2)).foregroundStyle(.secondary)
ZStack {
RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.3))
@@ -219,29 +342,25 @@ struct ControllerTestView: View {
}
}
private func fingerDot(_ pad: GCControllerDirectionPad, color: Color) -> some View {
let x = pad.xAxis.value, y = pad.yAxis.value
let active = !(x == 0 && y == 0) // GC snaps a lifted finger to exactly (0, 0)
return Circle().fill(color).frame(width: 10, height: 10)
.offset(x: CGFloat(x) * 71, y: CGFloat(-y) * 33)
.opacity(active ? 1 : 0)
private func fingerDot(_ p: CGPoint?, color: Color) -> some View {
Circle().fill(color).frame(width: 10, height: 10)
.offset(x: (p?.x ?? 0) * 71, y: -(p?.y ?? 0) * 33)
.opacity(p == nil ? 0 : 1)
}
private func motionReadout(_ m: GCMotion) -> some View {
let a = Self.totalAccel(m)
return VStack(alignment: .leading, spacing: 2) {
private func motionReadout(_ m: InputSnapshot.Motion) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text("Motion").font(.geist(11, relativeTo: .caption2)).foregroundStyle(.secondary)
Text(String(format: "gyro %+.2f %+.2f %+.2f",
m.rotationRate.x, m.rotationRate.y, m.rotationRate.z))
Text(String(format: "gyro %+.2f %+.2f %+.2f", m.gyro.x, m.gyro.y, m.gyro.z))
.font(.caption2.monospaced())
Text(String(format: "accel %+.2f %+.2f %+.2f", a.0, a.1, a.2))
Text(String(format: "accel %+.2f %+.2f %+.2f", m.accel.x, m.accel.y, m.accel.z))
.font(.caption2.monospaced())
}
}
// MARK: Rumble
private func rumbleCard() -> some View {
private func rumbleCard(backend: String, health: String?) -> some View {
card("Rumble") {
VStack(alignment: .leading, spacing: 12) {
Picker("Strength", selection: $intensity) {
@@ -253,9 +372,9 @@ struct ControllerTestView: View {
.pickerStyle(.segmented)
Toggle("Heavy motor (left)", isOn: $heavyOn)
Toggle("Light motor (right)", isOn: $lightOn)
Label("Backend: \(tester.rumbleBackend)", systemImage: "waveform")
Label("Backend: \(backend)", systemImage: "waveform")
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
if let problem = tester.rumbleHealth {
if let problem = health {
Label(problem, systemImage: "exclamationmark.triangle.fill")
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.orange)
}
@@ -276,9 +395,9 @@ struct ControllerTestView: View {
// MARK: Adaptive triggers
private func triggerCard(_ c: GamepadManager.DiscoveredController) -> some View {
private func triggerCard(_ pad: ShotPad) -> some View {
card("Adaptive triggers") {
if c.hasAdaptiveTriggers {
if pad.hasAdaptiveTriggers {
VStack(alignment: .leading, spacing: 12) {
Picker("Apply to", selection: $triggerTarget) {
ForEach(TriggerTarget.allCases) { Text($0.rawValue).tag($0) }
@@ -315,8 +434,8 @@ struct ControllerTestView: View {
// MARK: Lightbar + player LED
@ViewBuilder
private func extrasCard(_ c: GamepadManager.DiscoveredController) -> some View {
if c.hasLight {
private func extrasCard(_ pad: ShotPad) -> some View {
if pad.hasLight {
card("Lightbar & player LED") {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 12) {
@@ -42,6 +42,9 @@ enum GpSettingsTab: String, CaseIterable, Hashable {
case controller = "Controller"
case interface = "Interface"
case profiles = "Profiles"
/// Trailing, like Profiles: both are built from something other than the settings store, and
/// About is where the strip ends because it is the one section that changes nothing.
case about = "About"
}
struct GamepadSettingsView: View {
@@ -53,6 +56,8 @@ struct GamepadSettingsView: View {
@Environment(\.displayBottomInset) private var displayBottomInset
@Environment(\.dismiss) private var dismiss
@Environment(\.gamepadHostedInShell) private var hostedInShell
/// The About section's link rows (never used on tvOS, which has no browser).
@Environment(\.openURL) private var openURL
/// The saved-host store the pin picker writes `setPinned` through it and the profile rows
/// count pins from its live hosts. Threaded in from GamepadHomeView like the home screen
/// itself (ContentView owns the instance).
@@ -64,6 +69,9 @@ struct GamepadSettingsView: View {
/// console's input drop) and while the connect takeover is up; a system presentation never
/// needs the gate and keeps the default.
var controllerActive = true
/// Whether this device has a microphone at all passed through to the About page's shortcuts
/// reference, which must not list a mute key on a device that can't mute anything.
var micAvailable = true
@AppStorage(DefaultsKey.streamWidth) private var width = 1920
@AppStorage(DefaultsKey.streamHeight) private var height = 1080
@AppStorage(DefaultsKey.streamHz) private var hz = 60
@@ -135,6 +143,14 @@ struct GamepadSettingsView: View {
/// The direction of the last value step (+1 right/forward, -1 left) picks which edge the
/// changed value slides in from, so the animation follows the user's motion.
@State private var lastAdjustDelta = 1
/// A reading surface opened from the About tab, replacing the row list the way the pin picker
/// does. Depth is 1: neither page opens anything further.
private enum AboutPage: Equatable {
case shortcuts
case licenses
}
@State private var aboutPage: AboutPage?
var body: some View {
GamepadMenuList(
@@ -160,9 +176,9 @@ struct GamepadSettingsView: View {
.foregroundStyle(ink.fg)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 24)
// The picker is one layer deeper its rows aren't sections of anything, so the
// strip would be a control that does nothing while it's up.
if pinTarget == nil { tabStrip }
// The picker and the About reading pages are one layer deeper their rows aren't
// sections of anything, so the strip would be a control that does nothing.
if pinTarget == nil, aboutPage == nil { tabStrip }
}
.padding(.top, gamepadTitleTopPadding(compact: compact))
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
@@ -329,16 +345,62 @@ struct GamepadSettingsView: View {
if let close { close() } else { dismiss() }
}
/// Where the product actually lives kept together so the three can be checked against the
/// README in one glance (the touch `AboutView` holds the same three).
private enum Destination {
static let docs = URL(string: "https://docs.punktfunk.unom.io")!
static let community = URL(string: "https://discord.gg/kaPNvzMuGU")!
static let source = URL(string: "https://git.unom.io/unom/punktfunk")!
}
/// "Version 0.29.0 (100000)" the build number only when it says something the version does
/// not. Mirrors `AboutView.versionLine`; a bug report is worth more with it.
private static var versionLine: String {
let info = Bundle.main.infoDictionary
let short = info?["CFBundleShortVersionString"] as? String ?? ""
let build = info?["CFBundleVersion"] as? String
guard let build, !build.isEmpty, build != short else { return "Version \(short)" }
return "Version \(short) (\(build))"
}
/// "Settings", or "Pin Work" while the pin picker is up the title is what says which
/// layer the row list currently is.
private var title: String {
pinTarget.map { "Pin “\($0.name)" } ?? "Settings"
if let profile = pinTarget { return "Pin “\(profile.name)" }
switch aboutPage {
case .shortcuts: return "Shortcuts"
case .licenses: return "Acknowledgements"
case nil: return "Settings"
}
}
/// The legend follows the layer: value-editing hints on the settings rows, pin/unpin on the
/// picker where B reads "Back" (it peels to the settings rows, GamepadAddHostView's "one
/// layer" rule), and a hostless picker has nothing to pin, so only Back remains.
private var hints: [GamepadHint] {
// A reading page is scrolled, not operated: offering A would be the same lie a dimmed row
// used to tell. Only Back remains.
if aboutPage != nil {
return [.init(
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back",
action: { back() })]
}
// The About rows open things rather than change them, so A reads "Open" and there is no
// Adjust cell left/right genuinely does nothing there.
if pinTarget == nil, tab == .about {
let sections: [GamepadHint] = showsSectionHint
? [.init(glyph: buttonGlyph(\.leftShoulder, fallback: "l1.rectangle.roundedbottom"),
text: "Section", action: { step(tabBy: 1) })]
: []
return sections + [
.init(
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Open",
action: { if let focusID { activate(id: focusID) } }),
.init(
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done",
action: { back() }),
]
}
guard pinTarget != nil else {
// The shoulders change section, so that cell leads where it fits and where the
// shoulders exist at all (see `showsSectionHint`).
@@ -386,6 +448,9 @@ struct GamepadSettingsView: View {
if let profile = pinTarget {
pinTarget = nil
focusID = "profile-\(profile.id)"
} else if let page = aboutPage {
aboutPage = nil
focusID = page == .shortcuts ? "shortcuts" : "licenses"
} else {
performClose()
}
@@ -393,7 +458,53 @@ struct GamepadSettingsView: View {
// MARK: - Row rendering
@ViewBuilder
private func rowView(_ row: Row, focused: Bool) -> some View {
switch row.kind {
case .control: controlRow(row, focused: focused)
case .footer:
Text(row.label)
.font(.geist(metrics.detailFont, .medium, relativeTo: .caption))
.monospacedDigit()
.foregroundStyle(ink.fg(focused ? 0.7 : 0.45))
.frame(maxWidth: .infinity, alignment: .center)
.padding(.top, 18)
.animation(.smooth(duration: 0.18), value: focused)
case .heading:
Text(row.label)
.font(.geist(metrics.labelFont, .bold, relativeTo: .headline))
.foregroundStyle(ink.fg(0.75))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, metrics.rowHPad)
.padding(.top, 14)
.padding(.bottom, 2)
case .prose:
// Focus here means "this is the part you are scrolled to", not "press A" so it is a
// quiet wash rather than the control rows' full glass.
VStack(alignment: .leading, spacing: 4) {
Text(row.label)
.font(.geistFixed(metrics.valueFont, .medium))
.foregroundStyle(ink.fg(0.95))
.fixedSize(horizontal: false, vertical: true)
if !row.value.isEmpty {
Text(row.value)
.font(.geist(metrics.detailFont, relativeTo: .caption))
.foregroundStyle(ink.fg(0.6))
.fixedSize(horizontal: false, vertical: true)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, metrics.rowHPad)
.padding(.vertical, metrics.rowVPad * 0.7)
.background {
RoundedRectangle(cornerRadius: metrics.rowCorner, style: .continuous)
.fill(ink.fg(focused ? 0.08 : 0))
}
.animation(.smooth(duration: 0.18), value: focused)
}
}
private func controlRow(_ row: Row, focused: Bool) -> some View {
let m = metrics
// No section header: the tab strip names the section now, and repeating it above the
// first row of every tab was just a second label saying the same word.
@@ -505,10 +616,23 @@ struct GamepadSettingsView: View {
/// `activate(id:)`, not per closure, so no row builder can forget it.
/// (Android's `GpRow.enabled` and `pf-console-ui`'s `RowSpec.enabled` are the twins.)
var enabled = true
/// How this row DRAWS. Every tab but About is `.control` the glass row with a label and
/// a value. About is a reading surface as much as a menu, so it also has a heading and a
/// block of prose, which are rows only so the focus list can scroll them (the same trick
/// `Licenses.chunked` plays for tvOS focus).
var kind: Kind = .control
/// Left/right step; returns whether the value actually changed (false boundary thud).
let adjust: (Int) -> Bool
/// A cycle forward (wrapping) / flip.
let activate: () -> Void
enum Kind {
case control
case heading
case prose
/// Quiet, centred trailing text the About tab's version line.
case footer
}
}
/// Dispatch by id so the focus list's stored input callbacks always act on freshly built rows
@@ -530,9 +654,133 @@ struct GamepadSettingsView: View {
/// controller wiring and the tvOS focus engine carry over as is).
private var rows: [Row] {
if let profile = pinTarget { return pinRows(for: profile) }
if let page = aboutPage {
switch page {
case .shortcuts: return shortcutRows
case .licenses: return licenseRows
}
}
if tab == .about { return aboutRows }
return allRows.filter { $0.tab == tab }
}
// MARK: - About
/// The About section: the ways out, plus the two reading surfaces. The identity itself (icon,
/// name, version, tagline) is the HEADER while this tab is up see `aboutIdentity` not a
/// row, so the list holds no focus stop that does nothing when pressed.
private var aboutRows: [Row] {
var list: [Row] = [
aboutAction(
id: "shortcuts", icon: "command", label: "Shortcuts", value: "While streaming",
detail: "What to press during a session on this device — and on a controller.",
open: .shortcuts),
aboutAction(
id: "licenses", icon: "text.document", label: "Acknowledgements",
value: "MIT or Apache-2.0",
detail: "Punktfunk's own licence and the third-party components it uses.",
open: .licenses),
]
list.append(contentsOf: [
aboutLink(id: "docs", icon: "book", label: "Documentation", url: Destination.docs),
aboutLink(
id: "community", icon: "bubble.left.and.bubble.right", label: "Community",
url: Destination.community),
aboutLink(
id: "source", icon: "chevron.left.forwardslash.chevron.right",
label: "Source code", url: Destination.source),
])
// The version sits UNDER the rows rather than in a header card above them. The card that
// used to head this tab carried the app icon, and on tvOS that icon is a 400x240
// rectangle that would not survive contact with a layout built for square art three
// attempts at framing it were still cropping it on the real TV. A version string answers
// the only question anyone actually opens About to ask, and has no aspect ratio to get
// wrong. `.footer` draws it quiet and centred, so it reads as a footer and not a row you
// failed to press.
list.append(Row(
id: "version", tab: .about, icon: "", label: Self.versionLine, value: "",
detail: "", adjustable: false, enabled: true, kind: .footer,
adjust: { _ in false }, activate: {}))
return list
}
private func aboutAction(
id: String, icon: String, label: String, value: String, detail: String, open: AboutPage
) -> Row {
Row(
id: id, tab: .about, icon: icon, label: label, value: value, detail: detail,
adjustable: false,
adjust: { _ in false },
activate: {
// Focus lands on the page's first row the focus list's reconcile follows this
// id when the row set swaps underneath it (the pin picker's pattern).
focusID = open == .shortcuts ? shortcutRows.first?.id : licenseRows.first?.id
aboutPage = open
})
}
/// tvOS has no browser and no `openURL`, so an address there is text to read off the screen
/// rather than a link to nowhere the same call the touch About page makes.
private func aboutLink(id: String, icon: String, label: String, url: URL) -> Row {
let shown = url.absoluteString.replacingOccurrences(of: "https://", with: "")
#if os(tvOS)
return Row(
id: id, tab: .about, icon: icon, label: label, value: shown,
detail: "Open this address on a phone or computer.",
adjustable: false, adjust: { _ in false }, activate: {})
#else
return Row(
id: id, tab: .about, icon: icon, label: label, value: shown,
detail: "Opens in your browser.",
adjustable: false, adjust: { _ in false }, activate: { openURL(url) })
#endif
}
/// The shortcuts reference the same `ShortcutsCatalog` the touch About page renders, so the
/// two can never drift.
private var shortcutRows: [Row] {
ShortcutsCatalog.groups(micAvailable: micAvailable).flatMap { group -> [Row] in
[aboutText(id: "group-\(group.title)", label: group.title, kind: .heading)]
+ group.items.map { item in
aboutText(
id: "sc-\(group.title)-\(item.keys)", label: item.keys, value: item.text,
kind: .prose)
}
}
}
/// The licence wall, one row per pre-chunked page (`Licenses.chunked`, which exists so tvOS
/// can page it by focus steps) so it scrolls with the stick and needs no machinery here.
private var licenseRows: [Row] {
var list: [Row] = [
aboutText(id: "lic-heading", label: "Punktfunk", kind: .heading),
aboutText(
id: "lic-summary",
label: "Punktfunk's source is open under MIT or Apache-2.0. It ships the Geist "
+ "typeface under the SIL Open Font License 1.1, and uses the third-party "
+ "components below, each under its own license.",
kind: .prose),
]
for (i, chunk) in Licenses.chunked(Licenses.appLicense).enumerated() {
list.append(aboutText(id: "lic-app-\(i)", label: chunk, kind: .prose))
}
list.append(aboutText(
id: "lic-third-heading", label: "Third-party software", kind: .heading))
for (i, chunk) in Licenses.thirdPartyNoticesChunks.enumerated() {
list.append(aboutText(id: "lic-third-\(i)", label: chunk, kind: .prose))
}
return list
}
private func aboutText(
id: String, label: String, value: String = "", kind: Row.Kind
) -> Row {
Row(
id: id, tab: .about, icon: "", label: label, value: value, detail: "",
adjustable: false, enabled: true, kind: kind,
adjust: { _ in false }, activate: {})
}
/// Every row on the screen, tagged with its section. Built as one list (not per tab) so the
/// platform-conditional insertions below can still place a row RELATIVE to another by id.
private var allRows: [Row] {
@@ -114,6 +114,10 @@ enum SettingsFields {
.init(name: "invert_scroll", key: DefaultsKey.invertScroll,
overlay: \.invertScroll, effective: \.invertScroll)
}
static var inhibitShortcuts: SettingsField<Bool> {
.init(name: "inhibit_shortcuts", key: DefaultsKey.inhibitShortcuts,
overlay: \.inhibitShortcuts, effective: \.inhibitShortcuts)
}
static var modifierLayout: SettingsField<String> {
.init(name: "modifier_layout", key: DefaultsKey.modifierLayout,
overlay: \.modifierLayout, effective: \.modifierLayout)
@@ -205,6 +209,7 @@ extension SettingsView {
#endif
#if os(macOS)
base.mouseMode = mouseMode
base.inhibitShortcuts = inhibitShortcuts
base.vsync = vsync
base.windowedSafePresent = windowedSafePresent
#endif
@@ -515,6 +515,9 @@ extension SettingsView {
Text("Desktop (absolute)").tag(MouseInputMode.desktop.rawValue)
}
}
described(inhibitShortcutsDescription, field: "inhibit_shortcuts") {
Toggle("Capture system shortcuts", isOn: scoped(SettingsFields.inhibitShortcuts))
}
#endif
described(
(ModifierLayout(rawValue: effective.modifierLayout) ?? .mac).detail,
@@ -534,6 +537,19 @@ extension SettingsView {
}
#if os(macOS)
/// Dynamic like the captions above, because the setting genuinely has no effect under the
/// desktop mouse model (system chords stay local there on every client) and a toggle that
/// silently does nothing should say so instead of leaving the user to find out.
private var inhibitShortcutsDescription: String {
if (MouseInputMode(rawValue: effective.mouseMode) ?? .capture) == .desktop {
return "⌘ shortcuts stay on this Mac under the desktop mouse model. Switch Mouse "
+ "input to Capture to send them to the host."
}
return "Sends ⌘ shortcuts to the host while input is captured, so ⌘Q and friends reach "
+ "the remote desktop instead of this app. ⌘⎋ always stays local — it is what "
+ "releases capture."
}
/// The SELECTED mouse model explained dynamic, like the touch-mode caption.
private var mouseModeDescription: String {
switch MouseInputMode(rawValue: effective.mouseMode) ?? .capture {
@@ -115,6 +115,10 @@ struct SettingsView: View {
#endif
#if os(macOS)
@AppStorage(DefaultsKey.mouseMode) var mouseMode = MouseInputMode.capture.rawValue
/// Cross-client `inhibit_shortcuts` here, the -chord passthrough (Q & co. reach the host
/// instead of the app menu while captured). macOS-only: it is the one platform whose window
/// system hands a plain app no keyboard grab, so the client has to claim the chords itself.
@AppStorage(DefaultsKey.inhibitShortcuts) var inhibitShortcuts = true
@AppStorage(DefaultsKey.speakerUID) var speakerUID = ""
@AppStorage(DefaultsKey.micUID) var micUID = ""
@AppStorage(DefaultsKey.micChannel) var micChannel = 0
@@ -0,0 +1,181 @@
// The in-session controls, written down once and read by every surface that shows them.
//
// This replaced the start-of-stream banner (ContentView's `showShortcutHint`): a 6-second pill
// that told you the controls exactly once, while you were busy looking at the thing you had just
// connected to, and then never again. A reference you can OPEN answers the question at the moment
// it is actually asked which is the second session, not the first.
//
// The catalog is data rather than a view so both About pages render the same words: the touch
// `AboutView` (a Form) and the controller-first `GamepadAboutView` (a console list). The banner
// was macOS/tvOS-only, so deleting it would have cost Mac TOUCH users the one place those keys
// were written down hence the touch surface gets this too, not just the gamepad UI.
//
// Per-platform by `#if`, because the honest answer really is different: tvOS has no keyboard and
// no menu bar, iOS has a touch gesture nothing else has, and macOS is the only one that has to
// explain mouse capture. A controller's chords are the one section common to all three they are
// the same buttons on every client (`GamepadCapture.escapeChord` / `.statsChord`), which is the
// whole point of a cross-client chord.
import AVFoundation
import PunktfunkKit
import SwiftUI
/// One line of the reference: what you press, and what it does.
struct ShortcutItem: Identifiable {
/// Stable within its group the keys are unique per group by construction.
var id: String { keys }
/// The chord itself, rendered monospaced so -style runs stay legible.
let keys: String
let text: String
}
struct ShortcutGroup: Identifiable {
var id: String { title }
let title: String
let items: [ShortcutItem]
}
enum ShortcutsCatalog {
/// Whether a mute key is worth listing when no session is running, for the About page reached
/// from settings. `SessionModel.micAvailable` is the authority DURING a session it also
/// consults the profile the session actually resolved but a reference page opened between
/// sessions has no session to ask, so it answers the device-level half of the same question:
/// a platform with an app-accessible input, the mic setting on, and the OS not refusing.
/// `.notDetermined` counts, exactly as it does there: the prompt is simply still pending.
static var micPlausible: Bool {
#if os(tvOS)
return false // no app-accessible microphone
#else
guard UserDefaults.standard.object(forKey: DefaultsKey.micEnabled) as? Bool ?? true
else { return false }
switch AVCaptureDevice.authorizationStatus(for: .audio) {
case .authorized, .notDetermined: return true
default: return false
}
#endif
}
/// `micAvailable` gates the mute row a device with no microphone would otherwise be told
/// about a key that does nothing, which is the failure the old banner already avoided.
static func groups(micAvailable: Bool) -> [ShortcutGroup] {
var groups: [ShortcutGroup] = []
#if os(macOS)
var keyboard: [ShortcutItem] = [
.init(keys: "Click", text: "Capture the mouse and keyboard for the stream"),
.init(keys: "⌃⌥⇧Q", text: "Release the mouse and keyboard back to this Mac"),
.init(keys: "⌃⌥⇧D", text: "Disconnect"),
.init(keys: "⌃⌥⇧S", text: "Cycle the statistics overlay"),
]
if micAvailable {
keyboard.append(.init(keys: "⌃⌥⇧A", text: "Mute or unmute the microphone"))
}
groups.append(.init(title: "Keyboard", items: keyboard))
#elseif os(iOS)
// iPad with a hardware keyboard gets the same cross-client set as the Mac (StreamCommands
// publishes it either way); a phone simply never sees a keyboard to press it on.
var keyboard: [ShortcutItem] = [
.init(keys: "⌃⌥⇧Q", text: "Release the pointer back to this device"),
.init(keys: "⌃⌥⇧D", text: "Disconnect"),
.init(keys: "⌃⌥⇧S", text: "Cycle the statistics overlay"),
]
if micAvailable {
keyboard.append(.init(keys: "⌃⌥⇧A", text: "Mute or unmute the microphone"))
}
groups.append(.init(title: "Hardware keyboard", items: keyboard))
groups.append(.init(title: "Touch", items: [
.init(keys: "Three-finger tap", text: "Cycle the statistics overlay"),
]))
#elseif os(tvOS)
// The remote section leads on tvOS: it carries the ONLY exits. Menu/B is swallowed during
// a session (ContentView's `.onExitCommand {}`), so a user who does not know the hold
// gesture is genuinely stuck which is why this was the one banner that could not simply
// be deleted without putting the words somewhere findable first.
groups.append(.init(title: "Siri Remote", items: [
.init(keys: "Hold Back", text: "Disconnect"),
.init(keys: "Touch surface", text: "Move the pointer"),
.init(keys: "Press", text: "Click"),
.init(keys: "Play/Pause", text: "Right-click"),
.init(keys: "Hold Play/Pause", text: "Cycle the statistics overlay"),
]))
#endif
// Every client's controller speaks these two chords see GamepadCapture.escapeChord and
// .statsChord, which a test pins against their GameController element lists.
groups.append(.init(title: "Controller", items: [
.init(keys: "L1 + R1 + Start + Select", text: "Hold to disconnect"),
.init(keys: "Select + X", text: "Cycle the statistics overlay"),
.init(keys: "Hold Select", text: "Press the host's guide button"),
]))
return groups
}
}
/// The standard-interface reference a sheet from `AboutView` on iOS/macOS, a pushed page on
/// tvOS so the keys the start-of-stream banner used to carry are still one press away.
/// (The controller-first surface renders the same catalog itself; see `GamepadAboutView`.)
struct ShortcutsView: View {
let micAvailable: Bool
var body: some View {
#if os(tvOS)
// No `Form`/`.formStyle(.grouped)` worth using at 10 feet, and the rows are read, not
// operated a plain scrolling column at TV sizes says the same thing with less chrome.
ScrollView {
VStack(alignment: .leading, spacing: 30) {
ForEach(ShortcutsCatalog.groups(micAvailable: micAvailable)) { group in
VStack(alignment: .leading, spacing: 12) {
Text(group.title)
.font(.geist(28, .semibold, relativeTo: .headline))
ForEach(group.items) { item in
HStack(alignment: .firstTextBaseline, spacing: 20) {
Text(item.keys)
.font(.geistFixed(22, .medium))
.frame(minWidth: 300, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
Text(item.text)
.font(.geist(22, relativeTo: .caption))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
}
}
.frame(maxWidth: 1000, alignment: .leading)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(60)
}
.navigationTitle("Shortcuts")
#else
form
#endif
}
#if !os(tvOS)
private var form: some View {
Form {
ForEach(ShortcutsCatalog.groups(micAvailable: micAvailable)) { group in
Section(group.title) {
ForEach(group.items) { item in
HStack(alignment: .firstTextBaseline, spacing: 12) {
Text(item.keys)
.font(.geistFixed(13, .medium))
.foregroundStyle(.primary)
// A fixed column keeps the descriptions aligned; the chords vary
// from "Click" to "L1 + R1 + Start + Select".
.frame(minWidth: 132, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
Text(item.text)
.font(.geist(13, relativeTo: .footnote))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.vertical, 2)
}
}
}
}
.formStyle(.grouped)
.navigationTitle("Shortcuts")
}
#endif
}
@@ -162,6 +162,17 @@ final class HostStore: ObservableObject {
hosts[i].osChain = chain
}
/// Learn/refresh this host's management-API port from its live advert same contract as
/// `updateMacs`. Until this existed, `StoredHost.mgmtPort` was declared and read but never
/// written, so `effectiveMgmtPort` always answered 47990 and a host that had moved its mgmt
/// port simply had no working library here.
func updateMgmtPort(_ hostID: UUID, port: UInt16?) {
guard let port, port > 0,
let i = hosts.firstIndex(where: { $0.id == hostID }),
hosts[i].mgmtPort != port else { return }
hosts[i].mgmtPort = port
}
/// Bind this host to a settings profile, or to "Default settings" (nil) the ONLY way the
/// default changes. A one-off "Connect with " deliberately never lands here (§5.2:
/// predictable, not sticky).
@@ -48,11 +48,8 @@ struct GamepadPairView: View {
@StateObject private var ceremony = PairCeremony()
@State private var pin = ""
#if os(macOS)
@State private var clientName = Host.current().localizedName ?? "Mac"
#else
@State private var clientName = UIDevice.current.name
#endif
// Same source the connect path knocks with see the note in `PairSheet`.
@State private var clientName = DeviceName.current
@State private var focusID: String?
/// The field row the keyboard tray is editing; nil the row list owns the controller.
@State private var editing: String?
@@ -49,7 +49,7 @@ final class PairCeremony: ObservableObject {
let identity = try ClientIdentityStore.shared.loadForPairing()
return try PunktfunkKit.pair(
host: address, port: port, identity: identity,
pin: pin, name: name.isEmpty ? "Mac" : name)
pin: pin, name: name.isEmpty ? DeviceName.current : name)
}
await MainActor.run {
guard !token.cancelled else { return } // screen dismissed mid-ceremony
@@ -21,11 +21,9 @@ struct PairSheet: View {
let onPaired: (Data) -> Void
@State private var pin = ""
#if os(macOS)
@State private var clientName = Host.current().localizedName ?? "Mac"
#else
@State private var clientName = UIDevice.current.name
#endif
// Same source the connect path knocks with (`DeviceName.current`), so a device the operator
// approves from the console's pending list and one that pairs by PIN land under one name.
@State private var clientName = DeviceName.current
@StateObject private var ceremony = PairCeremony()
private var busy: Bool { ceremony.busy }
@@ -32,8 +32,11 @@ final class AudioDeviceWatcher {
/// posts one last change as it is torn down, and other AVAudioEngines in the process are not
/// ours to restart.
private let isOurs: (AnyObject?) -> Bool
/// Delivered on the main queue.
private let onChange: (Reason) -> Void
/// Delivered on the main queue. The second argument is the engine that posted the change
/// (`.engineConfiguration` only; nil for the HAL listener) the owner needs the OBJECT, not
/// just the reason, because an engine that is RUNNING when the notification lands is one the
/// owner already restarted: acting on that echo is how a rebuild loop starts.
private let onChange: (Reason, AnyObject?) -> Void
private let lock = NSLock()
private var configObserver: NSObjectProtocol?
@@ -41,7 +44,7 @@ final class AudioDeviceWatcher {
private var defaultOutputListener: AudioObjectPropertyListenerBlock?
#endif
init(isOurs: @escaping (AnyObject?) -> Bool, onChange: @escaping (Reason) -> Void) {
init(isOurs: @escaping (AnyObject?) -> Bool, onChange: @escaping (Reason, AnyObject?) -> Void) {
self.isOurs = isOurs
self.onChange = onChange
}
@@ -63,7 +66,7 @@ final class AudioDeviceWatcher {
let posted = note.object as AnyObject?
DispatchQueue.main.async {
guard let self, self.isOurs(posted) else { return }
self.onChange(.engineConfiguration)
self.onChange(.engineConfiguration, posted)
}
}
lock.lock()
@@ -77,7 +80,8 @@ final class AudioDeviceWatcher {
// (the voice-processing engine, which is the DEFAULT macOS configuration and which no Mac
// here can even initialize). The HAL is told either way.
let block: AudioObjectPropertyListenerBlock = { [weak self] _, _ in
self?.onChange(.defaultOutputDevice) // on the main queue registered against it below
// On the main queue registered against it below. No engine posted this, so nil.
self?.onChange(.defaultOutputDevice, nil)
}
var address = Self.defaultOutputAddress()
let status = AudioObjectAddPropertyListenerBlock(
@@ -42,7 +42,10 @@ public enum AudioDevices {
return channelCount(id, scope: kAudioObjectPropertyScopeInput)
}
private static func defaultInputDevice() -> AudioDeviceID? {
/// The device the system is currently capturing from the key `SessionAudio`'s
/// voice-processing gate latches a start failure against (the failure is a property of the
/// input device, so a new device earns a fresh attempt).
static func defaultInputDevice() -> AudioDeviceID? {
systemDevice(kAudioHardwarePropertyDefaultInputDevice)
}
@@ -0,0 +1,89 @@
// The two policy decisions of the device-change recovery, extracted where a unit test can reach
// them. Both exist because of one field incident (2026-08-14, Mac Studio): the voice-processing
// engine could not start on a 6-channel input device, every rebuild re-tried it, and the failed
// attempt's HAL churn (VPIO builds and tears down an aggregate device) re-stopped the fallback
// engines which posted the configuration change that scheduled the next rebuild. A ~2.5 s
// metronome of audio gaps, forever, with each rebuild also stalling the main thread (where macOS
// input capture lives), so the stream's INPUT cut out on the same beat. The session-side wiring
// lives in `SessionAudio`; the decisions live here because the loop shipped precisely because
// they could not be tested without a mic and a session.
#if os(macOS)
import CoreAudio
#endif
import Foundation
#if os(macOS)
/// Should a rebuild try the combined (voice-processing) topology again?
///
/// A VPIO start failure is a property of the INPUT DEVICE (its channel count and format), not of
/// the moment: retrying it on the same device fails the same way, and the attempt is not free
/// engaging and abandoning the voice processor churns the HAL hard enough to stop the healthy
/// fallback engines. So a failure latches until the default input actually changes; a new device
/// earns exactly one fresh attempt (it may well support VPIO), and its own failure latches again.
struct CombinedTopologyGate {
private var failed = false
/// The default input device the failure was observed on nil is a real value here ("failed
/// with no resolvable input device"), which is why `failed` is tracked separately.
private var failedInput: AudioDeviceID?
/// The combined topology failed with `input` as the default input device.
mutating func noteFailure(input: AudioDeviceID?) {
failed = true
failedInput = input
}
/// True when the combined topology is worth attempting with `input` as the default input
/// device. A device change clears the latch the answer is about the CURRENT hardware, and
/// coming back to a device that failed before earns a fresh attempt too (the failure may have
/// been the mid-transition kind, and one attempt per device change cannot loop).
mutating func shouldTry(input: AudioDeviceID?) -> Bool {
guard failed else { return true }
guard input == failedInput else {
failed = false
failedInput = nil
return true
}
return false
}
}
#endif
/// The delay before the next engine rebuild the base debounce/floor behaviour, plus an
/// escalating floor when rebuilds CHAIN (each one retriggered by its predecessor's own fallout).
///
/// One device switch produces one rebuild: its trigger burst is coalesced upstream, so the next
/// trigger normally arrives minutes later and gets the base floor. A trigger that arrives hard on
/// the heels of the last rebuild, again and again, is a rebuild answering itself and since the
/// recovery cannot always identify its own echo, the backstop is to keep answering but at a
/// doubling floor, so an unforeseen feedback shape costs one audio blip per half-minute instead
/// of a metronome. A quiet stretch resets the ladder to full responsiveness.
struct RebuildBackoff {
/// Let the burst of triggers from one switch land before rebuilding.
static let debounce: TimeInterval = 0.15
/// Floor between two rebuilds.
static let floor: TimeInterval = 0.5
/// The escalated floor's cap: looping recoveries settle at one attempt per this interval.
static let floorCap: TimeInterval = 30
/// A trigger this long after the last rebuild is unrelated to it the chain resets.
static let chainWindow: TimeInterval = 10
/// Consecutive rebuilds whose trigger arrived within `chainWindow` of the previous rebuild.
private(set) var chain = 0
private var lastRebuildAt: TimeInterval = -.infinity
/// The delay to schedule the next rebuild with, for a trigger arriving at `now`
/// (`systemUptime`). Mutates the chain accounting: call once per SCHEDULED rebuild, not per
/// coalesced trigger.
mutating func delay(now: TimeInterval) -> TimeInterval {
let since = now - lastRebuildAt
chain = since < Self.chainWindow ? chain + 1 : 0
let floor = min(Self.floor * pow(2, Double(min(chain, 6))), Self.floorCap)
return max(Self.debounce, floor - since)
}
/// The rebuild actually ran at `now` the reference the next trigger's `delay` measures from.
mutating func noteRebuild(at now: TimeInterval) {
lastRebuildAt = now
}
}
@@ -48,11 +48,17 @@ final class AudioRing: @unchecked Sendable {
/// gaps per 10 minutes at a 5 ms quantum, against 3 at 8 ms and 1 at 16 ms on an identical
/// link. Mirrors `JitterTuning::COREAUDIO.deprime_ms`.
private static let deprimeMS = 60
/// How long a packet DROUGHT may be concealed (`DroughtConceal`) before this ring is allowed
/// to underrun and the hysteresis above is allowed to run: twice that window long enough to
/// ride out the delivery stalls that de-prime rings today, short enough that a genuinely dead
/// stream is not papered over. DERIVED from the fuse rather than written out, so it cannot
/// drift away from the thing it exists to protect. Mirrors `JitterTuning::plc_max_ms`.
static let plcMaxMS = deprimeMS * 2
/// Floor in callbacks under `deprimeMS`, so a large-quantum device keeps real hysteresis
/// instead of de-priming on the first short read. Mirrors `MIN_DEPRIME_CALLBACKS`.
private static let minDeprimeCallbacks = 2
/// The protocol's frame: the shed unit, and the slack added over a large device quantum.
private static let frameMS = 5
static let frameMS = 5
/// Depth average must exceed target by this before drift correction fires the middle of the
/// headroom band, so the smooth shed always gets its chance BEFORE the hard cap trims.
private static let shedExcessMS = 15
@@ -151,6 +157,10 @@ final class AudioRing: @unchecked Sendable {
/// no timestamps, so the drain thread (which has both a packet's `pts_ns` and the video leg)
/// hands the number back for reporting. Mirrors `NativeClient::audio_av_offset_ms`.
private var avOffsetMS = 0
/// Drought concealment the drain thread has synthesized this session, ms STORED here for the
/// same reason `avOffsetMS` is: the ring cannot compute it, but it is where the numbers a
/// listener's complaint needs can be read under one lock.
private var plcMS = 0
private let channels: Int
private let perMS: Int
private let lock = OSAllocatedUnfairLock()
@@ -224,6 +234,16 @@ final class AudioRing: @unchecked Sendable {
avOffsetMS = ms
}
/// Store the drain thread's running drought concealment (`DroughtConceal.totalMS`) for
/// reporting. Concealment that nobody can see is concealment that hides the bug it is
/// covering: a healthy `underruns` bought with a climbing `plc_ms` is a link in trouble, not
/// a link that is fine.
func notePlcMS(_ ms: Int) {
lock.lock()
defer { lock.unlock() }
plcMS = ms
}
/// Buffered depth in interleaved samples what the sync loop measures against (`bufferedMS`
/// is the same quantity rounded for humans). Everything queued here must play before the frame
/// the drain thread is about to write, which is exactly what delays it.
@@ -481,6 +501,10 @@ final class AudioRing: @unchecked Sendable {
/// Reported next to the depth, never instead of it: a deep ring on a jittery link is
/// CORRECT behaviour, and only the offset separates that from a ring holding audio late.
let avOffsetMS: Int
/// Audio synthesized for packet droughts this session (`DroughtConceal`), ms read next
/// to `underruns`, which it exists to prevent, because the two only mean something
/// together.
let plcMS: Int
}
var stats: Stats {
@@ -491,7 +515,8 @@ final class AudioRing: @unchecked Sendable {
targetMS: target / max(perMS, 1),
underruns: underrunCount,
sheds: shedCount,
avOffsetMS: avOffsetMS)
avOffsetMS: avOffsetMS,
plcMS: plcMS)
}
}
@@ -641,6 +666,70 @@ struct AvSync {
}
}
// MARK: - Drought concealment
/// Bounded concealment of a packet DROUGHT the Apple leg of the policy the three Rust clients
/// share (`punktfunk_core::audio::DroughtConceal`; design/host-source-stutter-fixes.md, WP-C1).
///
/// The decode path already conceals a SEQ GAP: core's in-ABI decoder synthesizes the packets the
/// sequence says went missing before the one that arrived (`nextAudioPcm`). But that only fires
/// when a LATER packet arrives to reveal the gap. When the wire simply goes quiet a delivery
/// stall on a bunching Wi-Fi link, or a host whose capture stalled nothing arrives to reveal
/// anything: `AudioRing` drains to empty, the render callback runs short, and `noteRead` de-primes
/// and then re-primes a whole target's worth of fresh silence. The artifact is far longer than the
/// audio actually missing, and this is the shape the 2026-08-15 field session spent 316 % of its
/// wall-clock in.
///
/// So a drought that is draining the ring gets concealed too, from the same decoder state
/// (`PunktfunkConnection.audioPlc`), for a bounded time. Denominated in TIME, never in frames or
/// callbacks: that is the recorded lesson from the very fuse this protects, where a count gave an
/// iPad a third of a Mac's slack (`AudioRing.deprimeMS`, and
/// `testDeprimeFuseIsADurationNotACallbackCount`).
///
/// Time is passed IN, so the policy stays as deterministic as the ring's own.
struct DroughtConceal {
/// A drought must outlast ordinary arrival jitter before anything is synthesized for it: two
/// protocol frames, the same tolerance the host's capture-hole infill uses at the other end.
private static let afterMS = 2 * AudioRing.frameMS
/// and the ring must actually be running out. A drought a deep ring can cover is not audible,
/// and concealing it would synthesize audio the late packets are about to duplicate pushing
/// the whole stream later and handing the drift shed a mess to clean up audibly.
private static let floorMS = 2 * AudioRing.frameMS
/// Concealed since the last real packet.
private var concealedMS = 0
private let maxMS: Int
/// Concealed over the session what the 10 s `plc_ms=` line reports. Concealment must be
/// visible: a policy that quietly papers over a failing link is a policy that hides the bug.
private(set) var totalMS = 0
init(maxMS: Int) {
self.maxMS = maxMS
}
/// A packet arrived, ending any drought the next one starts from a full budget.
///
/// The Rust twin also hands back the frames it concealed, for its caller to subtract from the
/// loss concealment the seq path is about to ask for. Here that subtraction is core's, on the
/// far side of the ABI, because that is where the gap tracker lives (see
/// `punktfunk_connection_audio_plc`) a packet genuinely lost inside a covered drought must
/// not be concealed twice either way.
mutating func packet() {
concealedMS = 0
}
/// Should one more frame be concealed? `depthMS` is the playout ring as the render callback
/// last left it.
mutating func conceal(sinceLastPacketMS: Int, depthMS: Int) -> Bool {
if sinceLastPacketMS < Self.afterMS || depthMS > Self.floorMS || concealedMS >= maxMS {
return false
}
concealedMS += AudioRing.frameMS
totalMS += AudioRing.frameMS
return true
}
}
/// CoreAudio channel layout for the canonical wire order FL FR FC LFE RL RR [SL SR]. nil for
/// stereo (the standard layout is correct). For 5.1/7.1 we list explicit channel labels via
/// `kAudioChannelLayoutTag_UseChannelDescriptions` preset tags (DTS_5_1 etc.) don't reliably
@@ -63,15 +63,24 @@ public final class SessionAudio {
private var micMuted = false
/// The playback jitter ring created by whichever engine starts playback first and KEPT
/// across an engine rebuild (the permission-grant upgrade in `startEngines` swaps engines,
/// not the ring, so the drain thread never has to be re-pointed). Main-thread confined,
/// like every start path.
/// not the ring, so the drain thread never has to be re-pointed). Guarded by `stateLock`:
/// the start paths run on `engineQueue`, while `stats` reads from the main thread.
private var ring: AudioRing?
/// Every engine build, start, stop and rebuild runs here, serially and NOT on the main
/// thread. macOS captures and sends input from the main thread, so the seconds a
/// voice-processing start can take (~1.9 s measured in the 2026-08-14 field loop) would
/// freeze the stream's input for exactly that long the recovery must never make the main
/// thread wait on the audio server. The main queue keeps only the trigger bookkeeping
/// (debounce, backoff, retry ladder), which is cheap by construction.
private let engineQueue = DispatchQueue(
label: "io.unom.punktfunk.audio.engines", qos: .userInitiated)
/// The video plane's end-to-end meter (captureon-glass), if the owner wired one the
/// reference the A/V sync loop steers the ring against. `nil` leaves the loop inert and the
/// ring exactly as it was before sync existed, which is also what the stage-1 fallback
/// presenter gets: it decodes and presents inside the layer with no per-frame stamp, so it can
/// offer no reference, and a loop with no reference must not invent one. Main-thread confined,
/// like `ring`; the meter itself is internally locked and read from the drain thread.
/// offer no reference, and a loop with no reference must not invent one. Written ONCE in
/// `start()` before anything is dispatched (the queue hop orders it for `startDrain`); the
/// meter itself is internally locked and read from the drain thread.
private var videoLatency: LatencyMeter?
#if !os(macOS)
/// AVAudioSession `setCategory`/`setActive` are synchronous and block on the audio server, so
@@ -99,7 +108,8 @@ public final class SessionAudio {
// MARK: - Device changes (see `installDeviceChangeRecovery`)
/// What `start()` was asked for, so a rebuild can put back the SAME topology the session was
/// started with. Main-thread confined, like the start paths that read it.
/// started with. Guarded by `stateLock` (written on the caller's thread, read when a rebuild
/// fires on the main queue).
private var startConfig: StartConfig?
private struct StartConfig {
let speakerUID: String
@@ -110,20 +120,23 @@ public final class SessionAudio {
}
/// Watches the hardware for us (see `AudioDeviceWatcher`). Guarded by `stateLock`.
private var deviceWatcher: AudioDeviceWatcher?
/// Whether the engines have been built at least once. Distinguishes "not started yet" (iOS
/// starts asynchronously) from "started and dead", which is what the recovery may act on.
/// Main-thread confined.
/// Whether the engines have been built at least once. Distinguishes "not started yet" (every
/// platform starts asynchronously now) from "started and dead", which is what the recovery
/// may act on. Guarded by `stateLock` (set on `engineQueue`, read on the main queue).
private var enginesAttempted = false
/// A rebuild is already on the main queue one device switch produces a burst of triggers
/// and they must collapse into one restart. Main-thread confined.
private var rebuildQueued = false
/// `systemUptime` of the last rebuild, so a device that renegotiates in a loop cannot spin
/// the session. Main-thread confined.
private var lastRebuildAt: TimeInterval = 0
/// Let the burst of triggers from one switch land before rebuilding.
private static let rebuildDebounce: TimeInterval = 0.15
/// Floor between two rebuilds.
private static let rebuildFloor: TimeInterval = 0.5
/// Debounce/floor for the next rebuild, with an escalating floor when rebuilds chain (each
/// retriggered by its predecessor see `RebuildBackoff`). Main-thread confined.
private var rebuildBackoff = RebuildBackoff()
#if os(macOS)
/// Latches a voice-processing start failure per input device, so a rebuild never re-attempts
/// a topology that deterministically fails the retry is what turned one failure into a
/// rebuild loop (see `CombinedTopologyGate` and the note on `installDeviceChangeRecovery`).
/// `engineQueue`-confined, like the start paths that consult and feed it.
private var combinedGate = CombinedTopologyGate()
#endif
/// Retries when a rebuild's `start()` loses the race with a device that is still going away
/// (0.3 s, 0.6 s, 1.2 s). A failed rebuild leaves no engine to post the next notification,
/// so this ladder and, on macOS, the HAL listener is all that stands between a mistimed
@@ -151,11 +164,12 @@ public final class SessionAudio {
}
/// Start playback (and, if enabled+authorized, the mic uplink). Empty UIDs = system default
/// device; on iOS the UIDs are ignored entirely (routes are AVAudioSession-managed). On macOS
/// the engines start synchronously on the caller's (main) thread. On iOS/tvOS start() is
/// ASYNCHRONOUS: it activates the AVAudioSession off the main thread, then starts the engines on
/// a later main-queue hop (gated by `!flag.isStopped`) so playback is live shortly after, not
/// on return. The mic may start later still if the permission prompt is pending.
/// device; on iOS the UIDs are ignored entirely (routes are AVAudioSession-managed).
/// ASYNCHRONOUS on every platform: the engines start on `engineQueue` (iOS/tvOS activate the
/// AVAudioSession off the main thread first), gated by `!flag.isStopped` so playback is
/// live shortly after, not on return. An engine start can block on the audio server for
/// seconds, and the caller's (main) thread is where macOS input capture lives it must
/// never wait. The mic may start later still if the permission prompt is pending.
/// `echoCancel` picks the engine topology see the header note and `wantsCombined`.
///
/// `videoLatency` is the session's END-TO-END latency meter (captureon-glass). Pass it to arm
@@ -166,26 +180,33 @@ public final class SessionAudio {
speakerUID: String, micUID: String, micChannel: Int, micEnabled: Bool, echoCancel: Bool,
videoLatency: LatencyMeter? = nil
) {
self.videoLatency = videoLatency
self.videoLatency = videoLatency // before any dispatch below startDrain reads it
// Before any engine exists: the recovery watches the hardware, not the engines, and the
// config it rebuilds from has to be recorded whether or not this start succeeds.
stateLock.lock()
startConfig = StartConfig(
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
micEnabled: micEnabled, echoCancel: echoCancel)
stateLock.unlock()
installDeviceChangeRecovery(micEnabled: micEnabled)
#if os(macOS)
// No AVAudioSession on macOS start the engines directly (caller's thread, as before).
startEngines(
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
micEnabled: micEnabled, echoCancel: echoCancel)
// No AVAudioSession on macOS but the engines start on `engineQueue`, never the
// caller's (main) thread: a voice-processing start can block on the audio server for
// seconds, and the main thread is where input capture lives.
engineQueue.async { [weak self] in
guard let self, !self.flag.isStopped else { return }
self.startEngines(
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
micEnabled: micEnabled, echoCancel: echoCancel)
}
#else
// Configure + activate the session OFF the main thread (it blocks on the audio server),
// then start the engines back on the main thread once it's active engine routing/format
// then start the engines on `engineQueue` once it's active engine routing/format
// depend on the active session. A stop() racing in between is caught by the flag guard.
Self.sessionQueue.async { [weak self] in
guard let self else { return }
self.activateAudioSession(micEnabled: micEnabled)
DispatchQueue.main.async { [weak self] in
self.engineQueue.async { [weak self] in
guard let self, !self.flag.isStopped else { return }
self.startEngines(
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
@@ -342,12 +363,15 @@ public final class SessionAudio {
#endif
/// Build + start the engines combined (voice-processed) or split, per `wantsCombined`
/// with the mic uplink only when enabled + authorized. Main thread (engine setup); on
/// iOS/tvOS the session is already active by the time this runs.
/// with the mic uplink only when enabled + authorized. Runs on `engineQueue` (a start can
/// block on the audio server for seconds never the main thread); on iOS/tvOS the session
/// is already active by the time this runs.
private func startEngines(
speakerUID: String, micUID: String, micChannel: Int, micEnabled: Bool, echoCancel: Bool
) {
stateLock.lock()
enginesAttempted = true // even if every path below fails see `reviveStoppedEngines`
stateLock.unlock()
#if os(tvOS)
// No app-accessible microphone input on tvOS playback only.
startPlayback(speakerUID: speakerUID)
@@ -356,9 +380,25 @@ public final class SessionAudio {
startPlayback(speakerUID: speakerUID)
return
}
#if os(macOS)
// A rebuild must not re-attempt a voice-processing start that already failed on this
// input device: the failure repeats, and the failed attempt's HAL churn stops the healthy
// fallback engines the 2026-08-14 rebuild loop (see `CombinedTopologyGate`).
var combined = wantsCombined(
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
echoCancel: echoCancel)
if combined, !combinedGate.shouldTry(input: AudioDevices.defaultInputDevice()) {
log.info("""
voice processing already failed on this input device split engines, no echo \
cancellation
""")
combined = false
}
#else
let combined = wantsCombined(
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
echoCancel: echoCancel)
#endif
switch AVCaptureDevice.authorizationStatus(for: .audio) {
case .authorized:
if combined {
@@ -374,7 +414,8 @@ public final class SessionAudio {
// drain thread carry over see `makePlaybackChain`).
startPlayback(speakerUID: speakerUID)
AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in
DispatchQueue.main.async {
guard let self else { return }
self.engineQueue.async { [weak self] in
guard let self, granted, !self.flag.isStopped else { return }
if combined {
self.stateLock.lock()
@@ -513,6 +554,17 @@ public final class SessionAudio {
/// - the route-change and media-services-reset notifications, iOS/tvOS, where the session and
/// not the device is what moves.
///
/// And three defenses keep the recovery from ANSWERING ITSELF a rebuild is not a silent
/// act (a voice-processing start builds and tears down HAL aggregates, and every fresh engine
/// renegotiates its IO), so its own fallout can retrigger it. The 2026-08-14 field loop was
/// exactly that: VPIO failed on a 6-channel mic, every rebuild re-tried it, and the failure's
/// churn stopped the fallback engines audio and (via the main thread) INPUT cutting out
/// every ~2.5 s for the whole session. The defenses: a configuration change from an engine
/// that is RUNNING is a rebuild's echo and is ignored (`hardwareMoved`); a VPIO failure is
/// latched per input device and never re-attempted on it (`CombinedTopologyGate`); and
/// rebuilds that chain anyway back off exponentially instead of metronoming
/// (`RebuildBackoff`).
///
/// `micEnabled` only decides whether the mic-bearing session observers are worth installing.
/// Main thread.
private func installDeviceChangeRecovery(micEnabled: Bool) {
@@ -523,7 +575,7 @@ public final class SessionAudio {
let watcher = AudioDeviceWatcher(
isOurs: { [weak self] posted in self?.ownsEngine(posted) ?? false },
onChange: { [weak self] reason in self?.hardwareMoved(reason) })
onChange: { [weak self] reason, posted in self?.hardwareMoved(reason, posted: posted) })
stateLock.lock()
deviceWatcher = watcher
stateLock.unlock()
@@ -549,10 +601,17 @@ public final class SessionAudio {
/// question is playback still where it should be but they answer it differently: an engine
/// that told us it stopped is definitive, while the default device moving might not concern us
/// at all.
private func hardwareMoved(_ reason: AudioDeviceWatcher.Reason) {
private func hardwareMoved(_ reason: AudioDeviceWatcher.Reason, posted: AnyObject?) {
guard !flag.isStopped else { return }
switch reason {
case .engineConfiguration:
// The engine stops itself BEFORE posting this so an engine that is RUNNING when the
// notification lands on the main queue is one a rebuild already replaced or restarted:
// the notification is the rebuild's own echo, and answering it is how the recovery
// loops. A change that stops the engine again after this posts again, and the HAL
// backstop checks placement independently, so ignoring a live engine's echo can never
// strand a stopped one.
if let engine = posted as? AVAudioEngine, engine.isRunning { return }
scheduleEngineRebuild(reason: reason.rawValue)
case .defaultOutputDevice:
#if os(macOS)
@@ -572,7 +631,10 @@ public final class SessionAudio {
/// output device at the moment it connected and leaving it silent for good. On iOS the same
/// flag keeps this from racing the asynchronous start, where no engine yet is normal.
private func reviveStoppedEngines(_ reason: String) {
guard !flag.isStopped, enginesAttempted, !playbackIsLive else { return }
stateLock.lock()
let attempted = enginesAttempted
stateLock.unlock()
guard !flag.isStopped, attempted, !playbackIsLive else { return }
scheduleEngineRebuild(reason: "playback is stopped and \(reason)")
}
@@ -594,15 +656,43 @@ public final class SessionAudio {
private func scheduleEngineRebuild(reason: String) {
guard !rebuildQueued else { return }
rebuildQueued = true
let since = ProcessInfo.processInfo.systemUptime - lastRebuildAt
let delay = max(Self.rebuildDebounce, Self.rebuildFloor - since)
log.info("\(reason) — restarting the audio engines in \(Int(delay * 1000)) ms")
let delay = rebuildBackoff.delay(now: ProcessInfo.processInfo.systemUptime)
if rebuildBackoff.chain >= 2 {
// Each rebuild is retriggering the next a feedback shape the echo guard and the
// topology gate did not identify. Keep answering (a real recovery must not be
// abandoned), but say what is happening: this line repeating IS the diagnosis.
log.warning("""
audio engine rebuilds are chaining (\(self.rebuildBackoff.chain) in a row \
\(reason)); backing off \(Int(delay * 1000)) ms
""")
} else {
log.info("\(reason) — restarting the audio engines in \(Int(delay * 1000)) ms")
}
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.rebuildEngines(attempt: 0)
self?.rebuildFire(attempt: 0)
}
}
/// The scheduled rebuild came due (main queue): close out the bookkeeping and hand the
/// actual engine work to `engineQueue` the teardown + start can block on the audio server
/// for seconds, and the main thread is where macOS captures and sends the stream's input.
/// A trigger arriving while the work is in flight schedules a fresh rebuild rather than
/// being swallowed; `engineQueue` is serial, so the two never interleave.
private func rebuildFire(attempt: Int) {
rebuildQueued = false
guard !flag.isStopped else { return }
stateLock.lock()
let config = startConfig
stateLock.unlock()
guard let config else { return }
rebuildBackoff.noteRebuild(at: ProcessInfo.processInfo.systemUptime)
engineQueue.async { [weak self] in
self?.performRebuild(config: config, attempt: attempt)
}
}
/// Put back the topology this session was started with, on whatever hardware is there now.
/// Runs on `engineQueue`.
///
/// A full rebuild rather than a `start()` on the stopped engine, because the mic side has to
/// follow too: `installMicTap` reads the input's live format, and the voice processor
@@ -610,10 +700,8 @@ public final class SessionAudio {
/// across (`makePlaybackChain` reuses it, `startDrain` is idempotent), so the drain thread
/// keeps decoding right through the switch and its overflow policy has already dropped
/// everything that went stale while the engine was down.
private func rebuildEngines(attempt: Int) {
rebuildQueued = false
guard !flag.isStopped, let config = startConfig else { return }
lastRebuildAt = ProcessInfo.processInfo.systemUptime
private func performRebuild(config: StartConfig, attempt: Int) {
guard !flag.isStopped else { return }
tearDownEngines()
startEngines(
speakerUID: config.speakerUID, micUID: config.micUID, micChannel: config.micChannel,
@@ -626,6 +714,18 @@ public final class SessionAudio {
log.info("audio engines restarted on the current device")
return
}
DispatchQueue.main.async { [weak self] in
self?.rebuildFailed(attempt: attempt)
}
}
/// A rebuild's playback did not come back (main queue) walk the retry ladder. Retries
/// when a rebuild's `start()` loses the race with a device that is still going away
/// (0.3 s, 0.6 s, 1.2 s): a failed rebuild leaves no engine to post the next notification,
/// so this ladder and, on macOS, the HAL listener is all that stands between a mistimed
/// switch and a silent session.
private func rebuildFailed(attempt: Int) {
guard !flag.isStopped else { return }
guard attempt < Self.rebuildAttempts else {
#if os(macOS)
log.error("""
@@ -637,10 +737,11 @@ public final class SessionAudio {
#endif
return
}
guard !rebuildQueued else { return } // a fresh trigger already queued a full rebuild
rebuildQueued = true // holds off a trigger that would only race this ladder
let delay = Self.rebuildDebounce * Double(1 << (attempt + 1))
let delay = RebuildBackoff.debounce * Double(1 << (attempt + 1))
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.rebuildEngines(attempt: attempt + 1)
self?.rebuildFire(attempt: attempt + 1)
}
}
@@ -786,9 +887,13 @@ public final class SessionAudio {
public let avOffsetMS: Int
}
/// A snapshot of `Stats`, or nil before playback starts. Main thread (`ring` is main-confined;
/// the ring's own numbers are taken under its lock, so they describe one instant).
/// A snapshot of `Stats`, or nil before playback starts. Safe from any thread (the handle is
/// taken under `stateLock`; the ring's own numbers are taken under its lock, so they
/// describe one instant).
public var stats: Stats? {
stateLock.lock()
let ring = self.ring
stateLock.unlock()
guard let s = ring?.stats else { return nil }
return Stats(bufferMS: s.bufferedMS, avOffsetMS: s.avOffsetMS)
}
@@ -813,7 +918,7 @@ public final class SessionAudio {
/// The playback jitter ring + the source node draining it shared by the plain playback
/// engine and the combined voice-processing engine, and REUSED across an engine rebuild
/// (same session, same ring: the drain thread keeps writing right through the swap). nil
/// when the host's channel layout can't be expressed (already logged). Main thread.
/// when the host's channel layout can't be expressed (already logged). Runs on `engineQueue`.
private func makePlaybackChain()
-> (ring: AudioRing, source: AVAudioSourceNode, format: AVAudioFormat)?
{
@@ -823,8 +928,10 @@ public final class SessionAudio {
// 1 s interleaved capacity, scaled by the channel count. The de-jitter depth itself is
// the ring's own business now (`AudioRing.targetMS`, mirroring `JitterTuning::COREAUDIO`)
// rather than a prefill passed in here.
stateLock.lock()
let ring = self.ring ?? AudioRing(capacity: 48_000 * channels, channels: channels)
self.ring = ring
stateLock.unlock()
// Engine-native deinterleaved float; the render block deinterleaves from the ring. Surround
// uses an explicit wire-order channel layout; the mixer downmixes to the output device when
@@ -923,6 +1030,16 @@ public final class SessionAudio {
defer { drainDone.signal() }
var drained = 0
var av = AvSync(channels: channels)
// WP-C1 the drought half of concealment. Core heals a SEQ GAP, but only when a later
// packet arrives to reveal it; when the wire simply goes quiet nothing arrives to
// reveal anything, and the ring drains into an underrun and a de-prime whose re-prime
// is a longer artifact than the audio that was missing.
var drought = DroughtConceal(maxMS: AudioRing.plcMaxMS)
var lastPacketNs = DispatchTime.now().uptimeNanoseconds
// Something has decoded, so there is both state to conceal from and continuity to
// hold. Until then a session whose host never sends audio keeps the long timeout below
// rather than waking two hundred times a second to do nothing.
var decoded = false
// Decode happens IN-CORE (libopus multistream) AudioToolbox's Opus path is
// stereo-only and is handed back as interleaved f32 PCM in wire channel order.
// Per-iteration autorelease pool: no runloop on this thread (see Stage2Pipeline).
@@ -931,11 +1048,48 @@ public final class SessionAudio {
alive = autoreleasepool { () -> Bool in
let pcm: PunktfunkConnection.AudioPCM?
do {
pcm = try connection.nextAudioPcm(timeoutMs: 100)
// Wait at most one frame WHILE there is a stream to protect: the drought
// decision has to be made on the wire's schedule, not whenever the next packet
// happens to turn up.
pcm = try connection.nextAudioPcm(
timeoutMs: decoded ? UInt32(AudioRing.frameMS) : 100)
} catch {
return false // session closed
}
guard let pcm, pcm.frameCount > 0 else { return true }
guard let pcm, pcm.frameCount > 0 else {
// Nothing on the wire. If the ring is draining with it, conceal from the
// decoder's own state the same libopus interpolation the loss path uses,
// bounded by this ring's de-prime fuse so a genuinely dead stream is not
// papered over. ONE frame per tick, not a burst: this arm runs every frame,
// which is the rate the callback drains at, so concealment keeps pace with
// playout instead of racing ahead of a depth reading it has already
// invalidated.
guard decoded else { return true }
let quietMS = Int(
(DispatchTime.now().uptimeNanoseconds &- lastPacketNs) / 1_000_000)
guard drought.conceal(sinceLastPacketMS: quietMS, depthMS: ring.bufferedMS)
else {
return true
}
let plc: PunktfunkConnection.AudioPCM?
do {
plc = try connection.audioPlc()
} catch {
return false // session closed
}
if let plc {
plc.samples.withUnsafeBufferPointer { p in
if let base = p.baseAddress {
ring.write(base, count: plc.frameCount * plc.channels)
}
}
}
ring.notePlcMS(drought.totalMS)
return true
}
decoded = true
lastPacketNs = DispatchTime.now().uptimeNanoseconds
drought.packet()
// Place this frame against the picture it belongs with BEFORE queueing it: the
// depth read here is everything that must still play first, which is exactly what
// delays it. Skipped wholesale when no meter was wired, so an un-armed session
@@ -963,12 +1117,14 @@ public final class SessionAudio {
// Periodic vitals (~10 s at the protocol's 5 ms frames). The other three clients
// log buffer depth and underruns; without this an Apple audio report latency or
// dropout arrives with no numbers at all, which is the position every platform
// was in before the 2026-08 audio work.
// was in before the 2026-08 audio work. `plc_ms` rides along because a healthy
// `underruns` bought with a climbing `plc_ms` is a link in trouble, not a link
// that is fine.
drained += 1
if drained % 2_000 == 0 {
let s = ring.stats
log.info(
"audio: buffer_ms=\(s.bufferedMS) target_ms=\(s.targetMS) underruns=\(s.underruns) drift_sheds=\(s.sheds) av_offset_ms=\(s.avOffsetMS)"
"audio: buffer_ms=\(s.bufferedMS) target_ms=\(s.targetMS) underruns=\(s.underruns) drift_sheds=\(s.sheds) av_offset_ms=\(s.avOffsetMS) plc_ms=\(s.plcMS)"
)
}
return true
@@ -983,6 +1139,17 @@ public final class SessionAudio {
// MARK: - Mic (mic host)
#if !os(tvOS)
/// The combined topology failed to come up. On macOS, latch the input device it failed on so
/// a rebuild goes straight to the split topology instead of re-running the failure the
/// failed attempt is what churns the HAL and retriggers the recovery (see
/// `CombinedTopologyGate`). On iOS routes are session-managed and a VPIO failure is the
/// transient route-transition kind, so nothing is latched there.
private func noteCombinedFailure() {
#if os(macOS)
combinedGate.noteFailure(input: AudioDevices.defaultInputDevice())
#endif
}
/// One engine, both directions: engage the system voice processor on the shared IO unit
/// (AEC + noise suppression + AGC), hang the playback source off its render side and the
/// mic tap off its capture side. Every failure falls back to a WORKING configuration
@@ -1001,6 +1168,7 @@ public final class SessionAudio {
voice processing unavailable (\(error.localizedDescription)) separate \
engines, no echo cancellation
""")
noteCombinedFailure()
startPlayback(speakerUID: speakerUID)
startCapture(micUID: micUID, micChannel: micChannel)
return
@@ -1054,6 +1222,7 @@ public final class SessionAudio {
// processor won't engage at all, already does exactly this; this arm used to give up
// on the mic instead, which is how a whole session could go silent uplink-only.)
engine.stop()
noteCombinedFailure()
startPlayback(speakerUID: speakerUID)
startCapture(micUID: micUID, micChannel: micChannel)
return
@@ -1064,6 +1233,7 @@ public final class SessionAudio {
log.error("combined engine failed to start: \(error.localizedDescription)")
engine.inputNode.removeTap(onBus: 0)
engine.stop()
noteCombinedFailure()
// Same rule: a working mic without echo cancellation beats no mic at all.
startPlayback(speakerUID: speakerUID)
startCapture(micUID: micUID, micChannel: micChannel)
@@ -0,0 +1,76 @@
// The shared clipboard's format vocabulary (design/clipboard-and-file-transfer.md §3.5), stated
// once for AppKit and UIKit alike.
//
// Every Apple pasteboard type in the table IS a uniform type identifier, and both frameworks name
// them with the same strings `NSPasteboard.PasteboardType.png` and the UIPasteboard type
// `"public.png"` are the same bytes. Keeping the table as plain strings is therefore not a
// lowest-common-denominator compromise; it is the actual shared spelling, and it keeps the two
// platform adapters from drifting apart in what they announce.
#if !os(tvOS)
import Foundation
enum ClipboardFormats {
/// Wire MIME uniform type identifier, in announce order. Files
/// (`application/x-punktfunk-files`) ride Phase 2 and are absent here.
///
/// Original image formats sit beside the mandatory `image/png` floor rather than replacing it:
/// a copied JPEG never balloons into PNG and a GIF keeps its animation, while a peer that can
/// only place PNG still has something to take.
static let table: [(wire: String, uti: String)] = [
("text/plain;charset=utf-8", "public.utf8-plain-text"),
("text/rtf", "public.rtf"),
("text/html", "public.html"),
("image/png", "public.png"),
("image/jpeg", "public.jpeg"),
("image/gif", "com.compuserve.gif"),
]
/// Pasteboard marker types that must never cross the wire password managers mark secrets
/// with these (see nspasteboard.org). A Mac convention that costs nothing to honour on iOS:
/// the cross-platform managers set them there too, and a pasteboard that carries neither is
/// unaffected.
static let concealed = "org.nspasteboard.ConcealedType"
static let transient = "org.nspasteboard.TransientType"
/// Image types we do not announce verbatim but CAN serve `image/png` from by transcoding at
/// fetch time screenshots and Preview leave TIFF, the camera roll leaves HEIC.
static let pngSources = ["public.tiff", "public.heic"]
static func uti(forWire wire: String) -> String? {
table.first { $0.wire == wire }?.uti
}
static func wire(forUti uti: String) -> String? {
table.first { $0.uti == uti }?.wire
}
/// True when the pasteboard is carrying a secret and must be ignored entirely.
static func isConcealed(_ types: [String]) -> Bool {
types.contains(concealed) || types.contains(transient)
}
/// The format list to announce for a pasteboard holding `types` the lazy offer's whole
/// payload (§3.2). Empty means "nothing we sync", which legitimately clears the peer's side.
static func offerKinds(forTypes types: [String]) -> [PunktfunkConnection.ClipKind] {
var kinds = table
.filter { types.contains($0.uti) }
.map { PunktfunkConnection.ClipKind(mime: $0.wire) }
// PNG floor: announce the portable `image/png` whenever ANY convertible image is present
// native PNG, TIFF/HEIC, or a JPEG/GIF original already being offered verbatim above. The
// adapters convert at fetch time, so the fallback costs nothing unless a peer pastes it.
if !kinds.contains(where: { $0.mime == "image/png" }),
types.contains(where: { pngSources.contains($0) })
|| kinds.contains(where: { $0.mime.hasPrefix("image/") })
{
kinds.append(PunktfunkConnection.ClipKind(mime: "image/png"))
}
return kinds
}
/// The uniform types to place for a remote offer, in the table's order and skipping kinds this
/// client has no mapping for (files, and whatever a future host learns to offer).
static func placeableUtis(for kinds: [PunktfunkConnection.ClipKind]) -> [String] {
kinds.compactMap { uti(forWire: $0.mime) }
}
}
#endif
@@ -0,0 +1,115 @@
// The macOS half of the clipboard seam: `NSPasteboard.general`.
//
// AppKit's lazy-paste contract is a blocking one `provideDataForType` is called on a provider
// thread the moment a Mac app pastes, and whatever the item holds when that call returns is what
// the app gets. So this adapter is the one place that turns the asynchronous fetch into a wait.
#if os(macOS)
import AppKit
import Foundation
typealias SystemPasteboard = AppKitPasteboard
final class AppKitPasteboard: ClipboardPasteboard {
private let pb = NSPasteboard.general
private var activationObserver: NSObjectProtocol?
/// The provider backing the offer currently on the pasteboard. AppKit's own reference to it is
/// not something to rely on: nothing crosses if it is collected before the user pastes.
private var provider: BlockingOfferProvider?
var changeCount: Int { pb.changeCount }
var typeIdentifiers: [String] { (pb.types ?? []).map(\.rawValue) }
/// Read one wire format, converting where macOS stores a different native type: `image/png` is
/// served from a real `.png` entry when present, else converted from whatever image
/// representation the pasteboard holds (TIFF from screenshots and Preview, WebP/AVIF/GIF from
/// browsers `NSImage` decodes them all) into PNG at fetch time.
func read(wire: String) -> Data? {
guard wire == "image/png" else {
guard let uti = ClipboardFormats.uti(forWire: wire) else { return nil }
return pb.data(forType: NSPasteboard.PasteboardType(uti))
}
if let png = pb.data(forType: .png) {
return png
}
guard let img = NSImage(pasteboard: pb),
let tiff = img.tiffRepresentation,
let rep = NSBitmapImageRep(data: tiff)
else {
return nil
}
return rep.representation(using: .png, properties: [:])
}
func installLazy(utis: [String], fetch: @escaping ClipboardFetch) -> Int {
let provider = BlockingOfferProvider(fetch: fetch)
let item = NSPasteboardItem()
item.setDataProvider(provider, forTypes: utis.map { NSPasteboard.PasteboardType($0) })
pb.clearContents()
pb.writeObjects([item])
self.provider = provider
return pb.changeCount
}
/// Unused on macOS a promise here outlives any paste that might come, so there is never
/// cause to resolve one early. Implemented anyway so the seam has no platform-shaped hole.
func installResolved(_ items: [(uti: String, data: Data)]) -> Int {
let item = NSPasteboardItem()
for (uti, data) in items {
item.setData(data, forType: NSPasteboard.PasteboardType(uti))
}
pb.clearContents()
pb.writeObjects([item])
provider = nil
return pb.changeCount
}
func clear() -> Int {
pb.clearContents()
provider = nil
return pb.changeCount
}
/// A Mac keeps running after a session ends, but the promise dies with the sync regardless, so
/// there is nothing to be gained by spending a round-trip on it at teardown clearing leaves
/// the user exactly where they were.
let resolvesPendingOfferOnTeardown = false
func startObserving(onActivate: @escaping () -> Void) {
activationObserver = NotificationCenter.default.addObserver(
forName: NSApplication.didBecomeActiveNotification, object: nil, queue: nil
) { _ in onActivate() }
}
func stopObserving() {
if let activationObserver {
NotificationCenter.default.removeObserver(activationObserver)
self.activationObserver = nil
}
}
}
/// The lazy paste hook: AppKit calls `provideDataForType` only when a Mac app actually pastes; the
/// fetch then blocks this provider thread (never main) until the host's bytes arrive. On timeout
/// or a dead session it provides nothing, so the paste inserts nothing rather than hanging.
private final class BlockingOfferProvider: NSObject, NSPasteboardItemDataProvider {
private let fetch: ClipboardFetch
init(fetch: @escaping ClipboardFetch) {
self.fetch = fetch
}
func pasteboard(
_ pasteboard: NSPasteboard?, item: NSPasteboardItem,
provideDataForType type: NSPasteboard.PasteboardType
) {
guard let wire = ClipboardFormats.wire(forUti: type.rawValue) else { return }
let box = ClipboardResultBox()
fetch(wire) { box.settle($0) }
// The fetch enforces its own deadline and always completes; this is only a backstop
// against a lost completion wedging an AppKit thread forever.
guard let data = box.wait(timeout: ClipboardSync.fetchTimeout + 2) else { return }
item.setData(data, forType: type)
}
}
#endif
@@ -0,0 +1,121 @@
// The iOS/iPadOS half of the clipboard seam: `UIPasteboard.general`.
//
// Two things differ from AppKit in ways that shape the code here.
//
// **Laziness is asynchronous.** UIKit promises data with `NSItemProvider`, whose load handler is
// handed a completion rather than a return value, so this adapter passes the fetch straight
// through no thread is blocked waiting for a paste to resolve.
//
// **Reading the pasteboard is a privacy event.** Since iOS 14 the system tells the user when an
// app reads pasteboard *contents*, and since iOS 16 it asks first when the content came from
// another app. Reading *metadata* the change count, the list of type identifiers does not.
// That maps exactly onto the lazy design: the announce poll only ever looks at metadata, so it is
// silent no matter how long a session runs, and the one moment a read really happens is when
// someone on the host pastes, which is a deliberate act the user is present for.
#if !os(tvOS) && !os(macOS) && canImport(UIKit)
import Foundation
import UIKit
import UniformTypeIdentifiers
typealias SystemPasteboard = UIKitPasteboard
final class UIKitPasteboard: ClipboardPasteboard {
private let pb = UIPasteboard.general
private var activationObserver: NSObjectProtocol?
var changeCount: Int { pb.changeCount }
/// `types` reports the identifiers present without touching a single byte of content, so the
/// poll costs the user nothing and raises no banner.
var typeIdentifiers: [String] { pb.types }
/// Read one wire format, converting where iOS stores a different native type: `image/png` is
/// served from a real PNG entry when present, else re-encoded from whatever image the
/// pasteboard holds a photo copied out of Photos is HEIC, a screenshot may arrive as TIFF,
/// and neither is something a host can be expected to place.
///
/// This is the one call that reads contents, and on iOS 16+ it can put a permission alert in
/// front of the user and wait for their answer. `ClipboardSync` calls it off the drain thread
/// for exactly that reason.
func read(wire: String) -> Data? {
guard wire == "image/png" else {
guard let uti = ClipboardFormats.uti(forWire: wire) else { return nil }
if let data = pb.data(forPasteboardType: uti) {
return data
}
// UIPasteboard stores plain text as a string rather than a data representation often
// enough that the typed read comes back empty on content we can plainly see.
guard uti == UTType.utf8PlainText.identifier else { return nil }
return pb.string?.data(using: .utf8)
}
if let png = pb.data(forPasteboardType: UTType.png.identifier) {
return png
}
return pb.image?.pngData()
}
func installLazy(utis: [String], fetch: @escaping ClipboardFetch) -> Int {
let provider = NSItemProvider()
for uti in utis {
guard let type = UTType(uti), let wire = ClipboardFormats.wire(forUti: uti) else {
continue
}
provider.registerDataRepresentation(for: type, visibility: .all) { completion in
fetch(wire) { data in
if let data {
completion(data, nil)
} else {
completion(nil, ClipboardOfferError.unavailable)
}
}
return nil
}
}
// `localOnly`: these bytes do not exist on this device yet they are a promise against a
// session that is about to end. Handing that to Universal Clipboard would either force an
// eager pull of everything the host ever copies or strand another device with a promise
// nothing can answer.
pb.setItemProviders([provider], localOnly: true, expirationDate: nil)
return pb.changeCount
}
func installResolved(_ items: [(uti: String, data: Data)]) -> Int {
var representations: [String: Any] = [:]
for (uti, data) in items {
representations[uti] = data
}
pb.setItems([representations], options: [.localOnly: true])
return pb.changeCount
}
func clear() -> Int {
pb.items = []
return pb.changeCount
}
/// Backgrounding the app ends the session (see `ContentView`'s scenePhase driver), and with it
/// any hope of answering a promise so an offer the user has not pasted yet is pulled down to
/// real bytes while the connection is still open.
let resolvesPendingOfferOnTeardown = true
func startObserving(onActivate: @escaping () -> Void) {
activationObserver = NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification, object: nil, queue: nil
) { _ in onActivate() }
}
func stopObserving() {
if let activationObserver {
NotificationCenter.default.removeObserver(activationObserver)
self.activationObserver = nil
}
}
}
/// What a lazy representation reports when the host cannot supply it a stale offer, a timed-out
/// fetch, or a session that ended. UIKit shows the paste as producing nothing, which is the same
/// outcome AppKit gets by providing no data.
enum ClipboardOfferError: Error {
case unavailable
}
#endif
@@ -0,0 +1,110 @@
// The platform seam under `ClipboardSync`: everything that actually touches NSPasteboard or
// UIPasteboard, and nothing else.
//
// The sync logic above this protocol the drain thread, the offer sequence numbers, the pending
// fetches a blocked paste waits on, echo suppression is identical on macOS and iOS and is worth
// having exactly one copy of. What genuinely differs is small and lives in the two adapters:
// AppKit fulfils a paste by BLOCKING a provider thread, UIKit by answering an asynchronous load
// handler; AppKit transcodes images through NSImage, UIKit through UIImage; and only UIKit has to
// worry about the process being suspended out from under an offer it promised to serve.
#if !os(tvOS)
import Foundation
/// Pulls the bytes of one lazily-offered wire format from the host. Called on whichever thread the
/// OS fulfils a paste on never the drain thread, which has to stay free to deliver the very
/// chunks this fetch is waiting for and answers asynchronously.
typealias ClipboardFetch = (_ wire: String, _ completion: @escaping (Data?) -> Void) -> Void
/// The system pasteboard, as much of it as the shared clipboard needs.
///
/// Calls arrive from the drain thread, the serve queue, and the thread tearing the sync down;
/// `ClipboardSync` serializes them with its own lock, so an adapter need not be internally
/// synchronized. It must not, however, block on the **main** queue: that lock is also taken from
/// main, and a `main.sync` under it would deadlock.
protocol ClipboardPasteboard: AnyObject {
/// Monotonic per pasteboard write, by anyone. Reading it must never count as reading the
/// pasteboard's *contents* on iOS that distinction is the difference between a silent poll
/// and a system paste banner on every tick.
var changeCount: Int { get }
/// The uniform type identifiers currently on the pasteboard. Must be answerable WITHOUT
/// reading contents, for the same reason.
var typeIdentifiers: [String] { get }
/// Bytes for one wire format, read from the live pasteboard and transcoded where the system
/// stores a different native type (`image/png` from a TIFF screenshot). Nil when the format
/// is not really there. This one DOES read contents.
func read(wire: String) -> Data?
/// Replace the pasteboard with a single item advertising `utis`, each backed by `fetch` the
/// bytes cross only if something actually pastes. Returns the resulting `changeCount`, which
/// the caller records so it can tell its own write apart from the user's next copy.
func installLazy(utis: [String], fetch: @escaping ClipboardFetch) -> Int
/// Replace the pasteboard with concrete bytes. Only iOS needs this (see
/// `ClipboardSync.resolvePendingOffer`); on macOS a lazy promise outlives any paste that
/// might come, so the adapter there never has cause to call it.
func installResolved(_ items: [(uti: String, data: Data)]) -> Int
/// Empty the pasteboard. Returns the resulting `changeCount`.
func clear() -> Int
/// Whether a host offer still sitting unresolved on the pasteboard should be pulled down to
/// concrete bytes as the sync is torn down, instead of being dropped.
///
/// This is the difference between the two platforms' idea of how long a promise lives. A Mac
/// keeps running long after a session ends, but the promise dies with the sync either way, so
/// AppKit clears it and the user loses nothing they had before. On iOS the teardown IS the
/// user leaving backgrounding ends the session and "copy on the host, then paste into
/// Safari" is the whole point of the feature on a tablet, so those bytes have to be made real
/// while the connection that can still supply them is open.
var resolvesPendingOfferOnTeardown: Bool { get }
/// Start watching for the user coming back to the app. The case that matters is "copied
/// elsewhere, now focusing the stream to paste" the offer must reach the host before their
/// V lands, which is sooner than the announce poll would get there on its own.
func startObserving(onActivate: @escaping () -> Void)
func stopObserving()
}
/// A fetch result handed between threads: the OS fulfils a paste on one thread and the drain
/// thread produces the bytes on another.
final class ClipboardResultBox: @unchecked Sendable {
private let ready = DispatchSemaphore(value: 0)
private let lock = NSLock()
private var value: Data?
private var settled = false
func settle(_ data: Data?) {
lock.lock()
guard !settled else {
lock.unlock()
return
}
settled = true
value = data
lock.unlock()
ready.signal()
}
/// Blocks until the bytes arrive, or gives up. Never call this from the drain thread it is
/// the drain thread that delivers what is being waited for.
func wait(timeout: TimeInterval) -> Data? {
guard ready.wait(timeout: .now() + timeout) == .success else { return nil }
lock.lock()
defer { lock.unlock() }
return value
}
}
extension ClipboardPasteboard {
/// The wire formats this pasteboard is currently carrying, honouring the concealed/transient
/// markers. Nil when the pasteboard holds a secret distinct from "holds nothing we sync",
/// which is an empty list and legitimately clears the peer.
var offerKinds: [PunktfunkConnection.ClipKind]? {
let types = typeIdentifiers
guard !ClipboardFormats.isConcealed(types) else { return nil }
return ClipboardFormats.offerKinds(forTypes: types)
}
}
#endif
@@ -1,51 +1,45 @@
// Shared clipboard, macOS client half (design/clipboard-and-file-transfer.md §5.2).
// Shared clipboard, client half (design/clipboard-and-file-transfer.md §5.2). One implementation
// for macOS and iOS/iPadOS; everything that touches an actual pasteboard sits behind
// `ClipboardPasteboard`.
//
// Bridges NSPasteboard.general to the session's QUIC clipboard plane, both directions lazy:
// Both directions are lazy:
//
// * **Local copy host**: a changeCount poll announces the *format list* (`clipOffer`); the
// bytes cross only when a host app pastes (a `.fetchRequest` event, answered from the live
// pasteboard by `clipServe`).
// * **Host copy local**: a `.remoteOffer` writes one NSPasteboardItem whose
// NSPasteboardItemDataProvider fires only when a Mac app actually pastes the provider then
// blocks (on its provider thread, never main) on a `clipFetch` round-trip.
// * **Local copy host**: a changeCount poll announces the *format list* (`clipOffer`); the bytes
// cross only when a host app pastes (a `.fetchRequest` event, answered from the live pasteboard
// by `clipServe`).
// * **Host copy local**: a `.remoteOffer` places a pasteboard item whose data provider fires only
// when a local app actually pastes the provider then pulls the bytes over a `clipFetch`.
//
// Password-manager respect: pasteboards marked `org.nspasteboard.ConcealedType` or
// `org.nspasteboard.TransientType` are never announced, never fetchable. Echo suppression: the
// changeCount of every write WE make is recorded so the announce poll skips it (§3.4).
//
// Phase 1 formats only (text / RTF / HTML / PNG). Files (NSFilePromiseProvider) ride Phase 2.
#if os(macOS)
import AppKit
// Phase 1 formats only (text / RTF / HTML / PNG / JPEG / GIF). Files ride Phase 2.
#if !os(tvOS)
import Foundation
/// One live session's clipboard bridge. Created by the session model when streaming begins on a
/// host that advertises `HOST_CAP_CLIPBOARD` and whose per-host toggle is on; `stop()` before the
/// connection closes. All pasteboard traffic runs on one dedicated drain thread plus the
/// AppKit-owned provider threads (paste fulfillment).
/// connection closes. All wire traffic runs on one dedicated drain thread, plus the OS-owned
/// threads that fulfil a paste.
public final class ClipboardSync: NSObject {
/// Wire MIME NSPasteboard type for the Phase-1 vocabulary (§3.5), in announce order.
private static let wireToPasteboard: [(wire: String, type: NSPasteboard.PasteboardType)] = [
("text/plain;charset=utf-8", .string),
("text/rtf", .rtf),
("text/html", .html),
("image/png", .png),
// Original image formats pass through VERBATIM beside the PNG floor a copied JPEG
// never balloons into PNG, a GIF keeps its animation; the destination picks the richest
// kind it can place.
("image/jpeg", NSPasteboard.PasteboardType("public.jpeg")),
("image/gif", NSPasteboard.PasteboardType("com.compuserve.gif")),
]
/// Pasteboard marker types that must never cross the wire (password managers mark secrets
/// with these see nspasteboard.org).
private static let concealed = NSPasteboard.PasteboardType("org.nspasteboard.ConcealedType")
private static let transient = NSPasteboard.PasteboardType("org.nspasteboard.TransientType")
/// How long a blocked paste waits for the host's bytes before providing nothing (§5.2).
private static let fetchTimeout: TimeInterval = 10
/// How long a paste waits for the host's bytes before giving up and providing nothing (§5.2).
/// Enforced here, so an adapter that has to block a thread can treat it as a guarantee.
static let fetchTimeout: TimeInterval = 10
/// Serve chunk size for host-side pastes of our data (bounds the per-call ABI copy).
private static let serveChunk = 4 << 20
/// Announce poll interval how stale a local copy may be before the host hears about it.
private static let announceInterval: TimeInterval = 0.5
/// Ceiling on what `resolvePendingOffer` will pull. Text and modest images are worth having on
/// the chance the user pastes them after the session ends; a 200 MB screenshot is not.
private static let resolveBudget = 8 << 20
/// And how long that may hold up teardown. Short on purpose it sits between the user
/// leaving and the connection closing, and a LAN round-trip for a few KB of text is
/// milliseconds. An offer that cannot be had in this long is one the user does without.
private static let resolveTimeout: TimeInterval = 3
private let connection: PunktfunkConnection
private let pasteboard: any ClipboardPasteboard
/// `CLIP_FLAG_*` sent with the enable (`CLIP_FLAG_FILES` when the session permits files
/// always 0 in Phase 1).
private let controlFlags: UInt8
@@ -53,72 +47,91 @@ public final class ClipboardSync: NSObject {
/// Host `.state` updates, delivered on the main queue drives the toggle/footnote UI.
public var onState: ((_ enabled: Bool, _ policy: UInt8, _ reason: UInt8) -> Void)?
// Drain-thread state (touched only on the drain thread once started).
// MARK: Offer bookkeeping
//
// Read by the drain thread, and written by the thread tearing the sync down too, so it is all
// under one lock. Nothing here is held across a fetch or a pasteboard read.
private let stateLock = NSLock()
private var offerSeq: UInt32 = 0
private var lastSeenChangeCount = 0
/// The changeCount of the last pasteboard write WE made (echo suppression + "do we still
/// own the pasteboard" on teardown/clear).
/// The changeCount of the last pasteboard write WE made (echo suppression, and "do we still
/// own the pasteboard" on teardown).
private var ownedChangeCount = -1
/// The host offer currently installed on the local pasteboard (nil = none).
private var installedRemoteSeq: UInt32?
/// The host offer currently placed on the local pasteboard (nil = none).
private var installedRemote: (seq: UInt32, kinds: [PunktfunkConnection.ClipKind])?
/// The offer already pulled down to concrete bytes, so teardown neither re-fetches it nor
/// takes it back off the pasteboard.
private var resolvedSeq: UInt32?
/// Outbound fetches a blocked paste is waiting on. Guarded by `fetchLock` appended by the
/// drain thread (`.data` events), consumed by AppKit's provider threads.
private struct PendingFetch {
// MARK: Outbound fetches
//
// Appended by whichever thread starts a fetch, completed by the drain thread as `.data`
// arrives. Guarded by `fetchLock`, which is never held while a completion runs.
private final class PendingFetch {
var buffer = Data()
let done = DispatchSemaphore(value: 0)
var failed = false
let completion: (Data?) -> Void
init(completion: @escaping (Data?) -> Void) { self.completion = completion }
}
private let fetchLock = NSLock()
private var pendingFetches: [UInt32: PendingFetch] = [:]
/// Fires the deadline that keeps a blocked paste from waiting forever on a host that went
/// quiet mid-transfer.
private let deadlines = DispatchQueue(label: "io.unom.punktfunk.clipboard.deadline")
/// Serves host pastes off the drain thread where a pasteboard read can await a user decision
/// (iOS's paste permission alert) the drain thread must keep running, it is the one that
/// would deliver the host's cancel.
private let serves = DispatchQueue(label: "io.unom.punktfunk.clipboard.serve")
private final class StopFlag: @unchecked Sendable {
private final class Flag: @unchecked Sendable {
private let lock = NSLock()
private var stopped = false
func stop() {
lock.lock()
stopped = true
lock.unlock()
}
var isStopped: Bool {
lock.lock()
defer { lock.unlock() }
return stopped
}
}
private let flag = StopFlag()
private let drainDone = DispatchSemaphore(value: 0)
private var started = false
/// Set by the app-activation observer, cleared by the drain loop: the user may have copied
/// elsewhere and is coming back to paste announce immediately instead of waiting out the
/// poll interval.
private final class OneShot: @unchecked Sendable {
private let lock = NSLock()
private var raised = false
private var value = false
func raise() {
lock.lock()
raised = true
value = true
lock.unlock()
}
func takeIfRaised() -> Bool {
var isRaised: Bool {
lock.lock()
defer { lock.unlock() }
let was = raised
raised = false
return value
}
/// Read-and-clear, for the one-shot "check the pasteboard now" nudge.
func take() -> Bool {
lock.lock()
defer { lock.unlock() }
let was = value
value = false
return was
}
}
private let checkNow = OneShot()
private var activationObserver: NSObjectProtocol?
private let stopped = Flag()
/// Raised by the activation observer, taken by the drain loop: the user may have copied
/// elsewhere and is coming back to paste announce now rather than waiting out the poll.
private let checkNow = Flag()
private let drainDone = DispatchSemaphore(value: 0)
private var started = false
public init(connection: PunktfunkConnection, allowFiles: Bool = false) {
/// - Parameter allowFiles: reserved for Phase 2; `CLIP_FLAG_FILES` is never set yet.
public convenience init(connection: PunktfunkConnection, allowFiles: Bool = false) {
self.init(connection: connection, pasteboard: SystemPasteboard(), allowFiles: allowFiles)
}
/// Designated init, taking the pasteboard so tests can drive the whole state machine against a
/// stub without an AppKit/UIKit pasteboard in the way.
init(
connection: PunktfunkConnection, pasteboard: any ClipboardPasteboard,
allowFiles: Bool = false
) {
self.connection = connection
self.pasteboard = pasteboard
self.controlFlags = 0 // CLIP_FLAG_FILES rides Phase 2
_ = allowFiles
super.init()
}
deinit { flag.stop() }
deinit { stopped.raise() }
// MARK: - Lifecycle
/// Enable sync with the host and start the drain thread. The host answers the enable with a
/// `.state` event (surfaced via `onState`) `BACKEND_UNAVAILABLE` et al. arrive there.
@@ -126,103 +139,105 @@ public final class ClipboardSync: NSObject {
guard !started else { return }
started = true
connection.clipControl(enabled: true, flags: controlFlags)
// Baseline: whatever is on the pasteboard when sync starts is announced immediately
// the "copy first, then connect and paste" flow must work.
// Baseline: whatever is on the pasteboard when sync starts is announced immediately the
// "copy first, then connect and paste" flow must work.
stateLock.lock()
lastSeenChangeCount = -1
activationObserver = NotificationCenter.default.addObserver(
forName: NSApplication.didBecomeActiveNotification, object: nil, queue: nil
) { [checkNow] _ in checkNow.raise() }
let connection = self.connection
let flag = self.flag
let thread = Thread { [weak self] in
var lastAnnounceCheck = Date.distantPast
while !flag.isStopped {
// Drain events (bounded burst so a chatty host can't starve the announce poll).
var drained = 0
while drained < 32, !flag.isStopped {
let ev: PunktfunkConnection.ClipEvent?
do {
ev = try connection.nextClipboard(timeoutMs: drained == 0 ? 200 : 0)
} catch {
flag.stop() // session closed
break
}
guard let ev else { break }
drained += 1
self?.handle(ev)
}
// Announce poll: every 500 ms, or immediately after app activation (§5.2).
let now = Date()
if now.timeIntervalSince(lastAnnounceCheck) >= 0.5
|| self?.checkNow.takeIfRaised() == true
{
lastAnnounceCheck = now
self?.announceIfChanged()
}
}
self?.drainDone.signal()
}
stateLock.unlock()
pasteboard.startObserving(onActivate: { [checkNow] in checkNow.raise() })
let thread = Thread { [weak self] in self?.drain() }
thread.name = "punktfunk-clipboard"
thread.qualityOfService = .utility
thread.start()
}
/// Disable sync and join the drain thread. Called off-main before `connection.close()`
/// (the same discipline as the audio/feedback drains). If the local pasteboard still holds
/// our remote-offer items, they are cleared their providers die with us.
/// Disable sync and join the drain thread. Called off-main before `connection.close()` (the
/// same discipline as the audio/feedback drains).
///
/// A host offer still sitting on the local pasteboard as a promise has to be dealt with here,
/// because after this returns nothing can answer it: either it is pulled down to real bytes
/// (iOS, where this teardown is the user walking away with something they copied) or it is
/// cleared, so a later paste comes up empty rather than silently doing nothing.
public func stop() {
guard started else { return }
started = false
if let obs = activationObserver {
NotificationCenter.default.removeObserver(obs)
activationObserver = nil
pasteboard.stopObserving()
// Before anything is torn down the drain thread has to still be running to deliver the
// chunks, and the connection still open to carry the fetch.
if pasteboard.resolvesPendingOfferOnTeardown {
resolvePendingOffer()
}
connection.clipControl(enabled: false, flags: 0)
flag.stop()
stopped.raise()
drainDone.wait()
// Fail every paste still blocked on us so no provider thread waits out its timeout.
fetchLock.lock()
for (_, pending) in pendingFetches {
pending.done.signal()
// Fail every paste still blocked on us so nothing waits out its timeout against a dead
// session.
settleAll(nil)
stateLock.lock()
let ownsUnresolvedOffer =
installedRemote != nil && resolvedSeq != installedRemote?.seq
&& pasteboard.changeCount == ownedChangeCount
installedRemote = nil
stateLock.unlock()
if ownsUnresolvedOffer {
_ = pasteboard.clear()
}
pendingFetches.removeAll()
fetchLock.unlock()
let pb = NSPasteboard.general
if installedRemoteSeq != nil, pb.changeCount == ownedChangeCount {
pb.clearContents()
}
private func drain() {
var lastAnnounceCheck = Date.distantPast
while !stopped.isRaised {
// Drain events (bounded burst so a chatty host can't starve the announce poll).
var drained = 0
while drained < 32, !stopped.isRaised {
let ev: PunktfunkConnection.ClipEvent?
do {
ev = try connection.nextClipboard(timeoutMs: drained == 0 ? 200 : 0)
} catch {
stopped.raise() // session closed
break
}
guard let ev else { break }
drained += 1
handle(ev)
}
let now = Date()
if now.timeIntervalSince(lastAnnounceCheck) >= Self.announceInterval
|| checkNow.take()
{
lastAnnounceCheck = now
announceIfChanged()
}
}
drainDone.signal()
}
// MARK: - Local copy host (announce)
/// Announce the local pasteboard's format list when it changed (skipping our own writes and
/// concealed/transient pasteboards). Runs on the drain thread.
/// Announce the local pasteboard's format list when it changed, skipping our own writes and
/// concealed/transient pasteboards. Runs on the drain thread.
private func announceIfChanged() {
let pb = NSPasteboard.general
let count = pb.changeCount
guard count != lastSeenChangeCount else { return }
lastSeenChangeCount = count
if count == ownedChangeCount { return } // our own write (a remote offer) never echo
installedRemoteSeq = nil // a local copy replaced the host's offer
let types = pb.types ?? []
if types.contains(Self.concealed) || types.contains(Self.transient) { return }
offerSeq &+= 1
var kinds = Self.wireToPasteboard
.filter { types.contains($0.type) }
.map { PunktfunkConnection.ClipKind(mime: $0.wire) }
// PNG floor: announce the portable `image/png` whenever ANY convertible image is present
// native PNG, TIFF/HEIC (screenshots, Preview), or a JPEG/GIF original already being
// offered verbatim above. `readWireData` converts at fetch time (lazy, §3.5), so the
// fallback costs nothing unless a peer actually pastes it.
if !kinds.contains(where: { $0.mime == "image/png" }),
types.contains(.tiff)
|| types.contains(NSPasteboard.PasteboardType("public.heic"))
|| kinds.contains(where: { $0.mime.hasPrefix("image/") })
{
kinds.append(PunktfunkConnection.ClipKind(mime: "image/png"))
let count = pasteboard.changeCount
stateLock.lock()
guard count != lastSeenChangeCount else {
stateLock.unlock()
return
}
lastSeenChangeCount = count
guard count != ownedChangeCount else {
stateLock.unlock() // our own write (a remote offer) never echo
return
}
installedRemote = nil // a local copy replaced the host's offer
stateLock.unlock()
guard let kinds = pasteboard.offerKinds else { return } // concealed never announced
stateLock.lock()
offerSeq &+= 1
let seq = offerSeq
stateLock.unlock()
// Empty = the pasteboard holds nothing we sync (or was cleared) clears the host side.
connection.clipOffer(seq: offerSeq, kinds: kinds)
connection.clipOffer(seq: seq, kinds: kinds)
}
// MARK: - Event handling (drain thread)
@@ -236,93 +251,166 @@ public final class ClipboardSync: NSObject {
case let .remoteOffer(seq, kinds):
installRemoteOffer(seq: seq, kinds: kinds)
case let .fetchRequest(reqId, seq, _, mime):
serveFetch(reqId: reqId, seq: seq, mime: mime)
serves.async { [weak self] in self?.serveFetch(reqId: reqId, seq: seq, mime: mime) }
case let .data(xferId, chunk, last):
fetchLock.lock()
if var pending = pendingFetches[xferId] {
pending.buffer.append(chunk)
pendingFetches[xferId] = pending
if last {
pendingFetches[xferId]?.done.signal()
}
}
let pending = pendingFetches[xferId]
pending?.buffer.append(chunk)
let finished = last ? pendingFetches.removeValue(forKey: xferId) : nil
fetchLock.unlock()
// Outside the lock: a completion may start the next fetch (or wake a thread that will).
if let finished {
finished.completion(finished.buffer)
}
case let .cancelled(id), let .error(id, _):
fetchLock.lock()
if var pending = pendingFetches[id] {
pending.failed = true
pendingFetches[id] = pending
pending.done.signal()
}
fetchLock.unlock()
settle(id, nil)
}
}
// MARK: - Host copy local (lazy install + blocked-paste fetch)
// MARK: - Host copy local (lazy placement + paste-time fetch)
/// Write one NSPasteboardItem advertising the host's formats, each backed by a lazy data
/// provider bytes cross only when a Mac app pastes. Empty `kinds` = the host cleared its
/// clipboard: drop our item if it's still current.
/// Place a pasteboard item advertising the host's formats, each backed by a lazy provider
/// bytes cross only when a local app pastes. Empty `kinds` = the host cleared its clipboard:
/// drop our item if it's still current.
private func installRemoteOffer(seq: UInt32, kinds: [PunktfunkConnection.ClipKind]) {
let pb = NSPasteboard.general
let types = kinds.compactMap { kind in
Self.wireToPasteboard.first(where: { $0.wire == kind.mime })?.type
}
guard !types.isEmpty else {
if installedRemoteSeq != nil, pb.changeCount == ownedChangeCount {
pb.clearContents()
ownedChangeCount = pb.changeCount
lastSeenChangeCount = pb.changeCount
let utis = ClipboardFormats.placeableUtis(for: kinds)
guard !utis.isEmpty else {
stateLock.lock()
let owned = installedRemote != nil && pasteboard.changeCount == ownedChangeCount
installedRemote = nil
resolvedSeq = nil
if owned {
let after = pasteboard.clear()
ownedChangeCount = after
lastSeenChangeCount = after
}
installedRemoteSeq = nil
stateLock.unlock()
return
}
let item = NSPasteboardItem()
item.setDataProvider(RemoteOfferProvider(sync: self, seq: seq), forTypes: types)
pb.clearContents()
pb.writeObjects([item])
installedRemoteSeq = seq
ownedChangeCount = pb.changeCount
lastSeenChangeCount = pb.changeCount
let fetch: ClipboardFetch = { [weak self] wire, done in
guard let self else {
done(nil)
return
}
self.fetch(seq: seq, wire: wire, completion: done)
}
let before = pasteboard.changeCount
let after = pasteboard.installLazy(utis: utis, fetch: fetch)
stateLock.lock()
installedRemote = (seq, kinds)
resolvedSeq = nil
// Only claim the pasteboard if the write actually landed. Recording a change count we did
// not cause is the one bookkeeping mistake with no recovery: the announce poll would read
// the user's own next copy as our echo and never tell the host about it again.
if after != before {
ownedChangeCount = after
lastSeenChangeCount = after
}
stateLock.unlock()
}
/// Blocked-paste fulfillment: fetch one wire format of host offer `seq` and wait (provider
/// thread) for the drain thread to assemble the chunks. Nil on timeout/cancel/error the
/// paste then provides nothing rather than hanging (§3.4).
///
/// `fetchLock` is held ACROSS the `clipFetch` so the pending entry exists before the drain
/// thread can process the first `.data` event (its `handle` takes `fetchLock` after
/// releasing the connection's clipboard lock no cycle).
fileprivate func fetchBlocking(seq: UInt32, wireMime: String) -> Data? {
/// Start pulling one wire format of host offer `seq`. `completion` runs exactly once with
/// the bytes, or with nil on a stale offer, a timeout, a cancel, or a closing session.
private func fetch(seq: UInt32, wire: String, completion: @escaping (Data?) -> Void) {
fetchLock.lock()
guard let xferId = connection.clipFetch(seq: seq, mime: wireMime) else {
guard !stopped.isRaised, let xferId = connection.clipFetch(seq: seq, mime: wire) else {
fetchLock.unlock()
return nil
completion(nil)
return
}
pendingFetches[xferId] = PendingFetch()
let done = pendingFetches[xferId]!.done
pendingFetches[xferId] = PendingFetch(completion: completion)
fetchLock.unlock()
let outcome = done.wait(timeout: .now() + Self.fetchTimeout)
deadlines.asyncAfter(deadline: .now() + Self.fetchTimeout) { [weak self] in
guard let self, self.settle(xferId, nil) else { return }
self.connection.clipCancel(id: xferId)
}
}
/// Complete one pending fetch if it hasn't been already. Returns whether this call is the one
/// that settled it, so a deadline knows whether it still has to cancel the transfer.
@discardableResult
private func settle(_ xferId: UInt32, _ data: Data?) -> Bool {
fetchLock.lock()
let pending = pendingFetches.removeValue(forKey: xferId)
fetchLock.unlock()
if outcome == .timedOut {
connection.clipCancel(id: xferId)
return nil
pending?.completion(data)
return pending != nil
}
private func settleAll(_ data: Data?) {
fetchLock.lock()
let all = pendingFetches
pendingFetches.removeAll()
fetchLock.unlock()
for (_, pending) in all {
pending.completion(data)
}
guard let pending, !pending.failed else { return nil }
return pending.buffer
}
// MARK: - Resolving a promise before it dies (iOS)
/// Pull the host's offer down to concrete bytes, replacing the promise on the pasteboard.
///
/// A lazy promise is only as good as the ability to answer it, and on iOS that ability ends
/// with the session: backgrounding the app disconnects it. Without this, "copy on the host,
/// then paste into Safari on the iPad" would hand Safari an empty promise. Everything else
/// about the design stays lazy a user who copies on the host, pastes nothing, and stays in
/// the app moves no clipboard bytes at all; this runs once, at the end, for content that is
/// still on the pasteboard and still unclaimed.
///
/// Runs on the thread calling `stop()` off-main by contract, and never the drain thread,
/// which has to keep running to deliver what this waits for.
private func resolvePendingOffer() {
stateLock.lock()
let offer = installedRemote
let unresolved = resolvedSeq != installedRemote?.seq
let stillOurs = pasteboard.changeCount == ownedChangeCount
stateLock.unlock()
guard let offer, unresolved, stillOurs, !stopped.isRaised else { return }
let deadline = Date().addingTimeInterval(Self.resolveTimeout)
var items: [(uti: String, data: Data)] = []
var budget = Self.resolveBudget
for kind in offer.kinds {
guard let uti = ClipboardFormats.uti(forWire: kind.mime) else { continue }
// A size hint of 0 means "unknown" try it, and let the byte count enforce the cap.
guard kind.sizeHint <= UInt64(budget), !stopped.isRaised else { continue }
let left = deadline.timeIntervalSinceNow
guard left > 0 else { break }
let box = ClipboardResultBox()
fetch(seq: offer.seq, wire: kind.mime) { box.settle($0) }
guard let data = box.wait(timeout: left), data.count <= budget else { continue }
budget -= data.count
items.append((uti, data))
}
guard !items.isEmpty else { return }
// Re-check: the host may have copied again, or the user may have copied locally, while we
// were pulling either way these bytes are no longer what belongs on the pasteboard.
stateLock.lock()
defer { stateLock.unlock() }
let before = pasteboard.changeCount
guard installedRemote?.seq == offer.seq, before == ownedChangeCount else { return }
let after = pasteboard.installResolved(items)
// As in `installRemoteOffer`: a write that did not land leaves the promise in place, so
// teardown should still take it back rather than believing these bytes are on the board.
guard after != before else { return }
resolvedSeq = offer.seq
ownedChangeCount = after
lastSeenChangeCount = after
}
// MARK: - Host paste of our data (serve)
/// Answer a host paste of our offered data from the live pasteboard. A stale `seq` (the
/// local clipboard changed since that announce) is cancelled never serve mismatched bytes.
/// Answer a host paste of our offered data from the live pasteboard. A stale `seq` (the local
/// clipboard changed since that announce) is cancelled never serve mismatched bytes.
///
/// Runs on `serves`, not the drain thread: reading the pasteboard can block on a user decision
/// (iOS's paste permission alert), and the drain thread has to stay live throughout.
private func serveFetch(reqId: UInt32, seq: UInt32, mime: String) {
let pb = NSPasteboard.general
guard seq == offerSeq, pb.changeCount == lastSeenChangeCount,
let data = Self.readWireData(pb, mime)
else {
stateLock.lock()
let fresh = seq == offerSeq && pasteboard.changeCount == lastSeenChangeCount
stateLock.unlock()
guard fresh, !stopped.isRaised, let data = pasteboard.read(wire: mime) else {
connection.clipCancel(id: reqId)
return
}
@@ -337,66 +425,5 @@ public final class ClipboardSync: NSObject {
connection.clipServe(reqId: reqId, data: Data(), last: true)
}
}
/// Read one wire format from the pasteboard, converting where macOS stores a different
/// native type: `image/png` is served from a real `.png` entry when present, else converted
/// from whatever image representation the pasteboard holds (TIFF from screenshots/Preview,
/// WebP/AVIF/GIF from browsers `NSImage` decodes them all) into PNG at fetch time.
private static func readWireData(_ pb: NSPasteboard, _ mime: String) -> Data? {
guard mime == "image/png" else {
guard let type = wireToPasteboard.first(where: { $0.wire == mime })?.type else {
return nil
}
return pb.data(forType: type)
}
if let png = pb.data(forType: .png) {
return png
}
// No native PNG: decode whatever image the pasteboard carries and re-encode.
guard let img = NSImage(pasteboard: pb),
let tiff = img.tiffRepresentation,
let rep = NSBitmapImageRep(data: tiff)
else {
return nil
}
return rep.representation(using: .png, properties: [:])
}
}
/// The lazy paste hook: AppKit calls `provideDataForType` only when a Mac app actually pastes;
/// the fetch then blocks this provider thread (never main) until the host's bytes arrive or the
/// timeout provides nothing. One provider per installed remote offer a dead sync (weak) or a
/// superseded offer provides nothing.
private final class RemoteOfferProvider: NSObject, NSPasteboardItemDataProvider {
private weak var sync: ClipboardSync?
private let seq: UInt32
init(sync: ClipboardSync, seq: UInt32) {
self.sync = sync
self.seq = seq
}
func pasteboard(
_ pasteboard: NSPasteboard?, item: NSPasteboardItem,
provideDataForType type: NSPasteboard.PasteboardType
) {
guard let sync,
let wire = wireMime(for: type),
let data = sync.fetchBlocking(seq: seq, wireMime: wire)
else { return }
item.setData(data, forType: type)
}
private func wireMime(for type: NSPasteboard.PasteboardType) -> String? {
switch type {
case .string: return "text/plain;charset=utf-8"
case .rtf: return "text/rtf"
case .html: return "text/html"
case .png: return "image/png"
case NSPasteboard.PasteboardType("public.jpeg"): return "image/jpeg"
case NSPasteboard.PasteboardType("com.compuserve.gif"): return "image/gif"
default: return nil
}
}
}
#endif

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