Compare commits

..
Author SHA1 Message Date
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 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 90d13de81e fix(apple): the stats overlay lied three ways — a frozen clock offset, silently trimmed impossible samples, and Int -1 fallbacks printing as NaN
ci / web (pull_request) Successful in 1m13s
ci / bun-nix (pull_request) Successful in 1m47s
apple / swift (pull_request) Successful in 2m3s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 2m57s
ci / rust-arm64 (pull_request) Successful in 3m15s
ci / rust (pull_request) Successful in 4m20s
Field 2026-08-13, Apple TV vs Bazzite VM host, two sessions minutes apart on
the same wire: hostnet_p50 read 17-21 ms, then a physically impossible
4.4 ms (host-side encode alone is ~4.7). Root causes, each its own defect:

- The client consumed the CONNECT-TIME skew offset and froze it: cached 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 + on suspected wall-clock steps) and its
  own doc says the connect-time value 'silently corrupts every
  capture-clock comparison' after an NTP step — a VM host steps. Now
  PunktfunkConnection.clockOffsetNs IS the live read (an atomic load
  behind the FFI) and every consumer reads it at use: per record, per AU,
  per enqueue. The Swift audio plane's AvSync observation gets the live
  value through the same property.

- LatencyMeter's impossible-sample guard (≤ 0 after offset correction)
  dropped samples SILENTLY, so a wrong offset didn't invalidate a window —
  it trimmed the impossible half of the shifted distribution and presented
  the surviving tail as a plausible small number ('e2e 0-3 ms p50 /
  23 ms p95' on a session whose true hostnet was ~18 ms; also the
  historical '0 ms network / 0 ms e2e' readings). The 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.

- Every invalid-field fallback in the 1 Hz stats line was a bare -1: in
  the variadic CVarArg context the ternary 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 line existed; stage-1 (the first
  rung with invalid fields while frames flow) printed nan for every one.
  All fallbacks are now typed -1.0 / Double()-wrapped.
2026-08-13 23:15:30 +02:00
enricobuehler 1abf5c91b9 feat(apple): the tvOS present-floor verdict rested on a property readback, so open the two untested doors
The 2026-08-13 field ladder closed 'the tvOS two-refresh present floor is
immovable' on the strength of a 'link granted latency 1.00 frames' HUD line.
But that line reads back preferredFrameLatency — a plain read-write float
(CAMetalDisplayLink.h carries no doc contract) that echoes whatever we
stored. A readback is not a grant; the measured vend lead (1.95 refresh
periods) was the only truth-teller, and two levers were never actually
pulled. This commit also carries the ladder instrumentation that run used:
the 1 Hz stats mirror to stdout (the only log channel that exists on an
Apple TV), the PresentLinkInfo HUD plumbing, the stage-4 drawable-pool
clamp to 2, and the tvOS fixed-rate range pin.

- PUNKTFUNK_FRAME_LATENCY makes the ask a lever (float 0...4, default 1) so
  an on-device ladder can prove whether the property does ANYTHING on tvOS:
  ask=2 growing the vend lead to ~3 means it works and the floor is ~ask+1;
  a lead pinned at ~2 means it is inert and the compositor regime is fixed.
  ask=0.5 is the in-regime win probe (the property is a float for a reason).
  Ask + readback go to the HUD line and the stats line (link_ask/
  link_readback) so the ladder reads HUD-off over stdout.

- PUNKTFUNK_PRESENTER=stage1 now resolves on Release builds (env only; the
  persisted picker stays DEBUG-gated — an env var is never a leftover, it
  takes a devicectl/Xcode launch to exist). Stage-1 presents on the hardware
  video plane (AVSampleBufferDisplayLayer + DisplayImmediately) instead of
  through the GPU compositor — the only rung that can dodge the two-refresh
  regime — and the field A/B silently ran stage-4 because the gate keyed on
  build config. The pump gains stage-1's only latency instrument:
  capture→enqueue into the e2e meter (offset-corrected, displayed frames
  only), so cross-rung runs can pin any felt difference on the present tail.
2026-08-13 23:15:30 +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
enricobuehler fdf48fcaa1 Merge pull request 'The console UI died on any Vulkan loader newer than the version we asked for' (#204) from worktree-deck-skia-browse-fix into main
ci / docs-site (push) Successful in 1m16s
ci / rust-arm64 (push) Successful in 1m24s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m4s
android / android (push) Failing after 3m26s
deb / build-publish-gamescope (push) Failing after 14s
ci / web (push) Successful in 4m4s
deb / build-publish (push) Successful in 3m17s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m15s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 10s
deb / build-publish-host (push) Successful in 4m14s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 23s
ci / bun-nix (push) Successful in 5m15s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m7s
arch / build-publish (push) Successful in 7m42s
deb / build-publish-client-arm64 (push) Successful in 4m23s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 2m57s
docker / builders-arm64cross (push) Successful in 19s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 6m55s
deb / smoke-install (push) Successful in 3m54s
docker / deploy-docs (push) Failing after 3m59s
flatpak / build-publish (push) Successful in 9m29s
ci / rust (push) Successful in 17m34s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m51s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 21m13s
Reviewed-on: #204
2026-08-13 20:26:24 +00:00
enricobuehler 05a08b9804 Merge pull request 'The plugin runner was located by FHS path only, so NixOS never found it' (#207) from worktree-nix-plugin-runner-resolve into main
deb / smoke-install (push) Successful in 2m38s
ci / web (push) Successful in 1m14s
ci / docs-site (push) Successful in 1m19s
android / android (push) Failing after 2m13s
arch / build-publish (push) Failing after 2m15s
deb / build-publish-gamescope (push) Successful in 1m8s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 17s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 38s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
ci / bun-nix (push) Successful in 3m32s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 26s
ci / rust-arm64 (push) Successful in 4m37s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 1m18s
ci / rust (push) Successful in 4m39s
deb / build-publish-client-arm64 (push) Successful in 2m48s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 58s
deb / build-publish-host (push) Successful in 4m36s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 59s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 54s
docker / builders-arm64cross (push) Successful in 17s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3m37s
deb / build-publish (push) Failing after 4m19s
nix / flake (push) Failing after 5m46s
windows-host / package (push) Successful in 12m31s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 17s
docker / deploy-docs (push) Failing after 6m16s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 11m32s
2026-08-13 20:10:12 +00:00
enricobuehler 99c245520c Merge pull request 'AV1 decoded on the D3D11VA rung because 31 is not a level, and two host warnings that named the wrong subsystem' (#205) from worktree-av1-level-sentinel into main
android / android (push) Failing after 19s
ci / bun-nix (push) Successful in 24s
ci / docs-site (push) Successful in 1m16s
deb / build-publish-client-arm64 (push) Successful in 1m23s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 9s
apple / swift (push) Successful in 2m19s
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
ci / web (push) Successful in 3m36s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 13s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 21s
deb / build-publish-host (push) Successful in 4m45s
ci / rust-arm64 (push) Successful in 5m38s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 7m7s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 4m37s
arch / build-publish (push) Successful in 8m33s
deb / build-publish (push) Successful in 10m27s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m6s
flatpak / build-publish (push) Successful in 8m57s
apple / distribute (push) Successful in 11m16s
docker / deploy-docs (push) Successful in 38s
docker / builders-arm64cross (push) Successful in 17s
deb / build-publish-gamescope (push) Failing after 1m23s
ci / rust (push) Successful in 20m46s
apple / screenshots (push) Successful in 6m28s
windows-host / package (push) Successful in 13m22s
windows-host / winget-source (push) Skipped
deb / smoke-install (push) Successful in 8m53s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m23s
windows-host / canary-manifest (push) Successful in 59s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 24m34s
2026-08-13 19:35:37 +00:00
enricobuehler 4ab6a399e6 Merge pull request 'The console screens read the ink they publish, so a pale palette stayed white on Apple TV' (#206) from worktree-apple-tv-light-palette-ink into main
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 40s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 27s
ci / bun-nix (push) Successful in 1m37s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
apple / swift (push) Successful in 2m2s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
ci / rust-arm64 (push) Successful in 2m4s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m5s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
ci / web (push) Successful in 3m40s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m31s
docker / deploy-docs (push) Successful in 31s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 2m44s
docker / builders-arm64cross (push) Successful in 9s
ci / docs-site (push) Successful in 4m45s
apple / distribute (push) Successful in 10m52s
ci / rust (push) Successful in 15m14s
apple / screenshots (push) Canceled after 3m41s
2026-08-13 19:18:54 +00:00
enricobuehler 8216f1d92d fix(host): a 2 s keyframe cadence is the client's flush cooldown, not display churn
ci / bun-nix (pull_request) Successful in 45s
apple / swift (pull_request) Successful in 2m12s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 5m52s
ci / web (pull_request) Successful in 6m40s
ci / rust-arm64 (pull_request) Successful in 6m54s
android / android (pull_request) Successful in 7m42s
ci / rust (pull_request) Successful in 12m59s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m55s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m28s
The host's recovery-cadence detector warns that "client keyframe recoveries are
METRONOMIC — a periodic host/display disturbance (display-topology churn,
display-poller software, virtual-display timing) is the likely cause, not
random network loss". In a 2026-08-13 field log it fired at period_s=2.0 and
sent the investigation at three innocent host subsystems.

2.0 s is `punktfunk_core::client::FLUSH_COOLDOWN`. The client's receive-backlog
guard sheds a standing queue with a flush plus a keyframe request and is
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 —
the constant's own doc says it "degrades into a periodic skip + a logged
warning", which is the behaviour the detector then read as physical. Perfect
periodicity argues FOR a fixed software cooldown, not against it.

In the field case the host was blameless and the chain ran the other way: the
client refused the negotiated codec on its Vulkan rung, demoted to a slower
decode path, could not hold 4K120 there, and built the standing queue. Three
layers between the symptom the host reported and the cause.

So the detector now routes: a period on the client's cooldown names the client
and says where to look in ITS log (`receive backlog stopped draining`, and a
demoted decode rung); anything else keeps the display-disturbance wording it
had. The comparison reads FLUSH_COOLDOWN itself — now `pub` for exactly this,
documented as such — rather than a copy of the number, so the two cannot drift.
±10 % absorbs scheduling jitter and the request's trip without being wide
enough to swallow the disturbance cadences the other branch exists to report.

Verified: 18/18 native::stream::tests on linux/amd64 (container), including the
new case, which derives its inputs from FLUSH_COOLDOWN so it survives a retune;
clippy --all-targets -D warnings clean; cargo check clean on the Windows CI
runner.
2026-08-13 21:00:48 +02:00
enricobuehler 1677d1c0c2 fix(host/audio): stop warning that "the stream will click" when there is no stream
A 2026-08-13 field host log carried ten "the audio encode thread could not keep
up — captured audio was DROPPED" warnings, the worst reading
dropped_chunks=11251. That reads like catastrophic audio loss. It was not: 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 stats window holds exactly 11250 — those windows were a
100 % drop rate, at peak_db=-120.0 (digital silence). Every one of the ten
straddled a session boundary, and across all of them dropped_chunks/375 matches
the seconds with NO live session in that window to within a fraction of a
second (3890/375 = 10.4 s against a 10.5 s gap; 3616/375 = 9.6 s against 9.8 s).

The capturer is host-lifetime: the native and gamestream planes PARK it between
sessions (`AudioCapturer::idle`) rather than dropping it, but the consumer is
the per-session encode thread. The hand-off channel is a bounded
sync_channel(64), so ~170 ms after a session ends it is full and every
try_send fails for as long as the host sits idle — counted as the encode thread
falling behind, and reported with a sentence about a stream that does not
exist. It is the worst kind of false alarm: it names a real failure mode, in a
subsystem with real open audio work, at a volume that demands attention.

So the drop counter now only counts while a session is actually reading, via an
`active` flag shared with the capture thread and toggled by the same
open/drain/idle/Drop transitions that already own the routing claim. A full
channel under a live consumer still means exactly what it used to.

Both backends: the parking call sites are platform-independent, so the WASAPI
half had the identical defect (it had no `idle` at all, and gains one). Only
the Linux half has field evidence.

Verified: punktfunk-host clippy --all-targets -D warnings clean on
linux/amd64 (container) and cargo check clean on the Windows CI runner.
2026-08-13 21:00:26 +02:00
enricobuehler 9a52d725d5 fix(apple): every console screen read the ink it publishes, so a pale palette stayed white on tvOS
ci / rust-arm64 (pull_request) Successful in 1m20s
ci / web (pull_request) Successful in 2m49s
ci / docs-site (pull_request) Successful in 2m21s
ci / bun-nix (pull_request) Successful in 19s
apple / swift (pull_request) Successful in 2m31s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Successful in 6m49s
A screen that applies `gamepadPaletteInk()` to its own body sits ABOVE its own copy of the
environment: the modifier covers its descendants, never the body's own `ink.…` references. So
each of these screens read whatever was published above it — and on tvOS, where they are
presented as covers rather than nested in the iOS shell, that is nothing at all. They got the
bare dark default while their CHILD views (the hint bar, the host tiles, the glass) resolved the
real palette, which is why a pale field came out with a white title, white row labels and white
values under correctly-pale glass, with the focus wash still brand violet instead of the
palette's accent. The same trap the `gamepadMetrics` comment already documents, one environment
key over.

Resolve the ink from the stored `ui_palette` instead of the environment in the six screens that
publish it, and in `GamepadScreenBackground` — mounted as their `.background { }`, so it was
reading the parent's ink too and bleaching a pale field's scrim toward white.

Three more of the same family, all tvOS-only:
  - the pairing cover drew the system's dark chrome straight over the launcher showing through
    it (a tvOS cover has no background of its own): the PIN prompt was white on the bright
    aurora. It gets the console field and the palette now, in the launcher's branch only — the
    touch route to the same sheet still belongs to the system background.
  - the library cover's navigation title is drawn by the NavigationStack, which wraps LibraryView
    from outside its own ink, so the shelf's name stayed white over content that had already gone
    dark. Fixed on tvOS and on the macOS sheet (gated there — that sheet is both modes').
  - the library's loading / error / empty states mounted no backdrop at all; only the coverflow
    did. They now take the same field, so the spinner no longer sits on the launcher's own
    aurora with the host tiles still visible behind it.

And a contrast bug the same screens made visible: a saved host's badge glyph took `fg`, which is
chosen against the FIELD, while the badge it sits on IS the accent. The two disagree at both ends
of the set — a pale palette put near-black on a deep accent, Graphite (accent luma 0.80) put
white on light grey. It takes `onAccent` now, like the selected settings tab.

Verified on the tvOS 26.5 simulator across Mint, Sunset, Violet and Graphite: launcher, settings,
add-host, pairing and the library's loading state. `swift test` 288 passed / 6 skipped; iOS and
tvOS both build.
2026-08-13 20:58:36 +02:00
enricobuehler fbbfce9b0e fix(console-ui): Skia sized its function table to the loader, not to what we promised
ci / bun-nix (pull_request) Successful in 21s
ci / docs-site (pull_request) Successful in 1m10s
ci / rust-arm64 (pull_request) Successful in 1m17s
ci / web (pull_request) Successful in 3m33s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 5m15s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m58s
ci / rust (pull_request) Successful in 16m40s
android / android (pull_request) Successful in 5m18s
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
(now removed) `BackendContext::new` did". That is true of the VALUE and 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 ever promised 1.3, `vkGetDeviceProcAddr` returns
null for the entry points in between, validation fails, and `make_vulkan` hands
back `None`. At 0.87 the same sentinel was inert, because that Skia knew nothing
of Vulkan 1.4 -- which is why this surfaced the moment 0.28.0 landed.

`run.rs` makes an overlay that cannot init fatal for `--browse`, so on the Steam
Deck the console home died on update: the Decky panel's button and the
gamepad-UI library shortcut both launch `PF_BROWSE=1`, and neither would open.
In a stream the same failure only warns, so those sessions quietly lost their
stats OSD and capture HUD instead. `pf-presenter`'s `vk` module is
`cfg(any(linux, windows))`, so this was never Deck-specific.

The presenter now publishes the version an overlay may size itself to as
`SharedDevice::api_version`, and `SkiaOverlay::init` passes it instead of `None`.
It is `min(what we declared, what the loader reports)`: taking the loader's
number alone is this bug, and taking ours alone would break the mirror case,
where a 1.1+ loader accepts our 1.3 `apiVersion` as intent even when it cannot
deliver 1.3. Three unit tests pin both directions and the no-answer case. The
three `API_VERSION_1_3` spellings in setup.rs now read the one constant, so the
number the overlay is told can no longer drift from the number we asked for.

Measured on the Deck (RADV VANGOGH, loader 1.4.321) with a standalone repro
against the shipped crate -- the client build is not needed to see it:

  vkEnumerateInstanceVersion() -> 1.4.321 ; VkApplicationInfo -> 1.3.0
  max_api_version = None      => DirectContext NULL
  max_api_version = Some(1.3) => DirectContext OK

Verified: cargo fmt --all --check; and in the pf-lxcheck2 x86_64 container,
cargo build + cargo clippy --all-targets -- -D warnings for pf-console-ui and
pf-presenter, plus cargo test -p pf-presenter (46 passed). Note that
`cargo check -p pf-console-ui` on macOS is vacuous -- every mod in that crate is
cfg(linux|windows), so it compiles nothing there.
2026-08-13 20:50:09 +02:00
enricobuehler c12476736d docs(changelog): v0.28.0 was tagged, so these fixes belong in a v0.28.1 section
ci / bun-nix (pull_request) Successful in 28s
ci / rust (pull_request) Successful in 4m44s
ci / rust-arm64 (pull_request) Successful in 4m32s
ci / docs-site (pull_request) Successful in 1m30s
ci / web (pull_request) Successful in 5m54s
nix / flake (pull_request) Successful in 15m13s
android / android (pull_request) Failing after 1m10s
The branch is based on the commit v0.28.0 points at, so writing the NixOS
runner fix into that section would have credited a released version with
a change it does not contain. Moved to a fresh `v0.28.1 — in development`
section, matching how the v0.28.0 cycle was kept (a `— in development`
heading the release commit renames).
2026-08-13 20:40:02 +02:00
enricobuehler 5bcee83c34 test: /bin/true and /bin/false do not exist on NixOS
Two tests hardcoded absolute paths into /bin, which on NixOS holds only
`sh` — so `cargo test` failed there for reasons that had nothing to do
with the code under test.

`gamelease` only needs a process that exits quickly and successfully, so
the bare name resolved through PATH is exactly right.

`pyrowave_remote` cannot use a bare name: `spawn_link` pins the binary
with `PinnedExe::open`, so a name that PATH would have resolved fails the
OPEN instead — which takes the spawn-failure rung rather than the
handshake rung the test exists to exercise. It resolves a real path off
PATH first, keeping the test on the rung it names.
2026-08-13 20:30:33 +02:00
enricobuehler 4ebe7d1185 fix(host): the plugin runner was located by FHS path only, so NixOS never found it
On NixOS every plugin PACKAGE op failed with "the plugin runner isn't
installed" on a box where the runner was installed, enabled and running.
`runner_command()` checked FHS locations exclusively — /usr/bin, the
/usr/lib + /usr/share pair behind it, and the ~/.local mirror the SteamOS
installer lays down. Nix ships punktfunk-scripting as a derivation of its
OWN, so its wrapper is neither beside the host binary nor anywhere under
/usr, and no rung could ever match. Service ops go through systemd and
were unaffected, which is what made it read as arbitrary: `plugins
status` said running/enabled while `plugins add` said not installed.

Resolution now matches punktfunk-encode-worker's: PUNKTFUNK_SCRIPTING ->
beside the host binary -> PATH -> /usr -> ~/.local. PATH is the rung Nix
lands on. The /usr rungs stay AFTER it rather than being dropped, because
a systemd unit's PATH need not include /usr/bin. As with the encode
worker the env override is deliberately not existence-checked — a named
path that is wrong should fail naming itself, not fall through to some
other runner. Lifted into a pure injected function so the whole table is
testable, which is also how the regression is pinned: removing the PATH
rung fails the NixOS row specifically.

Second half, and the reason the Rust change alone would not have fixed
the console: the NixOS module now puts the runner on the HOST UNIT's
`path`. The console installs plugins from inside the host service, whose
PATH is exactly that unit list — `environment.systemPackages` only ever
covered an operator's interactive shell. Without it the CLI would have
been fixed and the console would not. module-check.nix gains both the
positive and the negative assertion, so CI's `nix flake check --no-build`
holds the property.

The error text named only apt and SteamOS; it now names NixOS and the
override. The ~/.local/bin symlink workaround is no longer needed.
2026-08-13 20:27:26 +02:00
enricobuehler 3f738a9989 fix(pf-vkdecode): AV1's "maximum parameters" level is not a level above the ceiling
A 2026-08-13 field report from the same RTX 5060 client as a02014ec: every AV1
session demoted to D3D11VA with "outside device caps: stream level
(seq_level_idx 31) above the device's maxLevel (AV1 Std level 23)" — 4K120,
NVIDIA, the hardware decoding the stream trivially on the D3D11VA rung it fell
through to. a02014ec fixed the H.264/H.265 half of exactly this and left AV1
alone on the premise that "no over-declaration has been seen in the field";
the reporter's own log from that same day already showed otherwise.

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 here: 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.

So the gate warns once and proceeds, like its H.265 sibling. Unlike H.265
there is nothing to clamp: StdVideoAV1SequenceHeader carries no level field,
so the declaration never reaches the driver and cannot be invalid usage. The
stream's real demands stay enforced where they are physical facts — coded
extent and DPB depth, both checked at session build.

Not verified on glass: no RTX 5060 here, and the reporter's box is the only
one that has produced a seq_level_idx 31 stream. The unit test pins the
arithmetic that made the refusal look reasonable.
2026-08-13 20:05:41 +02:00
enricobuehler 7df321f459 Merge pull request '0.28.0' (#203) from worktree-release-0280 into main
audit / cargo-audit (push) Successful in 1m4s
audit / bun-audit (plugin-kit) (push) Successful in 29s
audit / bun-audit (sdk) (push) Successful in 23s
audit / bun-audit (web) (push) Successful in 22s
audit / docs-site-audit (push) Successful in 24s
audit / pnpm-audit (push) Successful in 15s
audit / license-gate (push) Successful in 4m55s
audit / miri (push) Successful in 5m1s
audit / c-abi-asan (push) Successful in 10m17s
android-screenshots / screenshots (push) Successful in 3m7s
apple / distribute (push) Successful in 12m23s
apple / swift (push) Successful in 1m57s
nix / flake (push) Successful in 17m4s
sbom / sbom (push) Successful in 2m31s
linux-client-screenshots / screenshots (push) Successful in 7m15s
apple / screenshots (push) Successful in 6m55s
ci / rust (push) Successful in 21m17s
ci / web (push) Successful in 2m17s
ci / docs-site (push) Successful in 1m25s
ci / bun-nix (push) Successful in 53s
ci / rust-arm64 (push) Successful in 2m32s
decky / build-publish (push) Successful in 41s
arch / build-publish (push) Successful in 7m35s
windows-host / package (push) Successful in 14m11s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Successful in 30s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m9s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 7m6s
flatpak / build-publish (push) Successful in 27m0s
deb / build-publish (push) Successful in 11m57s
deb / build-publish-host (push) Successful in 10m52s
deb / build-publish-gamescope (push) Successful in 28s
deb / smoke-install (push) Successful in 3m37s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m49s
deb / build-publish-client-arm64 (push) Successful in 4m21s
web-screenshots / screenshots (push) Successful in 7m2s
android / android (push) Successful in 6m58s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 14s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 15s
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 33s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 33s
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-arm64cross (push) Successful in 10s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 58s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3m25s
docker / deploy-docs (push) Successful in 14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m24s
2026-08-13 16:20:00 +00:00
enricobuehler c7d0fd2e03 release: 0.28.0 — version bump, notes, CHANGELOG, Play notes
apple / swift (pull_request) Successful in 1m59s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / bun-nix (pull_request) Successful in 18s
android / android (pull_request) Successful in 11m19s
ci / rust-arm64 (pull_request) Successful in 7m47s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m18s
ci / web (pull_request) Successful in 10m42s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m53s
ci / docs-site (pull_request) Successful in 11m14s
nix / flake (pull_request) Successful in 15m44s
ci / rust (pull_request) Successful in 1h0m25s
180 commits since v0.27.0. Cut from origin/main 9c133350.

THE NUMBER: 0.28.0, not 0.27.1. The CHANGELOG's in-development section was
titled "v0.27.1", which the release does not support — 17 `feat(...)` commits,
a packager-visible default flip (GameStream opt-in on every route), the
edition-2024 MSRV rise, and now a genuinely BREAKING host change (the built-in
library scanners are deleted). `scripts/ci/pf-version.sh`'s canary rule agrees
independently: CI already stamps canaries `0.28.<run>`.

TWO DEFECTS FOUND AND FIXED WHILE PREPARING, both pre-existing on main:

1. C ABI_VERSION was stale at 18. Two exported symbols landed since v0.27.0
   without a bump — punktfunk_connection_note_frame_index_ex and
   punktfunk_reanchor_gate_arm_expecting_drops (72 -> 74 declarations in
   include/punktfunk_core.h). The constant's own doc history makes the rule
   explicit: v17 and v18 each bumped for adding exactly one symbol. Bumped to 19
   with its doc entry; the header is regenerated (cbindgen, CI-gated) and the
   C ABI harness passes printing abi_version=19.

2. docs-site/public/openapi.json had drifted to 0.21.0 against api/openapi.json,
   missing five endpoints. The copy is a documented manual step that nothing in
   CI enforces (CONTRIBUTING.md says so outright). Re-synced — and then it
   DRIFTED AGAIN inside this same cycle when the scanner-removal regen updated
   api/openapi.json alone, so it is re-synced a second time and the CHANGELOG
   now says to treat the copy as part of regenerating, not a follow-up.

 The final docs batch also invalidated a line in this CHANGELOG: the identity
section still said the P-256 key was "generated by ring via rcgen", which contradicted
this same document's "ring is gone from the tree entirely". Corrected to "rcgen on the
workspace's aws-lc-rs backend", matching 92db6651.

api/openapi.json stays stamped 0.27.0: it cannot be regenerated here
(punktfunk-host does not compile on macOS) and does not need to be — the drift
test normalizes info.version, so only the SURFACE is gated, and the surface is
current.

CHANGELOG: retitled to v0.28.0, gained the version table (wire 2 unchanged; C
ABI 18->19; edition 2021->2024 and MSRV 1.82->1.85; driver protocol 6 and
gamepad channel 3 unchanged; plugin-kit 0.4.0->0.4.1), a breaking-changes
section, and ~29 topics the in-development text predated — including the four
that landed last: the scanner->plugin migration, the Mutter rebuild
serialization, the KWin <=60 Hz readback, and the Apple/Android de-prime fuse.

 THE BREAKING ONE, stated plainly in both halves: the six built-in library
scanners are DELETED and the library is assembled entirely by plugins. There is
deliberately no migration — a plugin claims its store and republishes each title
under the same `<store>:<external_id>` id, so entry ids, GameStream app ids, art
caches, Moonlight pins, per-source toggles and per-entry hides all keep working.
The one visible consequence, and the whole upgrade note: a host with NO library
plugins installed has an empty grid.

 The Mutter two-client segfault this release now fixes (a5c9b7b8) is the one
found during THIS release's on-glass validation: chaining two clients through a
kept display killed gnome-shell in meta_monitor_manager_rebuild. It was A/B'd on
.21 against the released 0.27.0 and shown byte-identical there, so it was never
a 0.28.0 regression — and the fix's own commit message cites that A/B.

GATES RUN, all green on this commit (re-run after the rebase onto 86cbbea0):
  cargo fmt --all --check            clean
  cargo metadata --locked            OK against the new dependency tree
  Cargo.lock                         versions-only vs origin/main, 36/36 lines
  cargo test -p punktfunk-core       210 passed
  c_abi harness                      PASS, abi_version=19 (needs LIBRARY_PATH
                                     for opus on macOS; a link path, not a defect)
  docs-site build                    exit 0 (bun install --frozen-lockfile + build)
  Play notes gate                    440/500 CHARACTERS, not byte-identical to
                                     any other release (`•` is 3 bytes — count
                                     characters, as the gate does)
  notes voice check                  0 hits above `## For developers`; TL;DR at
                                     6 bullets (README caps it at six)

ON-GLASS (against the canary of 14425716, code-identical bar ABI_VERSION):
Windows .173 0.28.13309 + Android and iPad, Linux .21 0.28.0-0.00013300 +
iPhone — both PASS. The idle sleep-blocker fix is proven before/after on .173
(`powercfg /requests` SYSTEM: the mic devnode -> "Keine."), and the GameStream
flip is proven at the socket level on .21 (47984/47989/47999 absent by default,
restored by PUNKTFUNK_GAMESTREAM=1). Old-client compat holds: Android 0.26.0
streams against the 0.28.0 host.

 NOT re-validated: the Mutter fix itself. .21 (VM 103) is stopped — it and
home-bazzite-2 (VM 119, currently running) share one passed-through GPU, so
bringing .21 up would stop the other VM. Owed once .21 is free; the repro is
iPhone 2868x1320 -> SIGTERM -> Android 2800x1260, and the marker to confirm the
build carries the fix is the string "mutter: waited out a monitor-topology
rebuild before releasing the lock".

NOT INCLUDED: the 14 unpushed pf-capture/pf-vdisplay sweep commits on the local
main. Never through CI; pushing them is the user's call.
2026-08-13 18:06:18 +02:00
enricobuehler 9c13335089 Merge pull request 'docs(host): the identity comments still named ring after the aws-lc-rs move' (#202) from worktree-identity-doc-aws-lc into main
ci / rust-arm64 (push) Failing after 43s
ci / web (push) Successful in 1m40s
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.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
ci / bun-nix (push) Successful in 55s
deb / build-publish-gamescope (push) Successful in 37s
ci / docs-site (push) Successful in 2m56s
arch / build-publish (push) Failing after 54s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 1m15s
android / android (push) Successful in 9m40s
deb / build-publish-client-arm64 (push) Successful in 5m53s
deb / build-publish (push) Successful in 5m46s
ci / rust (push) Canceled after 11m40s
deb / build-publish-host (push) Canceled after 9m39s
deb / smoke-install (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 14m31s
windows-host / package (push) Canceled after 12m45s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
2026-08-13 16:00:03 +00:00
enricobuehler 92db66514b docs(host): the identity comments still named ring after the aws-lc-rs move
ci / web (pull_request) Successful in 1m38s
ci / bun-nix (pull_request) Successful in 25s
ci / docs-site (pull_request) Successful in 4m53s
ci / rust-arm64 (pull_request) Successful in 6m18s
android / android (pull_request) Successful in 9m54s
ci / rust (pull_request) Successful in 43m21s
PR #192 (79d755cd) moved rustls, quinn, rcgen and tokio-rustls to aws-lc-rs,
but two comments in identity.rs still credited ring:

  * the module doc credited the P-256 key to "ring via rcgen" — rcgen now
    selects `aws_lc_rs` (punktfunk-host/Cargo.toml:135, punktfunk-core/Cargo.toml:80);
  * the legacy-RSA fallback claimed "rustls/ring can SERVE an existing RSA cert".
    The substance still holds under aws-lc-rs; only the provider name was wrong.

4903c9d3 fixed the `generate()` doc but missed the module doc, whose phrase wraps
across two lines ("generated by" / "ring via rcgen"), so a line-based grep never
matched it.

Comment-only: every changed line is a comment, cargo fmt clean.
2026-08-13 17:58:45 +02:00
enricobuehler 86cbbea020 Merge pull request 'The de-prime fuse was a callback count, so an iPad gave up 3x sooner than a Mac' (#200) from worktree-apple-audio-jitter-probe into main
apple / swift (push) Successful in 1m57s
ci / web (push) Successful in 2m4s
apple / distribute (push) Successful in 10m44s
ci / docs-site (push) Successful in 2m2s
ci / bun-nix (push) Successful in 34s
apple / screenshots (push) Successful in 6m56s
android / android (push) Successful in 14m35s
ci / rust-arm64 (push) Successful in 13m22s
deb / build-publish-gamescope (push) Successful in 1m20s
deb / build-publish (push) Successful in 9m36s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 7m4s
deb / build-publish-host (push) Successful in 9m46s
deb / build-publish-client-arm64 (push) Successful in 3m37s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 20s
arch / build-publish (push) Failing after 1m54s
deb / smoke-install (push) Successful in 3m30s
ci / rust (push) Successful in 27m7s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 12m35s
windows-host / package (push) Canceled after 14m12s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m1s
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 1m40s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 55s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m10s
flatpak / build-publish (push) Successful in 20m21s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m10s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 16s
docker / deploy-docs (push) Successful in 41s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m58s
docker / builders-arm64cross (push) Canceled after 0s
Reviewed-on: #200
2026-08-13 15:19:54 +00:00
enricobuehler 3d5d8e2d76 Merge pull request 'The six built-in library scanners become plugins — M6/WP6.4' (#199) from worktree-remove-builtin-scanners into main
android / android (push) Canceled after 0s
apple / swift (push) Canceled after 21s
apple / distribute (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
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
flatpak / build-publish (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-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
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
audit / bun-audit (plugin-kit) (push) Successful in 26s
audit / bun-audit (sdk) (push) Successful in 34s
audit / bun-audit (web) (push) Successful in 21s
audit / pnpm-audit (push) Successful in 15s
audit / docs-site-audit (push) Successful in 21s
audit / license-gate (push) Successful in 5m11s
audit / miri (push) Successful in 6m51s
audit / c-abi-asan (push) Successful in 8m47s
nix / flake (push) Successful in 17m37s
audit / cargo-audit (push) Successful in 1m56s
Reviewed-on: #199
2026-08-13 15:19:43 +00:00
enricobuehler 8f6eb1494d fix(audio): the de-prime fuse was a callback count, so an iPad gave up 3x sooner than a Mac
apple / swift (pull_request) Successful in 2m2s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / bun-nix (pull_request) Successful in 42s
ci / docs-site (pull_request) Successful in 1m47s
ci / rust-arm64 (pull_request) Successful in 7m19s
ci / web (pull_request) Successful in 7m26s
ci / rust (pull_request) Successful in 10m23s
android / android (pull_request) Successful in 12m59s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m34s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m17s
Three defects behind the residual Apple audio jitter, found while chasing a field
report that survived both the PLC fix (#82) and the jitter-policy fix (#111).

1. `JitterTuning::deprime_after` counted CALLBACKS, and a callback is not a unit of
   time. The same `4` was ~44 ms of starvation slack on a Mac's ~11 ms quantum and
   20 ms on iOS, whose session asks for a short IO buffer — the shortest fuse of any
   client, on the one with the burstiest transport. A 100 ms Wi-Fi delivery stall
   therefore de-primed the Apple ring on every bunching cycle while the identical
   policy rode it out everywhere else. It is now `deprime_ms`, measured in starved
   audio, with a `MIN_DEPRIME_CALLBACKS` floor so a large-quantum device keeps real
   hysteresis instead of de-priming on the first short read. Android was latently
   exposed too (AAudio's low-latency burst is ~4-5 ms, so its `5` was also ~20 ms).

   Driving the real policy through a simulated link (100 ms stall / 5 s, -30 ppm,
   10 min) at a 5 ms quantum: 120 audible gaps and 690 ms of dead air before, 2 gaps
   and 60 ms after.

2. iOS asked for a 5 ms IO buffer that bought the uplink nothing. The mic tap
   installs with `bufferSize: 480` and the encoder consumes whole 10 ms
   `framesPerPacket` chunks, so at 5 ms the tap simply fired twice per packet for the
   same packet latency — while halving the render callback's deadline and, through
   (1), the ring's starvation hysteresis. Now 10 ms, matching the framing we already
   use. On the harsh link above that takes the residual from 2 gaps to 1.

   The granted `ioBufferDuration`/sample rate/route are now logged at activation:
   both asks are best-effort, and without the granted value an audio-jitter report
   arrives with no way to tell a 10 ms session from a 5 ms one.

3. The hard-cap trim spliced RAW, on the reasoning that a ring which blew its ceiling
   "is already a discontinuity". That describes the arrivals, not the samples either
   side of the seam, which are ordinary continuous audio — and it is the drop that
   actually fires: the same link above trims 120 times per 10 minutes where drift
   sheds a handful. The gentle path that almost never runs was the one being faded.
   Both kinds fade now, told apart by a new `JitterStep::hard_trim` rather than by
   the fade length. `crossfade_drop` lost its `Vec` in the process — it blends in
   place in one ascending pass, which it must, now that it runs on every trim inside
   a realtime callback.

Fixes 1 and 3 live in the shared `JitterPolicy`, so Windows, Linux and Android get
them without change (all three already pass `step.crossfade` into `crossfade_drop`).
The Swift mirror in `AudioRing` is kept in step, including the generalised
`dropFront(_:)` the cap trim now shares with the drift shed.

Gates: 210 core tests, 288 Swift tests, clippy --all-features --all-targets, fmt,
plus an iOS-triple typecheck for the `#if os(iOS)` session change. Both new fuse
tests were plant-the-defect verified: restoring a fixed count reproduces
20/32/40/64/84 ms across the quanta (a 4.2x spread) and fails them loudly.

Not fixed here: drift correction is still one-directional, so a host clock running
SLOW is corrected only by starving and re-priming. That is the remaining periodic
gap on a clean link and it needs rate adaptation — designed separately.
2026-08-13 17:14:47 +02:00
enricobuehler 362d532d25 chore(api): regenerate openapi.json for the scanner-removal doc changes
apple / swift (pull_request) Successful in 1m56s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Failing after 24s
ci / web (pull_request) Successful in 3m51s
ci / docs-site (pull_request) Successful in 4m19s
ci / bun-nix (pull_request) Successful in 5m28s
android / android (pull_request) Successful in 9m43s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m13s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m51s
ci / rust (pull_request) Successful in 35m11s
nix / flake (pull_request) Failing after 2m0s
`mgmt::tests::openapi_document_is_complete_and_checked_in` compares the served
document against the checked-in snapshot, so the endpoint doc edits in the
scanner removal made it stale and failed `ci / rust`.

Regenerated with `cargo run -p punktfunk-host -- openapi > api/openapi.json`.
The diff is 11 lines, all descriptions — no path, operationId or schema shape
moved. In particular `SourceOrigin` still enumerates ["builtin", "plugin"]:
the variant was kept deliberately so the console, which ships as its own
package and drives an N-1 host that still reports builtin sources, does not
have its generated union narrowed out from under that pairing.
2026-08-13 17:06:15 +02:00
enricobuehler b2a9b281f0 Merge pull request #198 from unom/worktree-console-plugin-update-all
ci / web (push) Successful in 5m25s
ci / rust-arm64 (push) Successful in 5m54s
ci / docs-site (push) Successful in 1m28s
ci / bun-nix (push) Successful in 47s
ci / rust (push) Successful in 10m36s
deb / build-publish-gamescope (push) Successful in 2m27s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 57s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 36s
deb / build-publish-client-arm64 (push) Failing after 1m10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 36s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 37s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 15s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 28s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 32s
deb / build-publish-host (push) Successful in 4m57s
deb / build-publish (push) Failing after 6m44s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 4m17s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5m26s
docker / builders-arm64cross (push) Successful in 2m59s
arch / build-publish (push) Canceled after 0s
deb / smoke-install (push) Canceled after 4m36s
docker / deploy-docs (push) Canceled after 2m31s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 5m56s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 11m49s
windows-host / package (push) Canceled after 13m36s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
feat(web-console): "Update all" on the plugins screen
2026-08-13 14:55:49 +00:00
enricobuehler e568513f74 fix(web): retry the console image's dependency install once
ci / bun-nix (pull_request) Successful in 23s
apple / swift (pull_request) Successful in 2m6s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 4m44s
ci / docs-site (pull_request) Successful in 5m11s
ci / rust (pull_request) Failing after 9m57s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 11m21s
android / android (pull_request) Canceled after 13m12s
ci / rust-arm64 (pull_request) Canceled after 13m8s
nix / flake (pull_request) Canceled after 12m40s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Canceled after 1m31s
A single failed tarball kills `bun install` and takes the whole image build with
it. Seen in CI as:

    error: Fail extracting tarball for "@rolldown/binding-linux-x64-musl"

— a 7.7 MB optional binding that bun fetches on any linux-x64 host (the lockfile
records `os`/`cpu` but no libc, so the musl and glibc bindings are equally
eligible) and that had arrived truncated.

The lockfile is NOT at fault, which is worth recording because it is the obvious
suspect: `bun install --frozen-lockfile` accepts it, regenerating it with bun
1.3.14 — the version in the failing log — is byte-identical, the tarball
downloads and extracts cleanly, and this exact layer builds green for
`--platform linux/amd64` with `--no-cache`.

So this is a transient-download guard, not a lockfile fix: two attempts with a
pause, then fail for real. It recovers a truncated download and deliberately does
NOT paper over a runner that is out of disk, which fails identically every time.
2026-08-13 16:52:12 +02:00
enricobuehler 7403450a8d feat(host/library)!: the six built-in scanners become plugins — M6/WP6.4
The host no longer scans any launcher itself. `library/{steam,epic,gog,heroic,
lutris,xbox}.rs` and the `scanner_defs()` table are gone; `GET /library/scanners`
now lists exactly what the operator installed, every row `origin: "plugin"`.

This is the end of the migration whose bridge half shipped in v0.26.0. The
plugins have been published and index-pinned since 2026-08-08, so the
replacement has been in the field for the whole bridge window.

A host with no library plugins installed has an empty grid — that is the upgrade
note. The console's one-click install per source (the D9 nudge) is unchanged and
still never auto-installs.

Nothing about a title changes when its plugin takes over, and that is why this
could be a deletion rather than a rewrite: a plugin CLAIMS its store (D2), and a
claimed entry surfaces under the deterministic `<store>:<external_id>` id the
scanner used to produce. Entry ids, GameStream FNV-1a app ids, client art
caches, Moonlight pins, the per-source toggles and the per-entry hides all key on
that id and none of them move. `library-scanners.json` keeps its name, shape and
contents: an operator who had `steam` off still has it off, with no migration.

Kept deliberately:

* `launch.rs` in full. Launch is host-owned by design D1 — a plugin publishes a
  validated value, the host builds the command — so every typed kind survives.
  `xbox_pfn()` MOVED here out of the deleted `xbox.rs`: resolving a package
  Identity to its PackageFamilyName needs `AppRepository` enumeration, readable
  by the host (LocalSystem) and denied to the plugin runner (LocalService). That
  measured asymmetry is the whole reason the `xbox` launch kind exists, so the
  resolver is launch vocabulary, not scanner vocabulary.
* `SourceOrigin::Builtin`. No host build emits it, but the console ships as its
  own package and drives an N-1 host that still does, so the variant stays in the
  schema and the console keeps its `builtin` handling.
* A store-label table, so a source row does not rename itself from "Steam" to
  `steam` the day its plugin takes over.

Removed with the scanners: the background cover-art warmer and its on-disk cache
(they existed only for GOG and Xbox, the two sources that had to ask a network
catalog what a cover was — a plugin resolves art while it scans), the legacy
`steam:` branch of the art proxy, and `GameMeta::pc()`. The host now makes no
outbound HTTP request to build a library at all.

Dependency audit, as WP6.4 required: `rusqlite` (with its bundled, cc-compiled
SQLite) and `roxmltree` leave the graph — verified no other users. `winreg`
stays: `launch.rs`, `procscan/windows.rs` and two `audio/windows/` modules need
it. `base64`/`ureq` stay, exactly as the plan predicted.

A stale `library-art-cache.json` from an older host is ignored, not migrated.
2026-08-13 16:51:19 +02:00
enricobuehler 141c04cd6b feat(web-console): "Update all" on the plugins screen
ci / bun-nix (pull_request) Successful in 29s
ci / web (pull_request) Successful in 1m8s
ci / docs-site (pull_request) Successful in 2m39s
ci / rust-arm64 (pull_request) Successful in 3m16s
ci / rust (pull_request) Successful in 7m55s
The Installed tab could only update one plugin at a time, one dialog and one
watched job each. This adds the bulk action beside the list it acts on — the
same place Sources keeps "Refresh all" — plus a count badge on the Installed
tab trigger, because Browse is the tab the page opens on and a control nobody
passes is a control nobody finds.

The host takes ONE package operation at a time (409 otherwise: bun operations
share a lockfile and a node_modules tree), so this is a queue the console works
through job by job, driven by each job settling rather than by a timer. The run
carries its own copy of what is left: every finished install invalidates the
installed list, and a queue that re-derived itself would change shape underneath
a run the operator already confirmed.

Trust rules are unchanged, only taken once instead of N times. If any entry in
the run comes from an operator-added source the whole dialog wears the external
treatment and names those catalogs — a bulk action must not be a way to wave
through, in one click, a warning each package would have shown on its own. The
dialog lists every version change rather than a count, and names what it will
not attempt: an update with no catalog entry, or one this host would refuse
(400 on incompatible, blocked entries) never enters the queue, so the button's
count still adds up on screen.

A failure ends the run. The failed job's card is the only record of what went
wrong, and starting the next install would replace it with a fresh spinner; the
toast says what was applied and what was not, and the rows are still there to
retry from.

Also fixed, because this change leans on it: disabled buttons were invisible.
AnimatedButton is a motion element and its mount animation settles as an inline
`opacity: 1`, which outranks the `disabled:opacity-50` class the library also
ships — measured `opacity: 1` on a disabled button, console-wide. Only
`pointer-events: none` landed, so every disabled control in the app looked live
and silently ignored the click. Corrected in the components/ui wrapper layer
like the other @unom/ui adaptations.

Verified: tsc, biome, `bun test server/`, production build, i18n check (650
messages, en + de). Storybook stories added for the list header and the confirm
dialog; both rendered headless in light and dark, with the disabled states
asserted on the DOM rather than by eye.
2026-08-13 16:43:05 +02:00
enricobuehler 0a53457cb7 Merge pull request 'feat(android): drop the in-stream mic control from the stream overlay' (#197) from worktree-android-remove-mic-button into main
android / android (push) Successful in 7m45s
ci / rust-arm64 (push) Successful in 1m53s
ci / bun-nix (push) Successful in 23s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 23s
ci / docs-site (push) Successful in 1m14s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 17s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 43s
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 17s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 15s
ci / web (push) Successful in 2m11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Failing after 1m14s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m26s
docker / deploy-docs (push) Skipped
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 3m59s
docker / builders-arm64cross (push) Successful in 2m40s
ci / rust (push) Successful in 23m24s
2026-08-13 14:03:27 +00:00
enricobuehler e7af5a5274 feat(android): drop the in-stream mic control from the stream overlay
ci / rust-arm64 (pull_request) Successful in 2m2s
ci / docs-site (pull_request) Successful in 1m53s
ci / bun-nix (pull_request) Successful in 24s
ci / web (pull_request) Successful in 7m24s
ci / rust (pull_request) Successful in 15m22s
android / android (pull_request) Failing after 1m3s
The mic element sat in the top-right corner of every stream that opened a
capture — a standing button on touch, a Muted badge on TV. It goes for now;
the on-screen overlay UI being built will carry mute as one of its controls,
and re-introducing it there is the right moment to decide how it looks.

Mute itself is untouched: `micRunning`, `micMuted` and `setMicMuted` still
back the Select + Y chord, which is now the whole of the control, and
`MicChordHint` is now its only on-screen feedback (its doc updated to say so
rather than pointing at the badge that no longer exists).
2026-08-13 16:01:55 +02:00
enricobuehler 155cced56b Merge pull request 'fix(flatpak): pin the skia-binaries archive to 0.99.0 — #193 bumped the crate and left the tarball at 0.87' (#196) from worktree-flatpak-skia-099-pin into main
android / android (push) Canceled after 9m25s
ci / rust (push) Canceled after 7m50s
ci / rust-arm64 (push) Canceled after 4m48s
ci / web (push) Canceled after 1m31s
ci / docs-site (push) Canceled after 32s
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 / deploy-docs (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
arch / build-publish (push) Successful in 11m33s
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 18m30s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 6m56s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 21m27s
flatpak / build-publish (push) Successful in 25m44s
deb / build-publish (push) Successful in 4m8s
deb / build-publish-client-arm64 (push) Successful in 3m10s
deb / build-publish-gamescope (push) Successful in 55s
deb / build-publish-host (push) Successful in 5m53s
deb / smoke-install (push) Successful in 3m53s
2026-08-13 13:53:18 +00:00
enricobuehler 8f66fafb90 Merge pull request 'fix(pf-vdisplay): verify the size KWin actually built the virtual output at (≤60 Hz path)' (#194) from worktree-moonlight-4k60-dims into main
android / android (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
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
windows-host / package (push) Successful in 12m40s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 26s
Reviewed-on: #194
2026-08-13 13:52:29 +00:00
enricobuehler 6fe53fff2b Merge pull request 'Serialize Mutter monitor rebuilds end-to-end — the two-client chain no longer segfaults gnome-shell' (#195) from worktree-mutter-rebuild-serialization into main
android / android (push) Canceled after 35s
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 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
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: #195
2026-08-13 13:51:48 +00:00
enricobuehler 1679275272 fix(flatpak): pin the skia-binaries archive to 0.99.0 — #193 bumped the crate and left the tarball at 0.87
ci / bun-nix (pull_request) Successful in 26s
ci / docs-site (pull_request) Successful in 1m19s
ci / rust-arm64 (pull_request) Successful in 1m47s
ci / web (pull_request) Successful in 3m23s
android / android (pull_request) Successful in 5m16s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 7m3s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m57s
ci / rust (pull_request) Successful in 8m51s
The dependency currency wave took skia-safe/skia-bindings 0.87.0 -> 0.99.0 in
crates/pf-console-ui/Cargo.toml, but packaging/flatpak/io.unom.Punktfunk.yml still
pinned the 0.87.0 prebuilt archive, so every flatpak leg since the merge dies with

    error[E0599]: no variant, associated function, or constant named `Default`
                  found for enum `SkPathFillType`   (and `SkPathDirection`)
    --> cargo/vendor/skia-bindings-0.99.0/src/defaults.rs:57

Nothing about that message points at the manifest, so it reads like a crate bug. It
isn't. `SKIA_BINARIES_URL: file://…` makes skia-bindings unpack the pinned tarball
verbatim into target/…/build/skia-bindings-*/out/skia/ — *including the bindings.rs
it was generated with*. Those two `Default`s are associated consts emitted INTO
bindings.rs, so they travel with the archive, not with the crate: 0.99.0's
src/defaults.rs was compiling against 0.87.0-era bindings. Verified directly — the
0.99.0 archive carries `impl SkPathFillType { pub const Default = Winding }` and
`impl SkPathDirection { pub const Default = CW }` on both x86_64 and aarch64.

Because the URL is file://, the fetch can never fail, so there is no download error
to notice — the only symptom is a compile error deep in a vendored crate.

The asset name changed across the bump: `jpeg` entered skia-safe's defaults at 0.99,
so the resolved-feature key went `pdf-textlayout-vulkan` -> `jpegd-jpege-pdf-textlayout-vulkan`.
Confirmed against each archive's own key.txt/tag.txt (tag 0.99.0, key
a25a0fdb7d90429aa2d1-<target>-jpegd-jpege-pdf-textlayout-vulkan), and libskparagraph.a
plus the Vulkan backend symbols are present, so the feature set still matches what
pf-console-ui resolves.

Everything else in the offline chain (Cargo.lock, cargo-sources.json) is regenerated
from the lock and self-corrects; this tarball is the single hand-maintained pin, which
is exactly why it was the thing left behind. Both bump sites now carry a pointer to
the other so the next one can't split-brain the same way.
2026-08-13 15:48:48 +02:00
enricobuehler a5c9b7b865 fix(pf-vdisplay): serialize Mutter monitor rebuilds end-to-end, not just our D-Bus calls
ci / web (pull_request) Successful in 1m17s
ci / bun-nix (pull_request) Successful in 1m24s
ci / docs-site (pull_request) Successful in 1m27s
android / android (pull_request) Successful in 4m36s
ci / rust-arm64 (pull_request) Successful in 6m14s
ci / rust (pull_request) Successful in 33m50s
Chaining two clients through a kept (keep-alive) Mutter display segfaults
gnome-shell in meta_monitor_manager_rebuild (libmutter-18) and takes the whole
desktop down; every later session then fails RemoteDesktop.CreateSession:
ServiceUnknown until GDM restarts. A/B'd on .21: byte-identical on released
0.27.0 and the 0.28.0 RC, so it was never a regression — the trigger has been
there all along.

TOPOLOGY_LOCK already serialized every topology-mutating D-Bus call, but two
gaps still let Mutter's REBUILDS overlap:

- Teardown was fire-and-forget: StopGuard::drop set a flag and returned, and
  the session thread only noticed on its ≤200 ms park tick. The A2 dead-reuse
  path (reused kept display dead on first frame → mark_failed → re-create)
  therefore issued its fresh RecordVirtual with the doomed monitor's removal
  still pending — the fresh session could even win the lock BEFORE the old
  thread had woken to take it, adding a monitor while the dead one still stood.
  The drop now waits (bounded, 20 s) for the session thread to finish.

- The lock was released while the shell was still rebuilding: Stop /
  RecordVirtual / ApplyMonitorsConfig all return mid-rebuild, and a temporary
  (APPLY_TEMPORARY) config auto-reverts asynchronously on top. Every locked
  mutation section now ends with settle_topology() — poll GetCurrentState
  until a removed connector is actually gone and the config serial holds still
  across two consecutive reads — before the guard drops. Bounded at 4 s and
  best-effort (a read error means the shell is gone; a hotplug storm must not
  park sessions), degrading to exactly the old behavior.

Cost when Mutter is already quiet: one confirming read plus one 150 ms recheck
per setup/teardown. The live_mutter_create_drop harness sheds its grace sleep —
the synchronous drop IS the teardown confirmation now.

Not fixed here, documented on TOPOLOGY_LOCK: the mid-stream mode-switch rebuild
is create-before-drop by design (H2), so its RecordVirtual still lands while
the superseded monitor exists; the settle makes Mutter quiescent at that point
but cannot remove the coexistence itself.
2026-08-13 15:41:43 +02:00
enricobuehler 6237e3d0a3 fix(pf-vdisplay): the KWin ≤60 Hz path never checked what KWin actually built, and the log reported the request as if it were a readback
ci / bun-nix (pull_request) Successful in 37s
ci / web (pull_request) Successful in 1m14s
ci / docs-site (pull_request) Successful in 2m31s
ci / rust-arm64 (pull_request) Successful in 4m14s
android / android (pull_request) Successful in 4m53s
ci / rust (pull_request) Successful in 25m8s
A 4K60 GameStream session captured 1920x1080. `create()` asked KWin for
3840x2160, KWin built something else, and nothing compared the two: only the
>60 Hz arm read anything back, and it gets that for free because it installs a
custom mode. The ≤60 Hz arm installs nothing, which is exactly why it never
noticed.

The line that should have caught it was the one that hid it. `spawn_vout`
returns a node id, never a size, so

    tracing::info!(node_id, width, height, "KWin virtual output ready")

was echoing the REQUEST — the field log stated 3840x2160 while the output was
1080p, and the first pass at diagnosing this was done against that number. It
now logs `requested_w`/`requested_h`, and the readback sits under it.

Unverified, the mismatch was silent and total. `final_dims` carried the request
forward, so `apply_topology`, `clear_replication_source` and
`resolve_kscreen_addr` — all of which resolve by dims — quietly missed their own
output, leaving the stream neither primary nor de-mirrored; and the encoder
opened at the captured size, handing the client a bitstream that disagreed with
the resolution it had configured its decoder from.

Suspected trigger is KWin restoring per-output mode/scale from
kwinoutputconfig.json, which is keyed by output NAME — and ours is deliberately
stable across sessions so KDE reapplies that client's scaling (Stage 3). The
feature and the failure are the same mechanism.

- `kwin_output_mgmt::actual_dims()` reads the output's real mode + scale.
  Resolution is by name alone, so it declines unless EXACTLY one output carries
  our prefix: two means a supersede is in flight, and the dims filter is the
  only thing that can tell the replacement from the predecessor whose name it
  reuses. Failing closed keeps this a pure addition.
- On a mismatch, re-assert the requested mode through the same
  `set_custom_mode` install+select the sacrificial birth already uses (an output
  at a size we don't want, moved to one we do) and arm `expect_exact_dims` so
  the capturer holds frames until the screencast renegotiates. 60 Hz is
  requested, not `mode.refresh_hz`: only the size is wrong here, and asking for
  the client's rate would install a 30 Hz mode for a 30 fps client.
- If KWin refuses the correction, report the size that is REALLY there rather
  than the request, so the dims-keyed resolves and the encoder key on reality,
  and say in the log how to clear the stored entry.
- Scale is logged, never corrected — a non-unity scale here is the Stage 3
  feature working, not a fault.
- `mode_satisfies()` extracts the acceptance predicate both arms now share, so
  they cannot drift into disagreeing about what "we got what we asked for"
  means. Tested: a restored 1080p does not pass for a 4K request, a CVT-aligned
  width does, and the slack is bounded, one-sided and width-only.

The stream-side warning is reworded but deliberately still NOT fatal: mirroring
a pinned monitor streams a size the client never negotiated BY DESIGN (§7.3 — a
panel runs at the mode its owner set and the client scales), so refusing the
mismatch would break every mirror session. It now names both causes and states
what the client actually does with the stream.

Does not claim to close the Xbox Moonlight disconnect it was found through: that
client's IDR storm begins ~4.6 s after the first frame, which a decoder simply
unable to handle the size would not do. The 1080p-instead-of-4K is a real defect
on its own terms and is what this fixes.
2026-08-13 15:26:35 +02:00
enricobuehler 14425716e5 Merge pull request 'Dependency currency wave: skia-safe 0.99, the RustCrypto digest-0.11 family, jni 0.22 and ten more — plus the notices they invalidated' (#193) from worktree-dep-currency-wave into main
apple / swift (push) Successful in 2m0s
audit / cargo-audit (push) Successful in 2m45s
audit / bun-audit (plugin-kit) (push) Successful in 43s
audit / bun-audit (sdk) (push) Successful in 38s
audit / bun-audit (web) (push) Successful in 19s
audit / pnpm-audit (push) Successful in 13s
audit / docs-site-audit (push) Successful in 31s
android / android (push) Successful in 8m25s
audit / license-gate (push) Successful in 5m42s
audit / miri (push) Successful in 7m59s
apple / distribute (push) Successful in 12m31s
audit / c-abi-asan (push) Successful in 7m8s
ci / bun-nix (push) Successful in 26s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m23s
ci / docs-site (push) Successful in 2m2s
ci / rust-arm64 (push) Successful in 5m51s
ci / web (push) Successful in 6m18s
arch / build-publish (push) Successful in 20m52s
apple / screenshots (push) Successful in 6m55s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Failing after 1m18s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 6m53s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 5m51s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4m30s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m2s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 6m53s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 3m58s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m38s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 7m9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7m7s
docker / builders-arm64cross (push) Skipped
docker / deploy-docs (push) Failing after 2m35s
windows-host / package (push) Successful in 18m44s
windows-host / winget-source (push) Skipped
deb / build-publish (push) Successful in 7m9s
deb / build-publish-gamescope (push) Successful in 4m42s
deb / build-publish-client-arm64 (push) Successful in 4m6s
windows-host / canary-manifest (push) Successful in 45s
ci / rust (push) Successful in 31m28s
flatpak / build-publish (push) Failing after 15m23s
nix / flake (push) Successful in 16m36s
deb / build-publish-host (push) Successful in 10m13s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m31s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 2m9s
deb / smoke-install (push) Successful in 10m18s
Reviewed-on: #193
2026-08-13 12:41:54 +00:00
enricobuehler 4903c9d3b5 docs(host): the P-256 identity comment still named ring's backend
ci / docs-site (pull_request) Successful in 1m25s
apple / swift (pull_request) Successful in 2m15s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / bun-nix (pull_request) Successful in 2m33s
ci / web (pull_request) Successful in 3m26s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 6m20s
android / android (pull_request) Successful in 7m34s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 9m4s
ci / rust-arm64 (pull_request) Successful in 16m13s
nix / flake (pull_request) Successful in 16m36s
ci / rust (pull_request) Successful in 47m49s
#192 moved rcgen to aws-lc-rs and removed ring from the tree, but this comment
still explained the P-256 path in terms of "rcgen's ring backend". It also
cross-references gamestream::cert's note, which this branch already corrected —
so leaving it made the two contradict each other.

The substance is unchanged and still load-bearing: rcgen generates EC keys
directly, while RSA has to be generated by the `rsa` crate and handed to rcgen
to self-sign, because no rcgen backend will generate an RSA key.
2026-08-13 14:34:43 +02:00
enricobuehler a4af1ee8bd chore(deps): regenerate third-party notices for the currency wave
Covers all five generated files, not just the root one: the four per-client
copies are scoped to the binaries their package installs, so they move
independently of the workspace-wide file.

Root: 571 -> 575 crates, reflecting this wave (skia-safe 0.99, the RustCrypto
digest-0.11 family, jni 0.22, x11rb 0.14, reis 0.7, xkbcommon 0.9, wasapi 0.24,
windows-service 0.8.1, x509-parser 0.18, rand 0.9, base64 0.23, libloading 0.9,
mdns-sd 0.21 + if-addrs 0.15, rcgen 0.14, criterion 0.8, android_logger 0.15).

The per-client diffs are much larger than the wave alone explains, because they
were never regenerated after #192: all four still attributed `ring` and named no
aws-lc-rs at all. Since #192 removed ring from the tree entirely, the shipped
Acknowledgements screens have been crediting a crypto library the clients do not
carry while omitting the one they do. They now catch up on both changes at once.
(`ring` still appears via the generator's deliberate `--all-features`
over-approximation, which sees quinn-proto's wasm-only edge; that is by design —
listing an unlinked crate is untidy, omitting a linked one is the failure the
file exists to prevent.)

Also stops gen-third-party-notices.sh preferring `cargo about` for the root file.
That preference was silently destructive: cargo-about only sees CARGO
dependencies, so it drops every VENDORED_TREES entry -- pyrowave, the Granite
subset, volk, Vulkan-Headers, the Font Awesome brand icons, Simple Icons -- which
are third-party sources shipped inside first-party crates under their own
licences. Measured today: cargo-about emitted 7,274 lines / ~514 crates with zero
mentions of volk, Vulkan-Headers or Font Awesome, against the python generator's
17,324 / 575 with all of them. Merely having cargo-about on PATH was enough to
degrade the file, so anyone regenerating after this commit would have undone it.
cargo-about remains what the CI licence gate runs -- that job asks a different
question (is every licence in the about.toml allowlist) and writes to /dev/null.

Both licence-gate legs pass: `cargo about generate about.hbs --fail` and the
drivers-workspace leg, RC=0.
2026-08-13 14:27:15 +02:00
enricobuehler 5cd4da4b46 Merge branch 'worktree-agent-a3a9cb8eda476b361' into worktree-dep-currency-wave
# Conflicts:
#	Cargo.lock
#	crates/punktfunk-host/Cargo.toml
2026-08-13 14:22:36 +02:00
enricobuehler 59346b46dc Merge branch 'worktree-agent-acec77fec2148a724' into worktree-dep-currency-wave 2026-08-13 14:19:25 +02:00
enricobuehler 76b80cffb5 Merge branch 'worktree-agent-aaab7ab43ea5de9c8' into worktree-dep-currency-wave 2026-08-13 14:19:21 +02:00
enricobuehler 9e492bfec3 Merge branch 'worktree-agent-a5cf35a576dde87f5' into worktree-dep-currency-wave 2026-08-13 14:19:16 +02:00
enricobuehler 692bfbaa4f Merge branch 'worktree-agent-a220ca550d5f8734b' into worktree-dep-currency-wave 2026-08-13 14:19:11 +02:00
enricobuehler f9932e0873 Merge branch 'worktree-agent-a61772a0f20a2e4f1' into worktree-dep-currency-wave 2026-08-13 14:19:07 +02:00
enricobuehler bd987d373e chore(deps): criterion 0.5 -> 0.8 (dev-only, benches)
Dev-dependency of punktfunk-core only — it ships in nothing. `default-features =
false, features = ["cargo_bench_support"]` carries over unchanged; that feature
still exists in 0.8 and still keeps plotters/rayon out of a headless CI run.

One source change, and it is a lint issue rather than an API one.
`criterion::black_box` survives in 0.8 but is `#[deprecated]` — it now just
forwards to `std::hint::black_box` — and benches ARE compiled by
`cargo clippy --workspace --all-targets -- -D warnings`, so keeping the criterion
import would have turned a deprecation warning into a failed lint gate. The
bench imports the std one directly.

What CI actually consumes from criterion is the on-disk result layout, so that
was checked rather than assumed: 0.8 still writes
`target/criterion/<group>/<id>/new/estimates.json`, and the key
scripts/bench/compare.py reads — `median.point_estimate` — is still there:

  $ cargo bench -p punktfunk-core --bench pipeline -- --warm-up-time 1 --measurement-time 3
  ... 12/12 benchmarks reported, e.g. pipeline/gf16/1048576  thrpt: [537 MiB/s 540 MiB/s 542 MiB/s]
  $ find target/criterion -name estimates.json | wc -l
  24
  $ python3 -c 'import json; print(json.load(open(".../crypto/seal/new/estimates.json"))["median"]["point_estimate"])'
  817.96

Verified on CachyOS (rustc 1.96.0):
  cargo clippy -p punktfunk-core --all-targets --locked -- -D warnings   OK  (this is what compiles the bench)
  cargo clippy --workspace --all-targets --locked -- -D warnings         OK
  cargo bench -p punktfunk-core --bench pipeline --locked -- --test      12/12 Success
  cargo bench -p punktfunk-core --bench pipeline --locked -- --warm-up-time 1 --measurement-time 3   OK  (CI's exact line)
  cargo test -p punktfunk-core --locked   210 + 8 + 1 passed, 0 failed
  cargo fmt --all --check                 clean
2026-08-13 14:14:25 +02:00
enricobuehler deb83ecc48 chore(deps): rcgen 0.13 -> 0.14 in core and the host
Both declarations keep `default-features = false, features = ["aws_lc_rs",
"pem"]`, which stays load-bearing in 0.14: `ring` is still in rcgen's DEFAULT
feature set, so dropping `default-features = false` would drag the backend this
tree deliberately left back in. Verified after the bump — `cargo tree -i ring`
finds nothing on x86_64-unknown-linux-gnu, x86_64-pc-windows-msvc,
aarch64-apple-darwin or aarch64-linux-android.

One breaking change reached us. `CertifiedKey`'s key field was renamed
`key_pair` -> `signing_key` (the struct is now generic, `CertifiedKey<S:
SigningKey>`), which hits the two `generate_simple_self_signed` callers in
core's quic/endpoint.rs — the dev/test server endpoint and `generate_identity`.
Both are a field rename; `KeyPair` still provides `serialize_der`/`serialize_pem`.

Everything the GAMESTREAM identity depends on survives unchanged, which is what
this bump's precondition was about: `KeyPair::from_pkcs8_pem_and_sign_algo`,
`PKCS_RSA_SHA256`, `CertificateParams::new`, `date_time_ymd`, `self_signed`,
`Certificate::pem`. The split in gamestream/cert.rs is therefore untouched — the
RSA-2048 key is still minted by the `rsa` crate and handed to rcgen only to load
and self-sign, because rcgen cannot generate an RSA key on the backend we use.
That path is asserted, not assumed:

  cargo test -p punktfunk-host --bins --locked gamestream::cert    3 passed, 0 failed
    generate_mints_a_loadable_rsa2048_identity          ok   (fresh-install keygen)
    moonlight_shaped_peer_completes_a_tls12_mutual_handshake   ok
    tls13_negotiates_the_post_quantum_group             ok   (X25519MLKEM768 by name)

Verified on CachyOS (rustc 1.96.0):
  cargo clippy --workspace --all-targets --locked -- -D warnings        OK
  cargo clippy -p punktfunk-host -p punktfunk-core --locked -- -D warnings   OK  (shipping build)
  cargo test --workspace --locked      78 test binaries, all ok, 0 failed
  cargo fmt --all --check              clean
2026-08-13 14:10:10 +02:00
enricobuehler 2718b7d4bd chore(deps): mdns-sd 0.20 -> 0.21 and if-addrs 0.13 -> 0.15, together
One commit because splitting them accomplishes nothing: mdns-sd 0.20 ALREADY
depends on if-addrs 0.15, so while our own five crates declared 0.13 the tree
carried both copies no matter which of the two moved first. Moving them together
is what collapses it:

    $ cargo tree -d | grep '^if-addrs'
    (no output)

Neither needed a source change. mdns-sd 0.21's public API is purely additive
over 0.20.3 — the sole new item is `ServiceDaemon::set_max_packet_size`, and
`ServiceInfo`'s surface is byte-identical — so `ServiceDaemon`/`ServiceInfo`/
`ServiceEvent`/`ResolvedService` behave as before at all six call sites
(host discovery + gamestream mdns, pf-client-core, and the Android, Windows and
probe clients). if-addrs 0.15 keeps 0.13's `Interface`/`IfAddr` shape, and we
only ever read those.

The one real change is a FEATURE, not a version. if-addrs has `link-local`, and
mdns-sd declares if-addrs with it on. Once our crates share that single copy,
unification turns it on for our calls too — meaning `get_if_addrs()` now also
reports fe80:: interfaces (and, on Windows, 169.254.x.x). Rather than inherit
that silently, punktfunk-core and punktfunk-host now NAME the feature. Two
reasons: it is what every real build gets anyway, so a standalone `cargo test -p
punktfunk-core` should not enumerate a different set of NICs than the host does;
and for the consumer here — Wake-on-LAN — it is the behaviour we want, since a
NIC is wake-capable whether or not it currently holds a routable address.

Verified on CachyOS (rustc 1.96.0):
  cargo clippy --workspace --all-targets --locked -- -D warnings   OK
  cargo test --workspace --locked                                  OK, 0 failed
  cargo test -p punktfunk-host --bins --locked      501 passed, 0 failed, 2 ignored
  cargo test ... gamestream::cert                   3 passed
  cargo fmt --all --check                           clean

(One `cargo test --workspace` attempt failed with E0463 "can't find crate for
pf_frame" in a doc-test. That is the target dir having only clippy's .rmeta for
a crate a doc-test wants to LINK, not anything in this change; a plain re-run
after cargo test built the rlibs was green.)
2026-08-13 14:10:10 +02:00
enricobuehler 99c3a47bbf chore(deps): libloading 0.8 -> 0.9 across the five crates that dlopen
All five declarations move together (pf-encode twice — Linux and Windows —
plus pf-client-core, pf-zerocopy and punktfunk-host), because a split would
have compiled two copies of a crate whose whole job is holding a process-wide
dlopen handle.

No source changes. 0.9 replaces the concrete parameter types with sealed traits
— `Library::new(impl AsFilename)` and `Library::get(impl AsSymbolName)` — and
both cover what our 16 call sites already pass: `&str` literals for the sonames
(`libnvidia-encode.so.1`, `libva.so.2`, `libnvidia-ml.so.1`, `libcuda.so.1`) and
`&[u8; N]` NUL-terminated byte literals for the symbols, which 0.9 implements
explicitly alongside `&[u8]`. MSRV rises to 1.88; the workspace pins 1.96.

libloading 0.8 does not leave the lock, and shouldn't: what remains is
`clang-sys` under `bindgen`, reached only as a BUILD-dependency of
ffmpeg-sys-next / libspa-sys / pyrowave-sys. That copy runs at build time and is
linked into nothing we ship.

Verified on CachyOS (rustc 1.96.0):
  cargo clippy --workspace --all-targets --locked -- -D warnings                 OK
  cargo clippy -p pf-encode --all-targets --locked --features nvenc,vulkan-encode,pyrowave -- -D warnings   OK
      (the only leg that compiles enc/linux/nvenc_cuda.rs, where the `lib.get(b"…\0")` calls live)
  cargo clippy -p punktfunk-host -p pf-encode -p pf-zerocopy -p pf-client-core --locked -- -D warnings   OK  (shipping build)
  cargo test -p punktfunk-host --bins --locked    501 passed, 0 failed, 2 ignored
  cargo test -p pf-encode --locked                33 passed, 5 ignored
  cargo test -p pf-zerocopy --locked              40 passed
  cargo fmt --all --check                         clean
2026-08-13 14:10:10 +02:00
enricobuehler 677b8ceb41 chore(deps): base64 0.22 -> 0.23 in the host and pf-update-check
ureq 3 already pulls 0.23, so the tree compiled both minors purely because our
two direct declarations named the older one. The API we use — `Engine`,
`engine::general_purpose::STANDARD`, `encode`/`decode` — is unchanged in 0.23;
no source edits.

Both are declared `default-features = false, features = ["std"]` rather than
taking the defaults. 0.23 added `simd-unsafe` (hand-written AVX2/NEON engines)
as a DEFAULT-ON feature, and ureq declares base64 with default features off, so
today that code is not in the tree. Accepting the defaults here would unify the
feature on and quietly add an unsafe SIMD engine to every artifact as a side
effect of a currency bump. Whether to enable it is a perf question deserving a
measurement and its own commit; this one changes versions, not exposure. `std`
covers every call site (encode to `String`, decode to `Vec`).

base64 0.22 does NOT leave the tree: `rcgen` -> `pem` 3.0.6 is now its sole
remaining consumer, and it stays one after the rcgen 0.14 bump later in this
batch — 0.14 still depends on `pem` "3.0.2", which resolves to the same 3.0.6,
which still uses base64 0.22. Clearing that last copy is upstream's move (a
`pem` release on 0.23), not ours.

Verified on CachyOS (rustc 1.96.0):
  cargo clippy -p punktfunk-host -p pf-update-check --locked --all-targets -- -D warnings   OK
  cargo clippy -p punktfunk-host -p pf-update-check --locked -- -D warnings                 OK  (shipping build)
  cargo test -p punktfunk-host --bins --locked    501 passed, 0 failed, 2 ignored
  cargo test -p pf-update-check --locked          32 passed, 0 failed
  cargo fmt --all --check                         clean
2026-08-13 14:10:10 +02:00
enricobuehler 4358261387 chore(deps): rand 0.8 -> 0.9 in the host, the last crate speaking the old major
punktfunk-core and pf-client-core were already on 0.9; the host sat on 0.8 by
drift, not by decision, so every build compiled two rand majors to satisfy six
call sites. Mechanical at five of them (`thread_rng()` -> `rng()`,
`gen_range` -> `random_range`); the sixth is the interesting one.

`RsaPrivateKey::new` is bounded on rand_core **0.6**'s `CryptoRngCore`. rand
0.9's `ThreadRng` implements rand_core 0.9's traits — same names, different
crate — so the RSA-2048 keygen in gamestream/cert.rs stopped satisfying the
bound the moment the version moved. It now draws from `rsa::rand_core::OsRng`:
rsa's own re-export, which is by construction the traits rsa compiled against,
so the two rand_core majors never have to meet in our source. That needs
`features = ["getrandom"]` on rsa (not one of its defaults) — and it must be
declared HERE rather than left to feature unification, because dropping our own
rand 0.8 also dropped the `std` feature that used to switch `rand_core/getrandom`
on as a side effect.

What this does and does not clear. The GAMESTREAM host keeps rand 0.8 +
rand_chacha 0.3 — `rsa` drags them in through `num-bigint-dig`, which is not
ours to move:

    $ cargo tree -i rand@0.8.7
    rand v0.8.7
    └── num-bigint-dig v0.8.6
        └── rsa v0.9.10
            └── punktfunk-host

The NATIVE-ONLY host (--no-default-features, no rsa) now sheds both entirely —
`cargo tree -p punktfunk-host --no-default-features --features pyrowave -i
rand@0.8.7` and the same for rand_chacha@0.3.1 both report no match. rand_core
0.6 stays in every flavour regardless: `crypto-common` (under aes-gcm) needs it,
which no rand bump can change.

`windows/install.rs`'s `random_password` is the one site a Linux box cannot
type-check; the edit there is the identical `thread_rng()` -> `rng()` the five
compiled sites took.

Verified on CachyOS (rustc 1.96.0):
  cargo clippy -p punktfunk-host --locked --all-targets -- -D warnings   OK
  cargo clippy -p punktfunk-host --locked -- -D warnings                 OK  (shipping build: no --all-targets)
  cargo clippy -p punktfunk-host --no-default-features --features pyrowave --all-targets --locked -- -D warnings   OK
  cargo test -p punktfunk-host --bins --locked      501 passed, 0 failed, 2 ignored
  cargo test ... gamestream::cert                   3 passed (incl. the fresh-install RSA-2048 keygen)
  cargo fmt --all --check                           clean
2026-08-13 14:02:12 +02:00
enricobuehler 8020fb6711 chore(deps): x509-parser 0.16 -> 0.18, and thiserror 1.0 leaves the host graph
x509-parser 0.16 pinned the old asn1-rs 0.6 / der-parser 9 / oid-registry 0.7
chain, and every one of those still depended on thiserror 1.0. It was the ONLY
thing doing so — so the host compiled two thiserror majors (and two
thiserror-impl proc macros) for one crate's error types. 0.18 moves the chain to
asn1-rs 0.7 + thiserror 2, which is the same major the rest of the tree already
uses, and the 1.0 half disappears — for the host, on every target and including
dev-dependencies:

    $ cargo tree -p punktfunk-host --target all -e normal,build,dev -i thiserror@1
    error: package ID specification `thiserror@1` did not match any packages

Scope that claim honestly: this clears the HOST graph, not the workspace.
thiserror 1.0 is still built, reached by `jni` 0.21 AND by the vendored
`ndk` 0.9.0, both under punktfunk-client-android. That is a different graph
and a different bump.

No source change was needed. The one API shift that could have reached us —
asn1-rs 0.7 making `BitString::data` a `Cow<[u8]>` instead of a `&[u8]` — lands
on `x509.signature_value.data.to_vec()` in gamestream/cert.rs and
gamestream/pairing.rs, and `to_vec()` goes through Deref either way. Both are
Moonlight pairing-hash inputs, so they are covered by the gamestream::cert
tests rather than taken on faith. nom 7 and nom 8 were already both in the lock;
this adds no new duplicate.

Verified on CachyOS (rustc 1.96.0):
  cargo clippy -p punktfunk-host --locked --all-targets -- -D warnings   OK
  cargo clippy -p punktfunk-host --locked -- -D warnings                 OK  (shipping build: no --all-targets)
  cargo clippy -p punktfunk-host --no-default-features --features pyrowave --all-targets --locked -- -D warnings   OK
  cargo test -p punktfunk-host --bins --locked      501 passed, 0 failed, 2 ignored
  cargo test ... gamestream::cert                   3 passed
  cargo tree -i ring                                nothing to print
2026-08-13 14:02:12 +02:00
enricobuehler 49a8f4f1d1 chore(deps): xkbcommon 0.8 -> 0.9
Version currency for the virtual-keyboard keymap in pf-inject's wlroots path
(`inject/linux/wlr.rs`), the crate's only consumer.

Additive on the Rust side: one new wrapper, `Keymap::key_get_mods_for_level`,
and the `extern` declaration behind it. Nothing we call changed shape -- we use
`Context::new`, `Keymap::new_from_names`, `get_as_string`, `State::new`,
`Keycode::new`, `KeyDirection` and the `serialize_mods`/`serialize_layout` pair,
all untouched. The feature set is unchanged too: `default = ["wayland"]` in both
releases, so `x11` -- the feature that would pull `as-raw-xcb-connection` -- stays
off and this links only `libxkbcommon`, exactly as before.

The one thing worth writing down is the new symbol's floor. On .25's
libxkbcommon 1.13.1 it reads
`xkb_keymap_key_get_mods_for_level@@V_1.0.0`, so the declaration wants
libxkbcommon >= 1.0.0 (2020) if it is ever referenced. Nothing in the workspace
calls the wrapper, so no relocation for it reaches our binaries -- and every ship
target clears 1.0 by years regardless (Ubuntu 22.04 is 1.4, Debian 12 is 1.5,
Debian 13 is 1.7). pf-inject's test binary, which unlike the rlib actually links,
builds and runs clean.

Two internal fixes come along, both in constructors we do not use:
`new_from_string` passes the Rust string's pointer + length to
`xkb_keymap_new_from_buffer` instead of round-tripping through a `CString`
(whose `unwrap()` panicked on an interior NUL), and `new_from_fd` passes the
full mapped `size` rather than `size - 1`.

Verified on .25 (Ubuntu 26.04, `CARGO_BUILD_JOBS=2`), all rc=0:

    cargo build  -p pf-inject --locked
    cargo clippy -p pf-inject --locked -- -D warnings
    cargo clippy -p pf-inject --all-targets --locked -- -D warnings
    cargo test   -p pf-inject --locked    127 passed, 0 failed, 8 ignored
                                          + motion_contract: 7 passed, 0 failed
    cargo check  -p punktfunk-host --locked
    cargo clippy -p punktfunk-host --locked -- -D warnings
    cargo fmt --all --check

Clippy is run BOTH ways because host CI lints without `--all-targets`.
`cargo tree -i ring` stays empty.
2026-08-13 14:00:05 +02:00
enricobuehler 72c7c3b17f chore(deps): reis 0.6.1 -> 0.7.1
Version currency for the libei path. pf-inject is the only consumer -- reis is a
pure-Rust implementation of the EI wire protocol, so this links nothing new and
still needs no libei on the host. The release tracks libei protocol 1.5.0 ->
1.6.0.

**Nothing in our API surface moved.** `ei::Context::new`, `handshake_tokio`,
`reis::tokio::EiConvertEventStream` and `reis::event::{Device, DeviceCapability,
EiEvent, Region}` all keep their shape; `Region`'s six fields are byte-identical.
The two enums grew variants for libei 1.6's `ei_text` (`EiEvent::TextKeysym` /
`TextUtf8`, `DeviceCapability::Text`), which compiles because `handle_ei`'s match
ends in `_ => {}` and the capability set we bind is written out explicitly. The
handshake now ADVERTISES `ei_text` as a supported interface, so a 1.6 EIS may
offer one; we never bind it and never emit on it.

**Behaviour we inherit, all of it upstream bug fixes:**

- Frames now commit per device. 0.6 held one global pending queue, so an
  `ei_device.frame` from device A committed device B's timestamped events under
  A's timestamp. Inert for us -- we are an EI *sender*, and the events we
  receive are device lifecycle plus `KeyboardModifiers`, none of which are the
  timestamped kind that queue -- but strictly more correct.
- `Device::interface()` now forgets an interface when the server sends its
  `destroyed` event, and `DeviceRemoved` un-registers the device's interfaces
  from the converter's reverse map. Our `slot.interface::<ei::Button>()` and
  friends therefore stop handing back a proxy for a torn-down interface instead
  of emitting into a dead object.
- `Device`, `Seat` and `Object` now hash consistently with their `PartialEq`.
  Both were already `Arc::ptr_eq`; `Hash` used the protocol object id, which is
  a broken pair. We keep devices in a `Vec` and compare with `==`/`!=`, so this
  changes nothing here.
- The wire backend rejects a header length above libei's 1 MiB `max_msglen`
  BEFORE waiting for that many bytes, so a malformed length no longer parks the
  reader until the connection dies.

**The host graph loses the `futures` facade crate.** reis's `tokio` feature
depended on `futures`; 0.7 depends on `futures-util` directly, which pf-inject
already declares. `cargo tree --target all -i futures` now names only relm4 (the
GTK Linux client), and `futures` + `futures-executor` are gone from
punktfunk-host's Linux tree (`futures-task` stays, under futures-util). Lockfile
delta is one package: `reis` 0.6.1 -> 0.7.1 with `futures` -> `futures-util` in
its dep list; the workspace package SET is unchanged, since relm4 and glib still
need those crates for the GTK client.

Verified on .25 (Ubuntu 26.04, `CARGO_BUILD_JOBS=2`), all rc=0:

    cargo build  -p pf-inject --locked
    cargo clippy -p pf-inject --locked -- -D warnings
    cargo clippy -p pf-inject --all-targets --locked -- -D warnings
    cargo test   -p pf-inject --locked    127 passed, 0 failed, 8 ignored
                                          + motion_contract: 7 passed, 0 failed
    cargo check  -p punktfunk-host --locked
    cargo clippy -p punktfunk-host --locked -- -D warnings
    cargo fmt --all --check

Clippy is run BOTH ways because host CI lints without `--all-targets`.
`cargo tree -i ring` stays empty.
2026-08-13 13:58:20 +02:00
enricobuehler d73bdcdcc1 chore(deps): jni 0.21 → 0.22 for the Android JNI bridge
Version currency, but a real API migration rather than a version-number edit — and the
payoff is bigger than "one crate is newer", because jni 0.22 was ALREADY in this .so:
`rustls-platform-verifier` (via quinn-proto, for Android cert verification) depends on it,
so pinning 0.21 here compiled two complete jni copies into one library. Matching the
version collapses them.

Eliminated, measured as the delta in THIRD-PARTY-NOTICES.txt (582 → 571 crates, nothing
added): jni 0.21.1, its `cesu8`, and — because jni 0.21 was the SOLE consumer of
windows-sys 0.45.0, the oldest crate in the tree — that whole windows-rs 0.42 generation:
windows-sys 0.45.0, windows-targets 0.42.2 and its seven per-arch import libraries. Eleven
crates, carried for a `cfg(windows)` dependency of an Android-only bridge.

NOT eliminated, contrary to what the sweep expected — recorded in the manifest so the next
person does not re-derive it. thiserror 1.0 and the jni-sys 0.3/0.4 split both survive,
because jni was never their only source:

  thiserror 1.0.69  ← vendor/ndk 0.9.0 (+ asn1-rs/x509-parser, host side)
  jni-sys 0.3.1     ← vendor/ndk 0.9.0 AND crates.io ndk-sys 0.6

jni's share of both is gone; the remainder is the ndk stack. jni-sys 0.3.1 is itself a
facade crate over 0.4.1, so the split cannot close until ndk and ndk-sys move, and `ndk` is
vendored for a one-line visibility patch — changing its dependency versions would mean
rewriting the vendored snapshot instead of reading it as a diff against upstream. Left
alone deliberately.

The migration itself, across 66 native methods in 10 files:

  * `JNIEnv` split into `EnvUnowned` (the FFI-safe native-method argument) and `Env` (where
    the JNI calls live). The 41 methods that never touched the environment are a type
    rename; the 22 that do now acquire an `Env` inside `EnvUnowned::with_env` and map the
    outcome with an `ErrorPolicy`.
  * `LogErrorAndDefault` everywhere, chosen to PRESERVE behaviour: the old code swallowed
    JNI errors and returned a default, and this logs and returns the same default. The
    throwing policy would have been a behaviour change (new Java exceptions out of methods
    that previously failed quietly), which is not what a currency bump should do.
  * `with_env` also catches panics, which is exactly what the crate's own `jni_guard` did,
    so the guard is folded into it where the two would have nested. It stays on the methods
    that take no `Env`. ONE exception, marked at the call site: `nativeNextHidout` returns
    -1 as its failure sentinel, and every error policy resolves to `T::default()` — which
    for `jint` is 0, a *valid* byte count. That method keeps `jni_guard(-1, …)` outside and
    uses `with_env_no_catch` inside so the sentinel survives the panic path unchanged.
  * `jboolean` is `bool` in jni-sys 0.4, not `u8` — a type change, not a rename, and the
    reason for most of the mechanical diff (`down != 0` → `down`, `return 0` → `false`).
  * `Env::get_string` is deprecated in favour of `JString::try_to_string`, and CI runs
    clippy with `-D warnings`, so the call sites moved rather than being left to warn.
    Likewise `set_/get_*_array_region` → `JPrimitiveArray::set_region`/`get_region`.
  * `Env::get_native_interface()` is now `Env::get_raw()` — the raw pointer handed to
    `ndk::NativeWindow::from_surface`. The `as *mut _` cast next to it was already commented
    as bridging jni-sys skew between `jni` and `ndk`; that skew is now real (0.4 vs 0.3)
    rather than hypothetical, so the comment says so.
  * Return types moved from raw `jni::sys::jstring`/`jdoubleArray`/`jintArray` to
    `JString`/`JDoubleArray`/`JIntArray`, because `resolve()` requires `T: Default` and raw
    pointers have no `Default`. All three are `#[repr(transparent)]` over the same
    `jobject`, so the exported ABI is unchanged and `Default` IS the null reference the old
    code returned explicitly.

Kotlin and Gradle needed NO changes, checked rather than assumed: every affected
`external fun` in NativeBridge.kt already declares `Boolean` / `String?` / `IntArray?` /
`DoubleArray?`, which is what these signatures still present to the JVM, and the Gradle
side only shells out to cargo-ndk without naming a jni version.

Verified on NDK 30.0.14904198, both shipping ABIs, using the environment
clients/android/kit/build.gradle.kts hands cargo-ndk:

  cargo clippy -p punktfunk-client-android --all-targets -- -D warnings   → ok (host)
  cargo test   -p punktfunk-client-android                                → 20 passed
  cargo ndk -t arm64-v8a   --platform 28 clippy … -- -D warnings          → ok
  cargo ndk -t armeabi-v7a --platform 28 clippy … -- -D warnings          → ok
  cargo ndk -t arm64-v8a -t armeabi-v7a --platform 28 build               → both .so LINKED
  scripts/ci/check-android-jni-imports.sh … 28    → 2 ABI(s) clean at the API-28 floor

That last one matters for this change specifically: a cdylib links with dangling undefined
symbols, so the floor check is the only thing that would catch jni 0.22 hard-importing an
NDK entry point above minSdk 28 — the shape of the 0.9.0 `System.loadLibrary` regression.
It is clean.

`cargo tree -i ring` stays empty on host and on aarch64-linux-android.
2026-08-13 13:58:08 +02:00
enricobuehler 93b4c725a6 chore(deps): x11rb 0.13 -> 0.14
Version currency for the three crates that speak core X11: pf-capture's XFixes
cursor source, pf-vdisplay's gamescope splash client, and pf-client-core's
gamescope overlay watcher. Nothing outside the workspace pulls x11rb, so all
three move together and no two versions coexist in the lock.

**The no-libxcb property survives, which is the whole reason those manifests
say `default-features = false`.** 0.14 declares no `default` feature either, so
that flag is still the belt to the braces; `libc` and `as-raw-xcb-connection`
are still optional and still reachable only through `allow-unsafe-code`, and
`dl-libxcb` still requires it too. `cargo tree -e features -i x11rb -p
pf-capture` resolves to exactly `xfixes` -> `render` + `shape` and nothing else,
and neither `as-raw-xcb-connection` nor any other libxcb-linking crate appears
in Cargo.lock. `RustConnection` remains the only connection type, so no host or
client package gains a C dependency.

**One upstream behaviour change does land**, and it is why the `SessionBind` doc
in gamescope.rs moved. 0.14 removed the abstract-unix-socket attempt from
`rust_connection::stream` -- `ConnectAddress::Socket` is now documented as
"Connect to this Unix socket by path" -- so `@/tmp/.X11-unix/X<n>` is no longer
tried ahead of the filesystem path. That doc asserted the ATTACH route's XFixes
cursor reader reached the display over the abstract socket; it now records what
is actually load-bearing. On ATTACH the session belongs to
`gamescope-session-plus`, we arm no bind, its `/tmp` is the real one, and
`punktfunk-host.service` sets no `PrivateTmp` (nor does the NixOS host unit), so
`/tmp/.X11-unix/X<n>` is exactly where `DISPLAY` says it is. The two conditions
that would have needed the abstract fallback still cannot coincide: the bind
only arms for a resolved `punktfunk-gamescope`, whose patch level 2+ makes
`SessionPlan::gamescope_cursor` false and the reader is never spawned. The
splash client is gamescope's own nested child, inside the namespace, and reads
the bound directory directly. If those ever do have to coexist the reader logs
and retries forever and the stream runs without a composited pointer -- the doc
now says so instead of promising a fallback that no longer exists.

The rest of the 0.13.2 -> 0.14.0 delta is inert here: `AtomEnum::CUT_BUFFE_Rn`
was respelled `CUT_BUFFERn` (unused), the optional `raw-window-handle` went
0.5 -> 0.6 and `libloading`'s range widened (both features off), and the MSRV
moved 1.64 -> 1.68 against a 1.96 toolchain.

Verified on .25 (Ubuntu 26.04, `CARGO_BUILD_JOBS=2`), all rc=0:

    cargo build  -p pf-capture -p pf-client-core -p pf-vdisplay --locked
    cargo clippy -p pf-capture -p pf-client-core -p pf-vdisplay --locked -- -D warnings
    cargo clippy -p pf-capture -p pf-client-core -p pf-vdisplay --all-targets --locked -- -D warnings
    cargo test   -p pf-capture  --locked        68 passed, 0 failed
    cargo test   -p pf-vdisplay --locked       210 passed, 0 failed, 3 ignored
    cargo check  -p punktfunk-host --locked
    cargo fmt --all --check

Clippy is run BOTH ways on purpose: host CI lints without `--all-targets`, so a
`#[cfg(test)]`-only import would pass the local run and fail the shipping build.
`cargo tree -i ring` stays empty.
2026-08-13 13:52:54 +02:00
enricobuehler c814340607 build: drop the aarch64 --cfg aes_armv8 / polyval_armv8 flags, measured obsolete
aes 0.9 runtime-detects the ARMv8-Crypto backend on aarch64 via `cpufeatures` and polyval 0.7
picks its armv8 PMULL intrinsics by target_arch, so neither cfg exists any more — passing them
is inert. That retires a real footgun rather than tidying a file: a RUSTFLAGS env var overrides
config rustflags ENTIRELY, so every aarch64 lane that set its own (cargo-ndk does so internally
for every Android arm64-v8a build) silently dropped both and ran SOFTWARE AES on the per-packet
decrypt path.

Measured before deleting, `crypto/open_in_place` (1408-byte MTU shard, AES-128-GCM, single core,
Mac15,14 M3 Ultra, four runs back to back under identical background load):

    aes 0.8 + both cfgs      2.19 GiB/s
    aes 0.8, cfgs stripped     225 MiB/s   ~10x cliff — reproduces the recorded ~240 MiB/s
    aes 0.9 + both cfgs      5.28 GiB/s
    aes 0.9, cfgs stripped   5.28 GiB/s    identical to 4 s.f.

The ChaCha20-Poly1305 series of the same bench was the control and moved 0.07% across the cfg
toggle at both versions, so the toggle demonstrably reached only the AES path. A final run with
the flags actually deleted (not merely RUSTFLAGS-overridden) reproduced 5.29 GiB/s.

.cargo/config.toml is kept as a tombstone carrying that table so the flags are not reintroduced.
The two CI comments that warned about losing these cfgs to a RUSTFLAGS override are updated —
mold in ci/cargo-config-mold.toml is now the only thing such an override can cost.
2026-08-13 13:46:51 +02:00
enricobuehler 1fb081a1f0 chore(deps): move pf-console-ui from skia-safe 0.87 to 0.99
Twelve skia-safe releases (0.88 … 0.99), carrying Skia milestones 140 through
150, every one of them breaking under 0.x semver. Only three of those changes
actually reach this crate — the Vulkan surface/backend-texture path, the
textlayout/paragraph typography and RuntimeEffect all came through untouched:

* m143 (skia-safe 0.91) DELETED SkPath's mutating API. `Path::new()` followed by
  `move_to`/`line_to`/`arc_to`/`close` no longer compiles at all — geometry is
  built through `PathBuilder` now and frozen with `snapshot()`/`detach()`. That
  is the entire error list: 34 E0599s over eight call sites (the hint-bar
  triangles and the PlayStation triangle in `glyphs.rs`, the chevron / space /
  backspace / check icons in `widgets.rs`, the padlock shackle in
  `screens/home.rs`). Each becomes a `PathBuilder` detached at the draw call, so
  the path is still built and thrown away once per draw exactly as before.

* 0.93 deprecated the `gradient_shader` module in favour of `gradient`. Only a
  warning, but the Format/clippy gate runs `-D warnings`, so it is a hard break
  for us. The three gradients — the panel stroke in `theme.rs`, the
  connect-overlay vignette in `shell/overlays.rs`, the host monogram in
  `screens/home.rs` — now build a `gradient::Gradient` from
  `gradient::Colors::new_evenly_spaced` plus `Interpolation::default()`. That
  default (unpremul interpolation, destination colour space, shorter hue) is
  what the old `flags: None` argument mapped to, so the pixels do not move. The
  new API takes `Color4f` directly, which drops the `.to_color()` 8-bit
  round-trip the old signature forced.

* 0.98 deprecated `vk::BackendContext::new` in favour of a builder (upstream
  #1292). `skia_overlay.rs` now calls
  `BackendContext::new_builder(...)` + `build()`, passing `max_api_version:
  None` so Skia keeps deriving its cap from `vkEnumerateInstanceVersion()` —
  bit-for-bit what `new()` passed. `build()` is the unsafe half, so the SAFETY
  proof moved down onto it.

`ash` is untouched and stays on the workspace's exact `=0.38.0+1.3.281` pin:
skia-safe lists ash only as a DEV dependency, so the bump cannot reach it.

The prebuilt-binary assumption still holds — verified from the build log, not
from the release page: skia-bindings printed `DOWNLOAD AND INSTALL SUCCEEDED`
for
`skia-binaries-a25a0fdb7d90429aa2d1-x86_64-unknown-linux-gnu-jpegd-jpege-pdf-textlayout-vulkan`,
so no CI leg compiles Skia from source. The asset name DID change: `jpeg` joined
skia-safe's default feature set between 0.87 and 0.99, so `jpegd-jpege` is now
in the name. We take defaults, so the JPEG codecs came along — which is a fix in
disguise, since `screens/library.rs` hands host poster art straight to
`Image::from_encoded`, and JPEG posters used to fall out as "undecodable". The
Cargo.toml comment now records the verified asset names and the silent-source-
build trap for the next bump.

Verified on 192.168.1.21, x86_64-unknown-linux-gnu, toolchain 1.96.0:
  cargo build  -p pf-console-ui                              exit 0
  cargo clippy -p pf-console-ui --all-targets -- -D warnings  exit 0
  cargo test   -p pf-console-ui                              82 passed, 1 ignored
  cargo fmt --all --check                                    exit 0
2026-08-13 13:40:50 +02:00
enricobuehler 2b13b6353a deps: move the RustCrypto wave to aes 0.9 / aes-gcm 0.11 / sha2 0.11 / hmac 0.13 / cbc 0.2 / chacha20poly1305 0.11
These six share the `crypto-common` and `digest` traits, so they move as ONE change — a
partial bump leaves crates on incompatible trait generations that cannot interoperate.

The point is to delete a footgun, not for version hygiene. `aes` 0.8 only enabled ARMv8
hardware AES on aarch64 behind `--cfg aes_armv8`, and `polyval` 0.6 gated its PMULL GHASH
path behind `--cfg polyval_armv8`. A RUSTFLAGS env var OVERRIDES config rustflags
ENTIRELY, so any aarch64 lane that sets its own (cargo-ndk does this internally for every
Android build) silently dropped both and fell back to software AES on the per-packet
decrypt path. `aes` 0.9 runtime-detects via `cpufeatures` on aarch64 and `polyval` 0.7
selects its armv8 intrinsics backend by target_arch, so neither cfg exists any more.

API changes this generation forces:
  - `AeadInPlace` -> `AeadInOut`; `{encrypt,decrypt}_in_place_detached` ->
    `{encrypt,decrypt}_inout_detached` taking an `InOutBuf`.
  - `generic-array` -> `hybrid-array`: `Array::from_slice` is deprecated in favour of the
    infallible `&[u8; N] -> &Array<u8, UN>` reference cast, or `TryFrom` for runtime slices.
  - `Mac::new_from_slice` moved to `KeyInit::new_from_slice`.
  - `BlockEncrypt`/`BlockDecrypt` -> `BlockCipherEncrypt`/`BlockCipherDecrypt`;
    `BlockEncryptMut` -> `BlockModeEncrypt`; `encrypt_padded_vec_mut` -> `encrypt_padded_vec`.

`rsa` 0.9 is the one crate that cannot come along: it is built on `digest` 0.10 and its
0.10 line is still release-candidate only, which is not something the Moonlight pairing
ceremony should ride. Its `sha2` feature re-exports the digest its own traits speak, so the
three sites where a digest appears as an `rsa` TYPE PARAMETER (cert.rs, pairing.rs, tls.rs)
now take `rsa::sha2::Sha256` explicitly; everything else in the crate is on sha2 0.11.

The GameStream wire formats are untouched — AES-128-ECB no-padding, the CBC audio path, and
the GCM control-stream seal all keep their exact byte behaviour; only the type plumbing moved.
2026-08-13 13:39:09 +02:00
enricobuehler dfde5080cc chore(deps): windows-service 0.7 -> 0.8 (host + tray), removing the last windows-sys 0.52 in the tree
Version currency for the SCM plumbing behind `punktfunk-host service` (the
dispatcher, control handler and ServiceManager install) and the tray's
unprivileged QUERY_STATUS probe. No code changed in either crate.

The payoff is dependency unification, not the API. `windows-service 0.7` was the
ONLY crate in the workspace still pulling `windows-sys 0.52`, so it alone kept a
fourth windows-sys major compiling. It resolves to 0.8.1, which moves to
`windows-sys 0.61` — a version the tree already builds — and the duplicate
disappears:

  cargo tree -d --target x86_64-pc-windows-msvc | grep '^windows-sys v'
    before: 0.45.0, 0.52.0, 0.59.0, 0.61.2
    after:  0.45.0,         0.59.0, 0.61.2

Note 0.8.0 would NOT have been enough — it lands on windows-sys 0.59. 0.8.1 is
the release that reaches 0.61, hence the `"0.8"` caret plus the comment pinning
the reasoning to the manifest.

The 0.7 -> 0.8 delta is tiny and touches nothing this tree calls: `ServiceAccess`
gains READ_CONTROL / WRITE_DAC / WRITE_OWNER (additive), and `Service::raw_handle`
changes return type from `Security::SC_HANDLE` to `Services::SC_HANDLE` as a
consequence of the windows-sys bump — we never call it. `ScHandle` is crate-private
upstream. No enum gained variants, and the service control handler's match already
ends in a `_ =>` arm, so the `#[non_exhaustive]` types stay safe.

What remains duplicated (deliberately out of scope here): windows-sys 0.45 via
`jni`, and 0.59 via `punktfunk-core` + `if-addrs`.
2026-08-13 13:35:30 +02:00
enricobuehler 00245499e0 chore(deps): android_logger 0.14 → 0.15
Version currency for the Android client's only `log` backend. No code change: 0.15 is
almost entirely an internal refactor (the single `lib.rs` split into `config`/`arrays`/
`id`/`platform_log_writer`/`tests`), and the surface this crate uses — `init_once`,
`Config::default`, `with_max_level`, `with_tag` — is untouched. The lockfile delta is the
version and checksum alone: no dependency was added, removed or re-resolved, and the
third-party crate count stays at 582.

The one thing 0.15 adds that we must NOT take is recorded next to the dependency: the new
opt-in `android-api-30` feature filters levels through `__android_log_is_loggable_len` so
logcat's `setprop log.tag.*` overrides are honoured, but it HARD-LINKS that API-30 symbol.
Against our minSdk-28 floor that is a `System.loadLibrary` failure on Android 9/10 — the
identical shape of the ndk 0.9.0 `AMediaCodec_setOnFrameRenderedCallback` break the manifest
already warns about a few lines further down. Default features keep it off; the comment
explains why so nobody "completes" the upgrade by enabling it.

Verified with cargo-ndk (NDK 30.0.14904198), which is the only way to exercise this crate at
all — `android_logger` sits behind `cfg(target_os = "android")`, so the host workspace build
never compiles it:

  cargo ndk -t arm64-v8a   check -p punktfunk-client-android   → ok
  cargo ndk -t armeabi-v7a check -p punktfunk-client-android   → ok

Checked, not built: these are `cargo check` runs, not a linked `.so` and not an APK.
2026-08-13 13:34:19 +02:00
enricobuehler 94e3629905 chore(deps): wasapi 0.23 -> 0.24 (Windows audio), and correct the five comments that still describe its fixed GetDevice bug
Version currency for the crate behind the host's WASAPI loopback capture and
virtual mic, and the Windows client's render/capture path. No behavior change.

The 0.23 -> 0.24 API delta is almost entirely additive (device-change
notification callbacks, `AudioMeterInformation`, `HardwareSupport`, `DeviceState:
Clone + Copy`). The single removal is `AudioClient::get_bufferframecount`,
deprecated since 0.17 in favour of `get_buffer_size` — this tree never called it,
so no call site moved.

0.24 also fixes upstream the dangling-`PCWSTR` bug this tree routes around in
five places: `DeviceEnumerator::get_device` used to build its argument as
`PCWSTR::from_raw(HSTRING::from(id).as_ptr())`, dropping the `HSTRING` at the end
of that statement so `GetDevice` read freed memory. Those five comments asserted
the bug in the PRESENT tense and are now wrong, so they are corrected here rather
than left to mislead. The workarounds themselves STAY: `open_wasapi_device` is
still the one resolution path whose errors name the endpoint id and whose
`IMMDevice` `probe_activation` needs, and `device_by_id` additionally filters to
ACTIVE endpoints (`EnumAudioEndpoints(dir, DEVICE_STATE_ACTIVE)`), which the
crate's `get_device` does not. Removing them is a behavior change, not currency.

⚠ This does NOT collapse the duplicate windows-rs. wasapi 0.24 still depends on
`windows ^0.62` / `windows-core ^0.62` exactly as 0.23 did, so the crates.io
`windows 0.62.2` still sits alongside the pinned git copy that `clients/windows`
uses. That duplicate costs build time and binary size, not correctness, and the
blanket `[patch.crates-io] windows` that would collapse it stays ruled out — the
pinned rev uses header-named features while a dozen manifests still use the old
`Win32_*` namespace features.
2026-08-13 13:30:15 +02:00
enricobuehler 2d037aa443 test(gamestream): cover the TLS 1.2 Moonlight handshake and pin the post-quantum group
PR #192 moved the rustls backend to aws-lc-rs and merged before CI reported.
Two of the things it changed here shipped with no assertion behind them.

`generate()` mints the RSA-2048 host identity and runs ONLY when no cert
exists, so no upgraded box ever re-executes it — a fresh install is the
only thing that would have found a regression. It was reached by other
tests via `ServerIdentity::ephemeral()`, but purely as a fixture: nothing
checked that what came back was still RSA-2048, which is the one property
Moonlight requires. The handshake behaviour had no coverage at all, and
the GameStream TLS path is the single place a legacy peer meets the new
backend.

Three tests:

  - generate_mints_a_loadable_rsa2048_identity — the fresh-install path,
    asserting the cert signature is 256 bytes (RSA-2048) rather than
    depending on an `rsa` accessor that could change shape.
  - moonlight_shaped_peer_completes_a_tls12_mutual_handshake — a peer that
    pins out of band, as Moonlight does, presenting an RSA-2048 client cert
    against the real `tls::server_config`.
  - tls13_negotiates_the_post_quantum_group — pins X25519MLKEM768 by name,
    so a provider or feature regression that silently drops ML-KEM back to
    a classical curve fails here instead of in the field.

Also corrects the comment on `generate()`. It opened by asserting the
workspace is ring-only because aws-lc-sys breaks Windows CI, and explained
that rcgen's *ring* backend is what loads the RSA key. Both are now false:
rcgen is on aws_lc_rs and loads and self-signs the key fine — verified, not
assumed. rcgen still cannot GENERATE an RSA key on either backend, which is
the part of the comment that remains true and load-bearing.

Verified on Linux (Ubuntu 26.04, x86_64): 3/3 pass, clippy clean both with
and without --all-targets (host CI lints without it), and the native-only
`--no-default-features --features pyrowave` build still checks clean — the
whole module is gamestream-gated, so it compiles out there.
2026-08-13 13:15:02 +02:00
enricobuehler c95db8eebc Merge pull request 'Move TLS to aws-lc-rs with post-quantum key exchange, drop ring via ureq 3, and fix the dependency defects behind it' (#192) from worktree-aws-lc-rs-migration into main
apple / swift (push) Successful in 2m18s
audit / bun-audit (plugin-kit) (push) Successful in 20s
audit / bun-audit (web) (push) Successful in 14s
audit / docs-site-audit (push) Successful in 16s
audit / pnpm-audit (push) Successful in 12s
audit / cargo-audit (push) Successful in 2m44s
audit / bun-audit (sdk) (push) Successful in 1m33s
audit / license-gate (push) Successful in 5m13s
ci / rust (push) Failing after 1m48s
ci / web (push) Successful in 1m45s
audit / miri (push) Successful in 10m5s
ci / docs-site (push) Successful in 1m29s
audit / c-abi-asan (push) Successful in 10m24s
ci / bun-nix (push) Successful in 1m6s
apple / distribute (push) Successful in 14m0s
deb / build-publish-gamescope (push) Failing after 56s
windows-drivers / driver-build (push) Successful in 1m59s
android / android (push) Successful in 19m12s
arch / build-publish (push) Successful in 19m57s
windows-drivers / probe-and-proto (push) Successful in 25s
ci / rust-arm64 (push) Successful in 13m8s
apple / screenshots (push) Successful in 7m34s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 2m14s
deb / build-publish (push) Successful in 10m28s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 10s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7m38s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 2m52s
deb / build-publish-client-arm64 (push) Successful in 12m9s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7m56s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4m49s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m29s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4m27s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3m12s
deb / build-publish-host (push) Successful in 19m29s
docker / deploy-docs (push) Failing after 1m58s
docker / builders-arm64cross (push) Successful in 6m28s
deb / smoke-install (push) Failing after 4m16s
flatpak / build-publish (push) Successful in 12m18s
nix / flake (push) Failing after 20m59s
windows-host / package (push) Successful in 19m52s
windows-host / winget-source (push) Skipped
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 7m36s
windows-host / canary-manifest (push) Successful in 27s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 9m53s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 22m36s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 2m50s
Reviewed-on: #192
2026-08-13 10:43:33 +00:00
enricobuehler 6202543b21 Merge pull request 'ci: cache the C/C++ half, link with mold, fix the debug/release cache collision, consolidate the Apple and Windows-client workflows' (#191) from worktree-ci-optimization into main
android / android (push) Canceled after 34s
apple / swift (push) Canceled after 1m49s
apple / distribute (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 55s
ci / web (push) Canceled after 49s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 42s
deb / build-publish (push) Canceled after 15s
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 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
flatpak / build-publish (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-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
windows-drivers / probe-and-proto (push) Canceled after 0s
windows-drivers / driver-build (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
decky / build-publish (push) Successful in 1m23s
Reviewed-on: #191
2026-08-13 10:41:54 +00:00
enricobuehler d0a7b262d2 chore(deps): regenerate notices + make audit.yml's header match what it now scans
windows-drivers / driver-build (pull_request) Successful in 1m57s
windows-drivers / probe-and-proto (pull_request) Successful in 35s
apple / swift (pull_request) Successful in 2m29s
apple / screenshots (pull_request) Skipped
ci / bun-nix (pull_request) Successful in 52s
ci / rust-arm64 (pull_request) Failing after 3m22s
ci / web (pull_request) Successful in 3m47s
ci / docs-site (pull_request) Successful in 5m10s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 7m21s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 24s
ci / rust (pull_request) Failing after 11m30s
nix / flake (pull_request) Failing after 30m26s
android / android (pull_request) Successful in 13m2s
THIRD-PARTY-NOTICES regenerated after the dependency changes (582 crates).

audit.yml's header claimed to cover "EVERY dependency tree the project ships"; it now
actually does, so the note spells out that each Rust lockfile needs its own `--file` —
a bare `cargo audit` reads only the root one, which is how the drivers lock stayed
unscanned while already sitting in this job's `paths:` filter. Also corrected "BOTH
Rust workspaces" for the licence gate, which covers the host + driver workspaces.

Both cargo-about legs re-run after the dependency removals: RC=0.
2026-08-13 12:41:47 +02:00
enricobuehler 51a005dd43 fix(deps): close the audit gaps, drop unused declarations, declare what is used
Acting on the 2026-08-13 dependency sweep. Every claim below was re-verified against
the tree before acting on it (greps carry a positive control; the advisories were
re-checked with cargo audit 0.22.2).

SECURITY
- event-listener 5.4.1 -> 5.4.2 (RUSTSEC-2026-0221, unsound Send/Sync on StackSlot;
  reaches the tray via zbus and the host via ashpd). This sat unnoticed because
  `cargo audit` reports unsoundness as a WARNING and the job fails only on
  vulnerabilities — audit.toml now says so out loud.
- spin 0.9.8 -> 0.9.9. 0.9.8 is YANKED and was genuinely compiled (flume via mdns-sd
  and relm4, plus lazy_static).
- wayland-scanner 0.31.10 -> 0.31.11, which moves quick-xml 0.39 -> 0.41. That is the
  exact trigger audit.toml documented for RUSTSEC-2026-0194/0195, so both ignores are
  deleted rather than left as permanent exceptions. Only RUSTSEC-2023-0071 (rsa
  Marvin, still unfixed upstream) remains.
- Corrected audit.toml's claim that `paste` arrives "via utoipa-axum": rav1d pulls it
  too, so every client has it through the decode path and dropping utoipa-axum would
  not have cleared it.

TWO CI GATES THAT SCANNED NOTHING
- `cargo audit` only ever reads the ROOT Cargo.lock. The drivers lock was already in
  this job's `paths:` filter, so edits to it triggered a run that then ignored them.
  All four secondary workspaces now get an explicit `--file` (verified: clean, bar the
  known `paste` warning in drivers).
- packaging/windows/pf-vkhdr-layer had NO lockfile at all while shipping as a DLL in
  the host installer, so every build resolved fresh and neither cargo-audit nor
  cargo-about ever saw it. Lockfile generated and committed, and added to `paths:`.

UNUSED / DUPLICATE DECLARATIONS
- punktfunk-host: removed 13 dependencies it never references — the Wayland stack
  (client, protocols{,-wlr,-misc}, scanner, backend), xkbcommon, reis, khronos-egl,
  ash, usbip-sim, parking_lot, bytemuck. The code moved to pf-inject and pf-zerocopy
  in the subsystem extraction and those crates declare them; only the manifest entries
  and their now-false comments stayed. Also dropped four redundant re-declarations
  (tokio/serde_json/futures-util in the Linux block, tower in dev-deps).
- Removed genuinely unused: bytes (punktfunk-core), anyhow (pf-win-display),
  tracing (clients/cli), anyhow (clients/session), serde (clients/windows).
- Removed the high-level `wdk` crate from all five driver crates and the drivers
  workspace: none of them ever referenced `wdk::` (62 `wdk_sys::` uses; pf-umdf-util
  is a full WDF crate that never declared it). `tracing`/`tracing-subscriber` remain
  in that lock afterwards but ONLY as wdk-sys build-dependencies, not in the DLLs.
- pf-win-display took punktfunk-core with `quic` for one type (`Mode`) that lives in
  the ungated `config` module; now `default-features = false`, which keeps
  quinn/tokio/rcgen/opus out of a leaf crate's declared closure.
- pf-encode declared the windows-rs feature `Wdk_Graphics_Direct3D` for a call that
  lives in pf-frame and is resolved via GetProcAddress on gdi32.

LATENT BREAKAGE (compiled only by feature unification)
- pf-inject uses `tokio::select!` without declaring `macros` (borrowed from
  punktfunk-core's quic feature); pf-capture uses `tokio::sync::oneshot` without
  declaring `sync` (borrowed from ashpd->zbus); pf-client-core uses the `minwindef`
  and `winnt` windows-rs headers without declaring them (borrowed from
  clients/windows). Each now declares what it uses, so an unrelated crate changing its
  features cannot break them.
- pf-console-ui took pf-client-core WITHOUT `default-features = false`, unlike every
  other consumer. That default is `pyrowave`, which compiles the vendored PyroWave C++
  — "fatal on Windows ARM64". Only safe today because the ARM64 leg passes
  --no-default-features (which also drops `ui`).

CORRECTED A FALSE INVARIANT
- clients/windows claimed "the workspace builds ONE windows-rs". It does not: wasapi
  pulls the crates.io windows 0.62.2 beside the git-rev copy. The invariant that DOES
  hold is narrower (reactor and that crate share one rev, which is what makes the
  IDXGISwapChain1 hand-off type-check). Comment rewritten, with a warning against
  "fixing" it via a blanket [patch.crates-io] — this rev uses header-named features
  while a dozen other manifests use the old Win32_* namespace ones.

Plus the safe in-compat `cargo update` sweep (no manifest edits).

Verified on macOS: punktfunk-core 385, pf-update-check 32, c_abi 1 (with
LIBRARY_PATH=/opt/homebrew/opt/opus/lib), cargo audit clean bar the two known
unmaintained warnings. Linux and Windows legs follow.
2026-08-13 12:41:47 +02:00
enricobuehler b84d37b5a0 test(host): make two socket stubs survive ureq 3 / Windows
Both failures found running the store + plugin-launch tests on the Windows runner
after the ureq 3 port. Neither is a production defect — the request/response round
trip and the 304 semantics both hold — but both tests were resting on assumptions
that ureq 2 happened to tolerate.

catalog::ureq_returns_304_as_ok: the stub answered without ever reading the request.
Closing a socket that still holds unread received data makes Windows send an RST
rather than a FIN, which discards the response already written, so the client saw a
transport error (os error 10053) instead of the 304 the test exists to pin. The stub
now drains the request first. The pinned behaviour is unchanged and still true:
ureq 3 turns only `is_client_error() || is_server_error()` into Err, so 304 arrives
as Ok exactly as before.

plugin_launch::asks_the_registered_plugin_and_takes_its_answer: hardcoded a cwd of
`/opt/emu`, which has no drive letter and is therefore NOT `Path::is_absolute` on
Windows, so `validate_reply` refused the recipe. This test could never have passed
on Windows, with either ureq version — its sibling
`a_working_directory_must_be_absolute` already had the `cfg!(windows)` split and this
one was simply missed. Confirmed by diagnostic before touching it: the body came back
over ureq 3 byte-perfect, so everything up to validation was working.
2026-08-13 12:41:47 +02:00
enricobuehler 9ec8350fc3 chore(licenses): retire ring's licence exception now that ring is gone
`about.toml` carried `OpenSSL` in the global accepted list and a `[ring]` per-crate
acceptance, both there solely because ring's licence is an AND that includes the
OpenSSL terms. The ureq 2 -> 3 upgrade removed ring from every target we build, and
aws-lc-sys 0.44's SPDX (ISC AND (Apache-2.0 OR ISC) AND Apache-2.0 AND MIT AND
BSD-3-Clause AND ... MIT-0) carries no OpenSSL clause, so neither entry has anything
left to permit.

Note about.toml sets no `targets`, so cargo-about still walks quinn-proto's wasm-only
ring edge — removing the exception is safe not because ring is invisible but because
ring 0.17.14 declares `Apache-2.0 AND ISC`, and both are globally accepted already.

Verified by running the gate itself, both legs of what audit.yml runs:
  cargo about generate about.hbs --fail                                    -> 0
  cargo about generate -m packaging/windows/drivers/Cargo.toml -c ...      -> 0
and proved non-vacuous with a negative control: dropping "ISC" from the accepted
list makes the first leg exit 1.

THIRD-PARTY-NOTICES regenerated: 601 -> 580 crates (ureq 3 pulls a smaller tree than
ureq 2 + ring), now listing ureq 3.4.0.
2026-08-13 12:41:47 +02:00
enricobuehler 3ccfd01699 feat(deps): upgrade ureq 2 -> 3, removing ring from the tree entirely
The aws-lc-rs move left `ring` compiled in for one reason: ureq 2 names
`features = ["ring", ...]` inside its own `[dependencies.rustls]` block, and cargo
features are additive, so no dependent could switch it off. ureq 3 declares rustls
with `default-features = false` and selects no backend, which finally makes the
choice ours.

`cargo tree -i ring` is now empty for macOS, Windows and Linux. The one remaining
hit under `--target all` is quinn-proto's wasm-only dependency
(`cfg(all(target_family = "wasm", target_os = "unknown"))`), a target we never build.

⚠ The dependency must be spelled `features = ["rustls-no-provider",
"rustls-webpki-roots"]`. ureq 3's convenience `rustls` feature expands to include
`_ring` — the same shape of trap as rustls's own `features = ["ring"]`, and the
reason punktfunk-webos still carries both backends today.

Ported 9 files. The two pinning call sites (the desktop client's library fetch and
the tray's status poll) needed a custom ureq Connector, because ureq 3's `TlsConfig`
exposes roots/client-cert/off-switch but no hook for a custom ServerCertVerifier.
That glue lives once in punktfunk-core behind a new off-by-default `ureq-tls`
feature rather than being hand-rolled twice on a trust boundary; the Apple/Android
cdylib embedders do not enable it and pull no HTTP stack. The connector is modelled
on ureq's own crate-private RustlsConnector and is transport glue only.

Two behaviour changes worth noting, both improvements:
- Body caps are enforced by the reader, so an over-cap response is now an Err rather
  than ureq 2's silent truncation — which used to surface as a signature failure
  pointing at the wrong thing.
- A pin mismatch matches ureq 3's typed `Error::Rustls(..)` instead of sniffing a
  substring out of a transport message, which could also fire on unrelated cert
  errors.
Conditional requests are unchanged: 304 still arrives as Ok (only 4xx/5xx are Err),
which the existing `ureq_returns_304_as_ok` socket test still pins.

Also removed four now-dead `std::io::Read` imports. One of them (plugin_launch) is
still needed by its test module, so it moved there rather than being deleted: host
CI lints without `--all-targets`, so a top-level import used only under cfg(test)
fails the shipping build.

Verified on macOS: punktfunk-core (quic + ureq-tls) checks clean, pf-update-check
32/32, cargo fmt clean.
2026-08-13 12:41:47 +02:00
enricobuehler 79d755cd98 feat(crypto): move the rustls backend from ring to aws-lc-rs, with PQ key exchange
The workspace pinned `ring` everywhere because aws-lc-sys 0.41.0 failed to C-compile
on the Windows CI runner. Re-tested on that runner (.133) with aws-lc-sys 0.44.0: the
`params.c` cl.exe failure does not reproduce under MSVC 14.44, and rustls's `aws_lc_rs`
feature turns on `aws-lc-rs/prebuilt-nasm`, so no NASM is needed on the box either.

That unblocks post-quantum TLS: `prefer-post-quantum` offers X25519MLKEM768 first on
every TLS 1.3 handshake (mgmt API, native control plane, QUIC), which ring cannot do —
it has no ML-KEM. Classical curves stay in the list, so older clients still connect.

rustls, quinn, rcgen and tokio-rustls each select a backend independently, so all four
had to move together; a single dissenter pulls a second crypto stack in via feature
unification. The direct `ring` users (ed25519 in pf-update-check, SHA-256 in the Windows
updater) moved to aws-lc-rs, whose API is ring-compatible.

`ring` does NOT leave the tree: ureq 2 names `features = ["ring"]` in its own rustls
dependency line and cargo features are additive, so no dependent can switch it off. Two
backends compiled in means rustls refuses to infer one, and anything built via
`ClientConfig::builder()` panics instead of picking — which is what ureq's default agent
does on its first HTTPS request. `tls::install_default_provider()` makes the choice
explicit; it runs at each binary's entry point and defensively in pf-client-core, which
several binaries link. Dropping ring entirely needs the ureq 2 -> 3 upgrade (36 call
sites), deliberately left out of this change.

Verified on macOS: pf-update-check 32, punktfunk-core 385, c_abi 1 (the last with
LIBRARY_PATH=/opt/homebrew/opt/opus/lib) — aws-lc-sys links into the C ABI harness, so
the Swift/Kotlin embedders keep working. cargo fmt --all --check clean.
2026-08-13 12:41:47 +02:00
enricobuehler 5fbf04f56d Ship punktfunk-gamescope on apt, support Debian 13, and state the real host floor (#190)
ci / docs-site (push) Successful in 1m19s
windows-host / package (push) Failing after 55s
ci / rust-arm64 (push) Successful in 1m40s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
ci / bun-nix (push) Successful in 20s
apple / swift (push) Successful in 1m39s
deb / build-publish-gamescope (push) Failing after 2s
android / android (push) Successful in 6m22s
deb / build-publish-host (push) Successful in 4m38s
deb / build-publish (push) Successful in 5m31s
ci / web (push) Successful in 7m54s
apple / screenshots (push) Successful in 5m57s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 3m30s
deb / build-publish-client-arm64 (push) Successful in 8m26s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 6m4s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 1m45s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 5m41s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m27s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 3m19s
decky / build-publish (push) Successful in 51s
arch / build-publish (push) Successful in 10m45s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10m24s
ci / rust (push) Canceled after 6m35s
deb / smoke-install (push) Canceled after 2m50s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 2m34s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 1m2s
docker / builders-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 1m33s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 6m22s
2026-08-13 10:21:52 +00:00
enricobuehler 85980b425e docs: state the real host floor — Ubuntu 26.04+ / Debian 13+ — and name Debian everywhere
ci / docs-site (pull_request) Successful in 1m17s
ci / rust-arm64 (pull_request) Successful in 1m29s
ci / bun-nix (pull_request) Successful in 1m33s
apple / swift (pull_request) Successful in 1m44s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 3m56s
android / android (pull_request) Successful in 9m55s
ci / rust (pull_request) Successful in 15m59s
The floor that matters is the DESKTOP, not the package: 24.04 installs
punktfunk-host and then has no compositor over the version floors and no
gamescope, which reads as a bug rather than an unsupported base. The
requirements page now leads with a per-release table separating 'package
installs' from 'can actually host', and install.md carries the same
caveat next to the apt row.

Debian was already a supported target after the previous commits but was
still invisible at the entry points — README's install table, the docs
index cards, and the 'what you need' list all said Ubuntu only. All three
now name Debian and carry the version floor.
2026-08-13 12:20:33 +02:00
enricobuehler 107fa3472d docs: Linux Mint 22.x cannot host, and 24.04 gives the host no compositor
Measured on a real linuxmintd/mint22-amd64 image and on Ubuntu 24.04. The
package installs on both, which is exactly what makes this easy to miss —
nothing on the box can then produce a stream:

  * Cinnamon cannot host a virtual display (Muffin has no RecordVirtual).
  * gamescope is absent from 24.04 and cannot be built for it: the tree needs
    wayland >= 1.23.1 (has 1.22.0), libinput >= 1.26 (1.25), libavif >= 1.2.1
    (1.0.4), pixman >= 0.44 (0.42), plus libdisplay-info2 and libxcb-errors0,
    neither of which 24.04 packages at all.
  * Switching desktop does not rescue it — 24.04 has KWin 5.27 (floor 6.5.6)
    and GNOME Shell 46 (floor 48). Only sway 1.9 is even a candidate.

So the gamescope route documented for Cinnamon holds for LMDE 7 (Debian 13,
verified end to end) but NOT for Linux Mint 22.x — which is every mainstream
Mint until Mint 23 lands on a 26.04 base in December 2026. Both the Debian and
Ubuntu pages now say so, and the Debian page carries a per-edition table.

Also states what Debian 13 itself can drive: GNOME 48.7 and sway 1.10 are above
the floors; its KWin 6.3.6 is below.
2026-08-13 12:12:52 +02:00
enricobuehler 0bfc7fe913 ci: fold release.yml into apple.yml and the two Windows client workflows into one
apple / swift (pull_request) Successful in 1m43s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
windows-drivers / probe-and-proto (pull_request) Successful in 30s
windows-drivers / driver-build (pull_request) Successful in 1m49s
ci / docs-site (pull_request) Successful in 1m21s
ci / bun-nix (pull_request) Successful in 31s
ci / web (pull_request) Successful in 3m50s
ci / rust-arm64 (pull_request) Successful in 8m32s
android / android (pull_request) Successful in 7m22s
ci / rust (pull_request) Successful in 14m7s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m6s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 8m7s
Two merges, both of which exist to express an ordering Gitea cannot express across
files, and both of which delete a duplicated build.

release.yml -> apple.yml (as the `distribute` job)
  The name described neither what it did (Apple only — every other platform's release
  is its own packaging workflow attaching to the same Gitea release on a v* tag, with
  announce.yml as the manual "go") nor anything a reader would guess. The name was the
  smaller problem. Gitea has no cross-workflow `needs`, so nothing sequenced it against
  apple.yml's tests: a canary main push uploaded iOS, macOS and tvOS builds to
  TestFlight even when `swift test` had just failed on that same commit. It is now
  `needs: swift`, which is only expressible in one file.

  The two files' paths: filters had also drifted — apple.yml watched crates/**,
  release.yml watched crates/punktfunk-core/**. The merged filter takes the NARROW one,
  because that is the correct one: everything on this runner is built from
  punktfunk-core via build-xcframework.sh, and punktfunk-core's only path dependency is
  its own vendored fec-rs. That is checkable in one command, and the header says so, and
  says to widen it if that ever stops being true. Net effect on the shared mac mini:
  pushes that touch host-side crates no longer build or upload anything Apple.

windows.yml + windows-msix.yml -> windows-client.yml
  The pair built the same three crates FOUR times per client push on ONE runner: debug
  x64 + arm64 for lint/test, release x64 + arm64 for packaging. windows-host.yml already
  records why a second (debug) dep tree on this machine is a liability rather than a
  cost — it re-runs openh264-sys2's vendored C++ through cc-rs's cl.exe fan-out and tips
  the runner into C1069, which is disk exhaustion wearing a compiler error's clothes. So
  there is one release build per arch now and clippy/fmt/test run against it, exactly as
  windows-host.yml does. The paths list went from three copies to one; PRs get the
  build/lint/test signal and stop before packaging.

The rename is safe, and this is worth recording because the GitHub instinct is wrong
here: `github.run_number` is REPO-WIDE in Gitea, not per-workflow — consecutive runs of
DIFFERENT workflows get consecutive numbers (verified against the API: android 13226,
apple 13227, arch 13228, ci 13229, deb 13230). The canary MSIX version <minor>.<run>.0
and Apple's CURRENT_PROJECT_VERSION therefore keep climbing across a rename. On GitHub
the same rename would reset both to 1, sorting every new canary below the published ones
and getting the TestFlight uploads rejected outright.

25 workflows, down from 27, and every `name:` now matches its filename. Cross-references
in windows-host.yml, windows-drivers.yml, android.yml, flatpak.yml, sbom.yml, the
provisioning scripts, gitea-release.sh and clients/windows/packaging/README.md updated.
2026-08-13 12:10:36 +02:00
enricobuehler 3f7fbf1061 ci: build the web console once per push instead of once per packaging job
The Nitro console bundle is a pure function of web/ and sdk/, and it was being built
six times on every push: ci.yml, deb, both RPM legs (f43 + f44), arch, and the docker
app image, at roughly 2.5 min each. windows-host.yml has cached it on exactly this
shape for a while — this extends the same arrangement to the Linux packaging legs,
sharing one key family so whichever job builds it first warms the others.

The bun version is part of the key. Each builder image runs the bun.sh installer at
image-build time, so rust-ci, fedora-rpm and arch-ci can drift apart; keying on it
means they share while they agree and simply stop sharing when they do not, rather
than one image's bun silently producing the bundle another image ships.

Each packaging path needed a different hand-off:

  * deb  — build-web-deb.sh already builds only if web/.output is missing, so the
           restore alone is enough; the workflow's build+smoke step is now gated on
           the miss.
  * arch — makepkg builds with PF_SRCDIR pointing at the workspace, so a restored
           bundle is already where it needs to be. PKGBUILD gains the same
           build-if-missing guard the deb script has.
  * rpm  — neither direction works by default. build-rpm.sh packages a `git archive`
           tarball and web/.output is gitignored, so a bundle in the workspace is
           invisible to rpmbuild; and the spec's own build lands in rpmbuild's
           %{_topdir}, which build-rpm.sh mktemps and removes on EXIT, so a console
           built there is gone before the cache's post step and the cache would never
           populate — every run a miss that quietly rebuilt. So the workflow builds it,
           and hands it over by absolute path through a new optional `pf_prebuilt_web`
           macro. Undefined (plain rpmbuild, COPR) takes the original build path.

Every path asserts the bundle exists and carries the Bun.serve marker, on cache hits
too. A cache is one more place a wrong artifact can come from, and the packaging
scripts' build-if-missing behaviour — correct for a local build — would otherwise turn
a broken restore into either a silent rebuild or, with the build step skipped, a
package with no console in it. That is not hypothetical: windows-host.yml shipped
0.22.1 and 0.22.2 with no console because an unset path variable was handled by a
single Write-Host, which is why its equivalent step throws.
2026-08-13 12:01:09 +02:00
enricobuehler f1dc6c9f94 ci: cache the C/C++ half, link with mold, and split the debug/release target caches
Three independent reasons Rust CI stayed slow despite sccache, fixed together because
they share the same measurement.

1. sccache only ever covered RUSTC. Every C/C++ dependency in the tree — aws-lc-sys,
   openh264-sys2's vendored C++, the CMake-built libopus behind audiopus_sys — was
   compiled from scratch on every job of every workflow. CMAKE_{C,CXX}_COMPILER_LAUNCHER
   plus CC_/CXX_x86_64_unknown_linux_gnu route both build-script styles (cc-rs and
   cmake-rs) through the same shared cache.

   The CC_* vars are JOB-scoped in ci.yml and deb.yml, never workflow-scoped: the
   arm64 cross image sets its own CC_x86_64_unknown_linux_gnu=pf-host-cc, the wrapper
   that keeps ffmpeg-sys-next's host probe off the arm64 include dirs. Overwriting it
   would surface as a header mismatch rather than as a CI config error.

2. Linking is cacheable by nothing, and these jobs relink the host, client, session,
   cli, worker and tray on every run — twice per push for rpm (f43 + f44). The four
   Linux builder images now install mold and carry a $CARGO_HOME/config.toml that uses
   it for x86_64. aarch64 is deliberately left alone (cross driver, already-fast legs).
   Each image asserts `mold --version` in its build, so an image can never ship the
   flag without the linker: docker.yml goes red and :latest stays on the last good one.

3. THE EXPENSIVE ONE. ci.yml (debug) and deb.yml (release) named a byte-identical
   target-cache key, under a comment claiming the release build reused ci.yml's
   artifacts. It never could. actions/cache is first-saver-wins on an exact key and
   ci.yml is the faster job, so the shared key always held a debug-only target/ — and,
   worse, deb.yml could then never save its own, because the key was taken. Every
   canary .deb has been a from-scratch release build for as long as both keys existed.
   Same collision on the arm64 pair, and a third participant in
   linux-client-screenshots.yml. Split into -debug-/-release- key families; that job
   reads deb's tree via restore-keys but keeps its own exact key so it can never win
   the save race and replace a full tree with its single-crate one.

Also: one scripts/ci/ensure-sccache.sh replaces ten copy-pasted bootstrap blocks that
had already drifted into two dialects (GNU tar --wildcards vs bsdtar), every Rust job
now ends with --show-stats so a cache regression is visible instead of just "CI got
slower", and deb.yml's web install joins every other CI install on --ignore-scripts.

No behaviour change to any artifact: same compilers, same flags, same outputs.
2026-08-13 11:57:13 +02:00
enricobuehler 5d8682d7b7 feat(vdisplay): explain a cinnamon/muffin compositor pin instead of listing backends
PUNKTFUNK_COMPOSITOR=cinnamon is the first thing a Mint or LMDE user
reaches for, and the bare list of accepted values invites the
next-closest guess — 'mutter', since Muffin is a Mutter fork — which
starts a session that fails deep inside an org.gnome.Mutter.ScreenCast
call Muffin does not serve. No value of the variable can work, so say
that and name headless gamescope, which needs no desktop compositor.
2026-08-13 11:50:05 +02:00
enricobuehler 030bc8a1c2 docs(debian): use deb822 sources for non-free, not apt-add-repository
software-properties-common is not available in Debian 13, so the
apt-add-repository line could not have worked. Debian 13 keeps its
sources in deb822 format; edit Components there instead (verified in a
trixie container — the NVIDIA driver it then offers is 550, above our
535 floor).
2026-08-13 11:47:20 +02:00
enricobuehler 346385bad8 fix(deb): ship punktfunk-gamescope on apt at last, and support Debian 13
`punktfunk-gamescope` had never been published to the apt registry — not in any
release. It was built inside the host job's Ubuntu 24.04 image, where it cannot
build: our pin vendors wlroots 0.19.3, which floors `wayland-server` at 1.23.1,
and noble ships 1.22.0 (it also lacks libxcb-errors-dev and has only
libdisplay-info 0.1.1). Every rung of that path was a `::warning::` returning 0
and the one hard gate ran last by design, so v0.26.0 and v0.27.0 both released
with the package missing while docs-site told apt users to install it. The same
tags shipped it fine for Arch, Fedora 44 and Bazzite.

It now builds in its own job on Debian 13 (ci/gamescope-trixie.Dockerfile), the
oldest apt base the tree configures on. One package serves Debian 13 AND Ubuntu
26.04 — measured by installing and running it on both — because the build also
vendors libdisplay-info via the new `--extra-fallback` option: linked against
the distro copy it demands `libdisplay-info2` on trixie, which Ubuntu 26.04 does
not have (it carries libdisplay-info3). The option is opt-in, so the
Arch/Fedora/nix outputs are byte-for-byte unchanged. Ubuntu 24.04 gets no
gamescope package and cannot — its wayland is too old to run one however built.

Debian 13 is now a documented host target. That needed no packaging change at
all: the host .deb's glibc-2.39 floor and bundled FFmpeg already made it
installable, and it had been working for a long time while docs-site said Debian
was unsupported and unverified. Verified by installing: host, web console and
plugin runner install, resolve every soname and run. The desktop client stays
Ubuntu-26.04-only (built there, floors at `libc6 >= 2.43`; Debian 13 has 2.41).

Compositor detection now answers Cinnamon (Mint, LMDE) with the route that works
instead of advice that cannot help. Muffin forked from Mutter 3.36:
`org.cinnamon.Muffin.ScreenCast` has only RecordMonitor/RecordWindow, never
RecordVirtual, and xdg-desktop-portal-xapp implements no ScreenCast — so no
value of PUNKTFUNK_COMPOSITOR makes a Cinnamon desktop host a virtual display.
The error names headless gamescope, which needs no desktop compositor. The XDG
sniff moved into a pure function so those branches are testable; Cinnamon is
matched before GNOME, since it is a GNOME derivative and the generic arm would
otherwise hand it the Mutter backend (caught by the new test).

New `smoke-install` job installs every published package from the registry in
pristine ubuntu:24.04, ubuntu:26.04 and debian:trixie images, asserts each
binary resolves its libraries and runs, and insists the version served is the
one this run built. Nothing in deb.yml had ever installed a package it produced,
which is how both of the above survived unnoticed.

⚠ Bootstrap: seed `punktfunk-gamescope-trixie:latest` into the LAN registry once
(docker.yml builds it thereafter) or the new job cannot start.
2026-08-13 11:43:54 +02:00
enricobuehler 0026143164 Merge pull request 'fix(bazzite): the shipped template pinned ATTACH, so Game Mode mirrored the box's screen instead of giving the client its own display' (#189) from worktree-no-attach-default into main
ci / bun-nix (push) Successful in 30s
ci / web (push) Successful in 1m9s
apple / swift (push) Successful in 1m44s
ci / rust-arm64 (push) Successful in 1m35s
windows-host / package (push) Failing after 1m31s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
android / android (push) Failing after 3m35s
deb / build-publish-client-arm64 (push) Successful in 3m12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
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 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
ci / docs-site (push) Successful in 6m7s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 3m2s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 20s
apple / screenshots (push) Successful in 6m7s
deb / build-publish-host (push) Successful in 6m41s
docker / builders-arm64cross (push) Successful in 11s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3m9s
arch / build-publish (push) Successful in 11m45s
deb / build-publish (push) Successful in 12m33s
docker / deploy-docs (push) Failing after 7m31s
ci / rust (push) Successful in 18m21s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m33s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 24m26s
Reviewed-on: #189
2026-08-13 09:24:21 +00:00
enricobuehler ba16237c35 fix(bazzite): the shipped template pinned ATTACH, so Game Mode mirrored the box's screen instead of giving the client its own display
apple / swift (pull_request) Successful in 1m46s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 8m43s
ci / bun-nix (pull_request) Successful in 22s
ci / web (pull_request) Successful in 1m7s
ci / docs-site (pull_request) Successful in 3m13s
ci / rust-arm64 (pull_request) Successful in 3m18s
ci / rust (pull_request) Successful in 15m34s
Field report: "on Bazzite when using gaming mode it is mirroring the main display
instead of giving the client its own." It is our own template that does it.

`packaging/bazzite/host.env` set `PUNKTFUNK_GAMESCOPE_ATTACH=1`, and every install
path — rpm, deb, Arch, nix — ships that file as `/usr/share/punktfunk/host.env.bazzite`
with the docs telling people to copy it verbatim. So the recommended Bazzite setup
turned the attach override ON for everyone.

That override is rung 2 of `pick_gamescope_mode`, ABOVE `dedicated_launch` at rung 3.
The rung comment calls the operator overrides a debug/CI escape hatch, which is right —
but we were shipping one as a distro default, so on a Bazzite box the managed takeover
and the dedicated game session were both unreachable. A game launched from a client's
library could not get a session of its own either, which is the case the dedicated
route exists for. With a physical display connected, attach then takes the
`physical_display_connected()` arm and streams the box's own head at the box's own
mode: the mirror the reporter saw.

The template now forces nothing and lets the per-connect detection answer, which on a
box with `gamescope-session-plus` is MANAGED. Attach stays available, documented as the
opt-in it is, with the mirror and the dedicated-session cost stated. Because managed
depends on the `punktfunk` group to stop the display manager, the template now says so
where someone choosing a model will read it, rather than only in the distro guide.

Also fixes the off-switch. Both overrides were read with `var_os(..).is_some()`, so
`PUNKTFUNK_GAMESCOPE_ATTACH=0` meant ATTACH ON — the opposite of what the line says,
and of every other knob on this host. They now use the shared `env_on` grammar, so
`0|false|off|no` disable and a bare `=1` keeps working. Anyone who "turned attach off"
in an older host.env had it on the whole time.

Note an upgrade never rewrites an existing `~/.config/punktfunk/host.env`, so boxes set
up from an older template keep the pin until the line is deleted by hand; the Bazzite
and HDR pages now say that.

Verified: `scripts/xcheck.sh linux` check + clippy `-D warnings` clean, pf-vdisplay
206/0 under rust:1.96, `cargo fmt --all --check` clean. Gate proved non-vacuous against
a planted `compile_error!` in routing.rs.
2026-08-13 09:45:52 +02:00
enricobuehler 981f32b8f6 Merge pull request 'Gamescope streams could tear pink at 120 fps — zero-copy handed the capture buffer back while the VCN was still reading it' (#188) from worktree-gamescope-tear-race into main
ci / bun-nix (push) Successful in 24s
ci / rust-arm64 (push) Successful in 1m29s
apple / swift (push) Successful in 1m44s
ci / web (push) Successful in 4m45s
ci / docs-site (push) Successful in 6m3s
ci / rust (push) Successful in 6m19s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 17s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m25s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 30s
deb / build-publish (push) Successful in 7m18s
apple / screenshots (push) Successful in 6m25s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 26s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 39s
deb / build-publish-host (push) Successful in 8m3s
deb / build-publish-client-arm64 (push) Successful in 4m57s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m54s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 15s
docker / builders-arm64cross (push) Successful in 16s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3m25s
android / android (push) Successful in 11m52s
arch / build-publish (push) Successful in 14m31s
windows-host / package (push) Successful in 13m55s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 33s
docker / deploy-docs (push) Successful in 7m2s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m48s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m41s
Reviewed-on: #188
2026-08-13 07:24:07 +00:00
enricobuehler a9a1b923a2 fix(pf-capture): withhold zero-copy buffers from the producer until the encoder is done reading them
ci / bun-nix (pull_request) Successful in 29s
ci / web (pull_request) Successful in 1m8s
apple / swift (pull_request) Successful in 1m50s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 4m14s
ci / rust-arm64 (pull_request) Successful in 4m52s
ci / rust (pull_request) Successful in 4m57s
android / android (pull_request) Successful in 7m6s
The raw-dmabuf passthrough handed the SPA buffer back to gamescope at
.process return while the encode thread had not yet imported - let alone
read - its dmabuf, and nothing ordered the producer's writes against the
consumer's read (no explicit sync; the implicit-fence wait measures
NoFence on every compositor x vendor pairing we have). On the direct-VCN
arms (native NV12, RGB-direct EFC) the captured buffer IS the encode
source for the whole ring-2-deep encode plus the phase-lock hold, so at
120 fps gamescope cycles back into the buffer mid-encode and the stream
ships torn frames: luma/chroma desync (magenta tint) plus block
corruption propagating through the P-chain until the next intra. Field
report: Nobara, gamescope mode at 120 fps - KDE sessions were clean
because cursor_blend routes them to the compute-CSC copy arm whose read
window is microseconds.

The fix defers the requeue: a published passthrough frame carries a
FrameHold (new on DmabufFrame), and the buffer rejoins the producer's
pool only when the last clone drops. The Vulkan encoder clones the hold
into the ring slot at submit and releases it when the slot's fence
retires (poll/backpressure/reset), extending "the producer must not
rewrite this" across exactly the GPU read. The host loop's repeat path
is fixed by the same mechanism: a re-submitted frame now aliases a
buffer the producer never got back, instead of whatever gamescope last
composited into it.

Bookkeeping lives in a per-stream HoldBook (loop-thread mutations only):
holds release through a pw channel onto the loop thread, a generation
tag keeps a stale release from requeueing a renegotiated pool's reused
address, and at most pool_depth - HOLD_POOL_RESERVE buffers are ever
withheld - a pool at the old floor of 2 cannot spare any and falls back
(with one warn) to the previous racy contract. PUNKTFUNK_ZEROCOPY_HOLD=0
restores the old behavior outright for field bisects.

Gates (.25): cargo check + clippy --all-targets -D warnings on
pf-frame/pf-capture/pf-encode/punktfunk-host; pf-capture 68/68 (4 new
HoldBook tests), pf-encode 75/75 (+15 ignored, host-feature set);
workspace cargo fmt --check clean. punktfunk-host's
hooks::prep_runs_do_in_order_and_undo_in_reverse fails on that box on
pristine main too (pre-existing; crate untouched here).
2026-08-13 09:00:23 +02:00
enricobuehler 124cb66324 Merge pull request 'Wake-on-LAN: support WoWLAN so Wi-Fi hosts wake like wired ones' (#187) from worktree-wowlan-support into main
apple / swift (push) Successful in 1m40s
ci / rust-arm64 (push) Successful in 1m41s
ci / bun-nix (push) Successful in 2m2s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m45s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 1m1s
deb / build-publish-client-arm64 (push) Successful in 3m24s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 45s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 31s
ci / rust (push) Successful in 5m55s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 22s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 23s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 3m59s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m17s
apple / screenshots (push) Successful in 6m3s
arch / build-publish (push) Successful in 8m55s
ci / web (push) Successful in 1m3s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 2m42s
ci / docs-site (push) Successful in 1m12s
docker / builders-arm64cross (push) Successful in 20s
docker / deploy-docs (push) Successful in 48s
deb / build-publish (push) Failing after 6m34s
deb / build-publish-host (push) Successful in 14m19s
android / android (push) Successful in 10m58s
release / apple (push) Successful in 10m0s
flatpak / build-publish (push) Successful in 12m38s
windows-host / package (push) Successful in 16m30s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 46s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m19s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m53s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m2s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m6s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m20s
2026-08-12 22:51:57 +00:00
enricobuehler 6774c4e7a2 feat(wol): support WoWLAN so Wi-Fi hosts wake like wired ones
ci / bun-nix (pull_request) Successful in 1m33s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m25s
apple / swift (pull_request) Successful in 2m5s
ci / web (pull_request) Successful in 2m14s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m57s
ci / docs-site (pull_request) Successful in 3m4s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m41s
android / android (pull_request) Successful in 7m3s
ci / rust (pull_request) Successful in 10m23s
The host's arming check asked `ethtool` about every NIC, which is the wrong
question for Wi-Fi: the magic-packet trigger lives in nl80211's WoWLAN state,
and most wireless drivers print `Wake-on: d` whether or not it is armed. An
armed Wi-Fi host was therefore told it was NOT armed, and handed an
`ethtool -s wlan0 wol g` its driver rejects. A NIC with an nl80211 phy
(`/sys/class/net/<i>/phy80211`) is now asked `iw phy <phy> wowlan show`
instead, and the warning carries WoWLAN-correct guidance — `iw ... wowlan
enable magic-packet`, plus the NetworkManager
`802-11-wireless.wake-on-wlan magic` that survives a reconnect. Two fallbacks
for when `iw` can't answer (missing binary, driver without the command, or
privilege the user-level host service lacks): a POSITIVE ethtool reading
counts (brcmfmac & co do report there), a negative one never does, and sysfs
`device/power/wakeup` reading `disabled` is conclusive in the negative.

The client sender now emits from a socket bound to EACH non-loopback
interface's own address rather than leaving the path to the routing table. A
station in WoWLAN sleep stays associated and its AP buffers broadcast frames
for it until the next DTIM beacon — but only if the datagram reaches the
wireless segment at all, and with a VPN or mesh interface holding the default
route `255.255.255.255` never did. A failed bind falls back to the routed
socket, so no segment is lost.

Tests: `iw`/`ethtool` output parsing split from the commands so both are unit-
tested on any platform, and a new end-to-end test asserts a real listener
receives the 102 magic-packet bytes.

Verified on Linux (Ubuntu 26.04, 12 interfaces): `cargo fmt --all --check`,
`cargo clippy -p punktfunk-core -p punktfunk-host --all-targets --locked
-- -D warnings`, and both wol test sets green. NOT yet exercised against real
Wi-Fi hardware — no Wi-Fi Linux box was reachable.
2026-08-13 00:49:21 +02:00
enricobuehler f06b84be63 Merge pull request 'plugin-kit 0.4.1 — publish the icon field, without which no plugin can name its mark' (#186) from worktree-launcher-icon-kit-release into main
ci / web (push) Successful in 1m9s
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 9s
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 9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m16s
ci / bun-nix (push) Successful in 3m7s
ci / docs-site (push) Successful in 4m32s
ci / rust-arm64 (push) Successful in 5m27s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m24s
docker / builders-arm64cross (push) Successful in 16s
docker / deploy-docs (push) Successful in 33s
plugin-kit-publish / publish (push) Successful in 1m2s
ci / rust (push) Successful in 9m41s
nix / flake (push) Failing after 12m47s
2026-08-12 22:30:11 +00:00
enricobuehler d6dbb391d6 chore(plugin-kit): 0.4.1 — publish the icon field, without which no plugin can name its mark
ci / bun-nix (pull_request) Successful in 40s
ci / docs-site (pull_request) Successful in 1m18s
ci / web (pull_request) Successful in 1m31s
ci / rust-arm64 (pull_request) Successful in 2m25s
ci / rust (pull_request) Successful in 11m40s
nix / flake (pull_request) Successful in 14m54s
`ProviderEntry.icon` landed in f62a48d4 along with the token's whole
supporting cast: the host-side shape guard, the seven masters, six client
renderers, the SDK and the OpenAPI. What it did not get was a version
bump, and the kit had cut 0.4.0 the day before.

So the registry's 0.4.0 is the tarball WITHOUT the field, and it is the
newest thing any plugin can resolve. A plugin that emits `icon` on a
launcher entry therefore fails `tsc --noEmit` — "Object literal may only
specify known properties, and 'icon' does not exist" — which is a CI gate
in every plugin repo. That is why the three plugins that were supposed to
carry the token never shipped it: the edits could not be committed
against a kit that had no field to fill.

Nothing but the version moves here. The only plugin-kit change since
0.4.0 was published is f62a48d4 itself, so 0.4.1 is exactly that commit's
kit surface — one optional string on an existing struct, additive, and
inert for a plugin that never sets it.
2026-08-13 00:29:26 +02:00
enricobuehler 907080f92b Merge pull request 'The Windows host could tank a local game's 1% lows — mint retries broadcast PnP device changes at the whole box, and session tuning never reverted' (#185) from worktree-audio-stutter-fixes into main
apple / swift (push) Successful in 1m43s
ci / rust-arm64 (push) Successful in 2m10s
ci / web (push) Successful in 2m48s
ci / docs-site (push) Successful in 1m18s
ci / bun-nix (push) Successful in 2m6s
ci / rust (push) Successful in 6m12s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
android / android (push) Successful in 6m42s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 18s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 15s
apple / screenshots (push) Successful in 5m58s
deb / build-publish-host (push) Successful in 6m34s
docker / deploy-docs (push) Successful in 29s
deb / build-publish-client-arm64 (push) Failing after 5m41s
docker / builders-arm64cross (push) Successful in 1m54s
windows-host / package (push) Successful in 13m17s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 22s
arch / build-publish (push) Successful in 11m8s
deb / build-publish (push) Successful in 12m23s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m50s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m15s
Reviewed-on: #185
2026-08-12 22:03:47 +00:00
enricobuehler 9425c6d40a docs(changelog): audio no longer costs local-game frame time on Windows hosts
ci / bun-nix (pull_request) Successful in 24s
ci / rust (pull_request) Failing after 57s
apple / swift (pull_request) Successful in 1m54s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 5m1s
ci / docs-site (pull_request) Successful in 4m46s
android / android (pull_request) Successful in 11m12s
ci / web (pull_request) Failing after 13m50s
2026-08-12 22:38:35 +02:00
enricobuehler ab8c7ec37c fix(audio/windows): stop the mint retry path from broadcasting PnP device changes at the whole box, and revert session tuning when streaming ends
Field report (2026-08-12): Punktfunk's audio devices tank Helldivers 2 to
1% lows of 2-5 FPS; uninstalling restores performance. Two host-side
mechanisms can plausibly do that, both fixed here.

The mint retry storm: minted::ensure_blocking() ran a FULL provisioning
pass on every mic-pump open with no cooldown, no in-flight guard, and no
give-up - and ensure_role() reached UpdateDriverForPlugAndPlayDevicesW
even when the devnode already existed. On a box where minting never
latches, the pump's reopen backoff (capped 60 s) turned that into a PnP
driver rebind + system-wide device-change broadcast roughly once a
minute, forever - and games rebuild their audio graph on each broadcast.
Now:

* ensure_role() gets a steady-state fast path: a marker devnode whose
  endpoints are all live resolves without touching PnP or the
  default-device policy.
* ensure_blocking() waits on an in-flight pass instead of racing a
  second SetupAPI sweep against it (the dead-mic-air deploy race),
  honours RETRY_COOLDOWN after a failed pass (first-ever resolve still
  blocks, per the cold-boot mint contract), and
* five unlatched passes stop minting for the host lifetime (a service
  restart re-arms) - counted across the worker and the blocking path.

The never-reverted session tuning: pf-frame's tune_process_once() put
the whole host at HIGH_PRIORITY_CLASS with timeBeginPeriod(1) and DWM
MMCSS on the first hot stream thread and documented 'reverts at process
exit' - but the host is a 24/7 service, so after one stream it competed
at HIGH class with a 1 ms global timer against whatever the user played
locally, forever. The process-wide tuning is now refcounted across the
hot threads via a TLS guard: the first hot thread applies it, the last
one's exit reverts it (timeEndPeriod, DwmEnableMMCSS(0), NORMAL class) -
the same thread-exit lifetime the MMCSS and execution-state effects
already ride. Every on_hot_thread() call site is a session-scoped
thread (capture/encode, packetizer, send, NVENC retrieve), so the
revert lands at session teardown.
2026-08-12 22:29:54 +02:00
enricobuehler 64e2af17c5 Merge pull request 'fix(decky): duplicate shortcut minted every boot + toast noise cut' (#184) from worktree-decky-shortcut-dup into main
decky / build-publish (push) Successful in 1m10s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 23s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 22s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 14s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 1m34s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 20s
docker / builders-arm64cross (push) Successful in 7s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m36s
ci / rust-arm64 (push) Successful in 1m32s
ci / web (push) Successful in 3m45s
ci / docs-site (push) Successful in 4m1s
ci / bun-nix (push) Successful in 29s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m8s
docker / deploy-docs (push) Successful in 32s
ci / rust (push) Canceled after 4m8s
2026-08-12 20:25:57 +00:00
enricobuehler 72189b29ec Merge branch 'main' into worktree-decky-shortcut-dup
ci / rust (pull_request) Successful in 6m55s
ci / web (pull_request) Successful in 1m10s
ci / docs-site (pull_request) Successful in 1m21s
ci / bun-nix (pull_request) Successful in 34s
ci / rust-arm64 (pull_request) Successful in 1m30s
2026-08-12 22:24:34 +02:00
enricobuehler 339a1d70f9 fix(decky): stop toasting on every launch and every failed panel refresh
Field complaint: the plugin toasts too much. Inventory of all 14 toast
sites says almost all are rare, explicit-tap feedback (pairing, update
buttons, recovery actions) — but two were routine-volume offenders:

  * startStream toasted "Starting stream — <host>" on EVERY successful
    launch, i.e. the overwhelming majority of all toasts the plugin ever
    shows. It repeats the button the user just pressed, and lands ON TOP
    of the starting stream after the QAM closes. Gone; launch FAILURES
    still toast (the QAM may already be closed, so inline state would go
    unseen).
  * useHosts.refresh() toasted "Couldn't list hosts" from its catch —
    and the panel remounts (and refreshes) on every QAM open, so a broken
    backend nagged on each open. It's now a third inline `problem` row
    ("Couldn't scan for hosts"), sitting next to the Refresh button that
    retries it, like the client-unavailable/client-outdated states
    already did.

The update-flow, pairing, trust and recovery toasts stay: each is a rare,
single, information-carrying response to an explicit tap (or, for the
request-access hint, the only warning that the connect is about to park).

Verified: tsc --noEmit and the rollup bundle pass.
2026-08-12 22:05:16 +02:00
enricobuehler 79dba7f95a fix(decky): a boot race minted a new library shortcut on every plugin load
Field report: each Steam start added another visible "Punktfunk" entry
(spotted in the desktop client, where the pile is plain to see).

Mechanism: db063792 made shortcutStillExists() actually answer for the
first time — and its callers treat a null overview as "the user deleted
the shortcut" and AddShortcut a replacement. But the plugin mounts while
Steam is still starting up, BEFORE appStore has registered its overviews,
so the remembered (perfectly live) appId looks up as null on every boot:
mint a duplicate, remember the new id, orphan yesterday's. One new entry
per load, forever.

The deleted verdict now has to be earned, and creation is a last resort:

  * shortcutStillExists() only believes "absent" once the store is
    demonstrably hydrated: wait out App.WaitForServicesInitialized (raced
    against the poll budget so a wedged signal can't hang the guard),
    poll until allApps is non-empty, then one grace recheck — overview
    registration can trail the bulk hydration. Unverifiable within budget
    answers true: a false "alive" merely no-ops until the next ask, a
    false "dead" duplicates forever.
  * On a genuinely lost id, both ensure paths first ADOPT an existing
    same-named shortcut (excluding the other role's) instead of minting
    an N+1th — which also heals installs the old builds already littered.
  * Both ensures are single-flight: mount's fire-and-forget can now be
    mid-wait when a QAM press arrives, and two ensures racing past the
    liveness check would each AddShortcut.
  * "Recreate library shortcut" additionally sweeps surplus "Punktfunk"
    shortcuts (RemoveShortcut) and toasts the count — cleanup for piles
    already minted. Deliberately button-only, never mount: automatic
    library deletion at boot is a bigger hazard than the mess.

Verified: tsc --noEmit and the rollup bundle both pass; the launch paths
(launchStream / launchGamepadUi) hit the fast path unchanged — a live
overview answers the first query and nothing waits.
2026-08-12 21:54:06 +02:00
enricobuehler d7430fe2bd Merge pull request 'fix(ci): gate C counts comments, and a comment named the env mutators verbatim' (#183) from fix/gate-c-comment-token into main
ci / rust-arm64 (push) Successful in 2m24s
ci / web (push) Successful in 1m46s
ci / bun-nix (push) Successful in 46s
ci / docs-site (push) Successful in 1m50s
deb / build-publish (push) Successful in 4m25s
deb / build-publish-client-arm64 (push) Successful in 6m32s
flatpak / build-publish (push) Successful in 6m59s
deb / build-publish-host (push) Successful in 12m2s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m42s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m0s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m23s
arch / build-publish (push) Successful in 13m38s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m34s
ci / rust (push) Successful in 13m31s
2026-08-12 16:57:38 +00:00
enricobuehler 6f81ec24ba fix(ci): gate C counts comments, and a comment named the env mutators verbatim
ci / rust-arm64 (pull_request) Successful in 1m50s
ci / docs-site (pull_request) Successful in 1m33s
ci / web (pull_request) Successful in 2m30s
ci / rust (pull_request) Successful in 14m12s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m21s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m32s
ci / bun-nix (pull_request) Failing after 13m41s
55a3d8b9 (#181) added the edition-2024 lint-level rationale to the session
bin's header naming std::env::set_var/remove_var — gate C's grep counts
comments by contract, so main went red at 5 mentions against the 4-call-site
baseline. Reword the comment instead of raising the baseline: a baseline of 5
with one comment inside would hide the next real call site.

Verified: scripts/ci/check-unsafe-hygiene.sh clean, cargo fmt clean.
2026-08-12 18:57:07 +02:00
enricobuehler 539236de91 Merge pull request 'feat(pad-audio): Linux hosts stream pad audio — the per-pad PipeWire sink (WP3)' (#182) from worktree-linux-pad-audio into main
arch / build-publish (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / web (push) Canceled after 41s
ci / rust-arm64 (push) Canceled after 0s
ci / docs-site (push) Canceled after 14s
ci / bun-nix (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 17s
deb / build-publish-host (push) Canceled after 16s
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 15s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 20s
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 12s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
apple / swift (push) Successful in 1m44s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m41s
android / android (push) Successful in 6m37s
apple / screenshots (push) Successful in 5m49s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 3m48s
docker / builders-arm64cross (push) Successful in 26s
windows-host / package (push) Successful in 13m15s
windows-host / winget-source (push) Skipped
docker / deploy-docs (push) Successful in 46s
windows-host / canary-manifest (push) Successful in 32s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 28m24s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 29m10s
2026-08-12 16:55:06 +00:00
enricobuehler 118758ff0b fix(pad-audio): the Linux pad sink speaks GE-Proton's AUX0-3 channel shape
ci / bun-nix (pull_request) Successful in 1m31s
ci / rust-arm64 (pull_request) Successful in 1m47s
ci / docs-site (pull_request) Successful in 1m54s
apple / swift (pull_request) Successful in 1m59s
ci / web (pull_request) Successful in 2m3s
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Failing after 3m18s
android / android (pull_request) Successful in 6m59s
A field report (GE-Proton 11-5, real DualSense on-host) surfaced the missing
constraint: haptics only work when the pad's card runs the Pro Audio profile —
because GE's route opens the node through its bundled pipewire-alsa plugin
with aux_channels=1, and its pulse fallback forces a PA AUX0..3 map with
stream.dont-remix (proton-ds5-haptic patches 0013/0115/0116: "the hidden
PipeWire parent for a DualSense output exposes AUX0 through AUX3"). A
positioned FL FR RL RR sink puts those writers through position channelmix
instead of index passthrough.

The sink now advertises AUX0..AUX3. Proven on the box: an AUX-mapped
rear-pair-only tone captures index-exact (speaker pair 0.0000, coil pair
0.3662); a positioned stray stream folds into the speaker pair and never
excites the coils. The devtest reports per-pair peaks so exactly this class
of remix bug is visible.

Also confirmed from the GE patch set while here: device matching is
device.bus/vendor.id/product.id + the Sony/Wireless_Controller name
substrings (both of which the sink carries), and the MMDevice container is
now synthesized from the wine-side HID USB parent (patch 0112) — the old
pure-PW-node GUID_NULL concern no longer applies on GE >= 11-4.
2026-08-12 18:53:35 +02:00
enricobuehler dcde856178 feat(pad-audio): Linux hosts stream pad audio — the per-pad PipeWire sink (WP3)
The 0xD1 plane was Windows-host-only: host_cap() answered false and spawn()
was a stub everywhere else, so an Android tier-A client against a Linux host
negotiated the cap off and stayed on wire rumble. The whole downstream
machinery (framer, silence gate, lanes, 0xD1 send) was already capture-
agnostic — only the capturer was WASAPI.

- audio/linux/pad_sink.rs: one Audio/Sink stream node per DualSense-family
  pad, minted with the identity the matchers read (ALSA-style node.name with
  the pad's pairing MAC, description "Wireless Controller", bus/vendor/
  product/form-factor proplist, per-pad serial), 4-ch F32 48 kHz FL FR RL RR,
  no default-sink claim, priority.session 50. The process() callback IS the
  capture. PUNKTFUNK_PAD_SINK_NAME/_DESC override the strings for field
  debugging ({pad}/{mac} expand).
- native/pad_audio.rs: the shared logic and lanes compile on Linux;
  pad_audio_thread is generic over the capturer (open-with-backoff kept);
  host_cap() Linux arm = client asked + PUNKTFUNK_PAD_AUDIO + a reachable
  PipeWire socket; spawn() Linux arm mints the sink lazily in the streamer
  thread. spawn() gains an edge flag (Edge identity; ignored on Windows).
- devtest pad-sink-test: mint one sink and capture from it, no client — the
  WP3 on-glass gate. Verified on a Bazzite 44 host: identity served through
  pipewire-pulse, rear-pair (voice-coil) tone captured bit-exact over both
  the native and pulse legs.
- docs: PUNKTFUNK_PAD_AUDIO{,_SLOTS} are no longer (Windows); the roadmap
  non-goal narrows to Bluetooth client pads.

Gates (fedora:44 container, natively on the .41 box): cargo build --release
--locked (nvenc+vulkan-encode), clippy --all-targets -D warnings, cargo test
pad_audio+pad_sink 11/11, cargo fmt.
2026-08-12 18:53:35 +02:00
enricobuehler 77918674c3 Merge pull request 'An over-declared HEVC level no longer demotes native Vulkan decode, and the Windows client legs build again' (#181) from worktree-vk-level-gate-clamp into main
android / android (push) Failing after 1m20s
apple / swift (push) Successful in 1m37s
ci / rust-arm64 (push) Successful in 1m45s
ci / docs-site (push) Successful in 1m29s
ci / rust (push) Failing after 3m2s
ci / bun-nix (push) Successful in 2m3s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m4s
ci / web (push) Successful in 3m46s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 16s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 18s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 18s
apple / screenshots (push) Canceled after 3m29s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m53s
arch / build-publish (push) Canceled after 5m25s
deb / build-publish (push) Canceled after 3m2s
deb / build-publish-host (push) Canceled after 2m45s
deb / build-publish-client-arm64 (push) Canceled after 2m39s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 33s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 30s
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 28s
flatpak / build-publish (push) Canceled after 2m37s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 3m11s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 2s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
2026-08-12 16:48:56 +00:00
enricobuehler faefbae830 fix(pf-presenter): spell MAKEINTRESOURCE(1) as ptr::without_provenance — clippy 1.96's manual_dangling_ptr reads the integer-ordinal cast as a dangling pointer and fails the Windows -D warnings gate (masked on main by the client bins failing to build first)
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m16s
ci / bun-nix (pull_request) Successful in 26s
ci / web (pull_request) Failing after 1m35s
apple / swift (pull_request) Successful in 1m42s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m18s
ci / rust (pull_request) Failing after 2m21s
ci / rust-arm64 (pull_request) Successful in 3m45s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m42s
android / android (pull_request) Successful in 4m29s
2026-08-12 18:47:45 +02:00
enricobuehler 44fa12a298 test(pf-vkdecode): bind the PTL level reads so the SAFETY comments precede their unsafe blocks (clippy::undocumented_unsafe_blocks counts nothing inside macro arguments) 2026-08-12 18:47:45 +02:00
enricobuehler 55a3d8b919 fix(clients/session): the edition-2024 session bin cannot build on Windows
The half of the #177 fallout #180's follow-up could not reach: WP20 wrapped
the session bin's single-threaded-startup env writes in the `unsafe {}`
blocks edition 2024 requires — under `#![forbid(unsafe_code)]`, which no
inner attribute can override, so `punktfunk-client-session` fails with two
hard errors on every Windows leg (main push runs 17615/17616 red at Build;
verified on .173). Same resolution as #180 gave the GTK shell: `forbid`
becomes `deny`, and the three documented SAFETY sites carry the localized
`#[allow(unsafe_code)]` pf-update models.
2026-08-12 18:47:45 +02:00
enricobuehler a02014ec19 fix(pf-vkdecode): treat an over-declared stream level as a clamp, not a refusal
A 2026-08-12 field report (RTX 5060 client): every HEVC session demoted to
D3D11VA with 81 "outside device caps: stream level (Std code point 12) above
the device's maxLevelIdc (H.265 Std level 11)" refusals — the host's AMF
encoder stamps general_level_idc 6.2 (the codec maximum) on a 4K120 stream
that needs 5.2, and NVIDIA's driver caps H.265 decode at 6.1. The hardware
decodes the actual stream trivially; only the declaration was oversized.
AV1 passed the same gate, which is why "native-vulkan runs only with AV1".

The declared level is a claim, and the stream's real demands are enforced
where they are physical facts — coded extent and DPB depth, both checked at
session build. So the up-front level gate (H.264 + H.265) now warns once and
proceeds, and every SPS/VPS handed to the Vulkan parameters object has its
level clamped to the device ceiling (a set above maxLevelIdc is invalid
usage). AV1's gate is untouched: its code space is the bitstream's own and
no over-declaration has been seen in the field.

Verified on .173 (RTX 4090, driver 610.88): HEVC and AV1 both decode on the
native Vulkan rung at 60 fps against an NVENC host; unit tests pin the clamp
(lowers, only lowers, mutates the driver-visible block in place).
2026-08-12 18:46:57 +02:00
enricobuehler 5f55b820bc Merge pull request 'Edition-2024 follow-up: the three gates only the PR's own CI could reach' (#180) from worktree-edition-2024 into main
ci / rust (push) Failing after 3m38s
apple / swift (push) Successful in 1m49s
android / android (push) Failing after 5m32s
ci / docs-site (push) Successful in 1m12s
ci / rust-arm64 (push) Failing after 3m36s
ci / bun-nix (push) Successful in 1m24s
ci / web (push) Successful in 2m24s
arch / build-publish (push) Failing after 5m50s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
deb / build-publish (push) Failing after 1m29s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 1m1s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 17s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 17s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m14s
docker / builders-arm64cross (push) Successful in 10s
deb / build-publish-client-arm64 (push) Failing after 3m43s
apple / screenshots (push) Successful in 5m38s
deb / build-publish-host (push) Successful in 6m17s
docker / deploy-docs (push) Failing after 4m38s
windows-host / package (push) Successful in 12m27s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 17s
flatpak / build-publish (push) Canceled after 6m57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 6m50s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 6m50s
2026-08-12 16:34:13 +00:00
enricobuehler 57703fe31c Merge remote-tracking branch 'origin/main' into worktree-edition-2024
ci / rust-arm64 (pull_request) Failing after 58s
ci / web (pull_request) Successful in 1m0s
ci / bun-nix (pull_request) Successful in 2m9s
apple / swift (pull_request) Successful in 1m38s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 4m10s
ci / rust (pull_request) Failing after 5m14s
android / android (pull_request) Successful in 9m15s
2026-08-12 18:30:50 +02:00
enricobuehler 90a3304c2b Merge pull request 'The Windows host no longer blocks system sleep while idle' (#179) from worktree-win-sleep-blockers into main
deb / build-publish (push) Failing after 1m16s
ci / bun-nix (push) Successful in 27s
ci / web (push) Successful in 1m5s
ci / docs-site (push) Successful in 1m11s
apple / swift (push) Successful in 1m37s
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 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
deb / build-publish-client-arm64 (push) Failing after 1m24s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 16s
android / android (push) Canceled after 3m59s
apple / screenshots (push) Canceled after 2m19s
ci / rust-arm64 (push) Failing after 3m56s
ci / rust (push) Failing after 3m56s
arch / build-publish (push) Canceled after 4m1s
deb / build-publish-host (push) Canceled after 2m54s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 1m11s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 1m7s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 17s
windows-host / package (push) Canceled after 4m4s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
Reviewed-on: #179
2026-08-12 16:30:11 +00:00
enricobuehler 017867f211 fix(ci): the gates the PR run reaches and the boxes could not
ci / rust-arm64 (pull_request) Failing after 1m43s
ci / web (pull_request) Successful in 1m52s
ci / bun-nix (pull_request) Successful in 28s
ci / docs-site (pull_request) Successful in 3m36s
apple / swift (pull_request) Successful in 1m44s
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Failing after 2m52s
android / android (pull_request) Successful in 6m23s
- adl_emul.rs adl_malloc: panic-free (a reachable expect in an extern fn is an
  abort — gate B; the Err arm is unreachable, ADL treats null as failure)
- punktfunk-host main.rs: reword the carve-out comments so gate C's textual
  count stays at its baseline (comments count)
- clients/linux: forbid(unsafe_code) -> deny with two named allows — the SDL
  device-filter clear and the spawn test's HOME scoping are unsafe calls in
  edition 2024 (caught by the aarch64 leg, the only one with glib)
2026-08-12 17:44:15 +02:00
enricobuehler ef0af3b558 docs(changelog): the Windows host no longer blocks system sleep while idle
ci / rust (pull_request) Failing after 1m1s
ci / web (pull_request) Successful in 1m7s
ci / bun-nix (pull_request) Successful in 27s
ci / rust-arm64 (pull_request) Successful in 6m17s
ci / docs-site (pull_request) Successful in 1m35s
apple / swift (pull_request) Successful in 1m42s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 9m51s
2026-08-12 17:41:08 +02:00
enricobuehler 535e95c4c0 fix(punktfunk-host): idle-stop the virtual-mic stream so a Windows host can sleep
The mic pump's WASAPI backend rendered silence into the virtual mic's
render endpoint (typically the Steam Streaming Microphone) for the whole
host lifetime. A RUNNING stream makes the Windows audio stack hold a
kernel power request ("An audio stream is currently in use", visible in
powercfg /requests), so every idle Punktfunk host blocked system sleep
forever — field-reported 2026-08-12 ("doesn't go to sleep anymore since
installing punktfunk; powercfg shows the Steam Streaming Microphone").

Stop the render stream (IAudioClient::Stop — client stays initialized,
the mic endpoint keeps existing) after 10 s of silence-only output at an
unchanged queue length, and park the render thread on a condvar the
producer notifies on the empty->non-empty edge, so the next mic frame
resumes the stream within one device period — well under the jitter
buffer's prime depth, so nothing audible changes. Keying the idle window
on the queue LENGTH (not emptiness) also covers a sub-prime tail a
vanished client left behind, while any fresh burst moves the length and
resets the window instead of being dropped.

During a session the box stays awake through the session's own
DisplayWakeRequest (pf-frame), never through this silence.
PUNKTFUNK_MIC_ALWAYS_ON=1 restores the old always-running stream in case
a virtual audio driver misbehaves while its render side is paused.
2026-08-12 17:39:22 +02:00
enricobuehler b385f0a031 Merge pull request 'Rust edition 2024: the whole tree, with the env-mutation class made visible (WP20)' (#177) from worktree-edition-2024 into main
ci / rust (push) Failing after 56s
apple / swift (push) Successful in 1m36s
ci / web (push) Successful in 1m9s
ci / docs-site (push) Successful in 1m15s
deb / build-publish-client-arm64 (push) Failing after 2m11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 15s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 18s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 2m28s
apple / screenshots (push) Successful in 5m59s
ci / rust-arm64 (push) Failing after 8m18s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 1m5s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3m43s
windows-drivers / probe-and-proto (push) Successful in 23s
deb / build-publish-host (push) Successful in 7m58s
decky / build-publish (push) Successful in 36s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
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 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
windows-drivers / driver-build (push) Successful in 1m50s
arch / build-publish (push) Failing after 11m24s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 51s
ci / bun-nix (push) Failing after 10m37s
deb / build-publish (push) Failing after 3m38s
flatpak / build-publish (push) Failing after 8m49s
android / android (push) Successful in 16m29s
docker / builders-arm64cross (push) Successful in 9s
docker / deploy-docs (push) Successful in 35s
release / apple (push) Successful in 10m11s
nix / flake (push) Successful in 15m25s
windows-host / package (push) Successful in 13m20s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 34s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Failing after 2m16s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 56s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m13s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m15s
Reviewed-on: #177
2026-08-12 15:38:22 +00:00
enricobuehler 7528fd48a9 Merge pull request 'A stats tier picked between streams reached nothing until the app was restarted' (#178) from worktree-console-stats-tier-relatch into main
android / android (push) Canceled after 21s
apple / swift (push) Canceled after 21s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 26s
ci / rust (push) Canceled after 32s
ci / rust-arm64 (push) Canceled after 12s
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 1s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 2s
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
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 4s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Reviewed-on: #178
2026-08-12 15:38:03 +00:00
enricobuehler c68e0be688 Merge remote-tracking branch 'origin/main' into worktree-edition-2024
ci / bun-nix (pull_request) Successful in 24s
windows-drivers / probe-and-proto (pull_request) Successful in 34s
ci / docs-site (pull_request) Successful in 1m23s
apple / swift (pull_request) Successful in 1m45s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 3m4s
windows-drivers / driver-build (pull_request) Successful in 2m23s
ci / rust (pull_request) Failing after 4m6s
ci / rust-arm64 (pull_request) Failing after 5m5s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 1m54s
nix / flake (pull_request) Successful in 13m36s
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 54s
android / android (pull_request) Successful in 15m6s
2026-08-12 17:27:27 +02:00
enricobuehler 71c1970b93 fix(client/console): a stats tier picked between streams reached nothing until a restart
ci / bun-nix (pull_request) Successful in 31s
apple / swift (pull_request) Successful in 1m43s
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Failing after 59s
android / android (pull_request) Successful in 3m59s
ci / web (pull_request) Successful in 4m18s
ci / rust-arm64 (pull_request) Successful in 5m5s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 4m17s
ci / docs-site (pull_request) Successful in 4m32s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 3m15s
Field report: "no matter what I select the stats overlay is stuck showing as
detailed" on the Deck, cured by restarting the client app.

The console (Gaming Mode, and therefore Decky) builds its window and its run loop
ONCE and streams every session through them, and the loop took its stats tier from
the settings snapshot read at process start. Its own settings screen writes the
chosen tier to the file and redraws its row, so the choice looked taken while every
stream kept the tier the process happened to start on — Detailed for anyone who had
been on Detailed. Only a restart re-read it. The desktop shells were never affected:
they spawn a session process per stream, which resolves settings for itself.

The tier now rides `SessionParams` per launch, so browse mode adopts what THIS launch
resolved and the start-of-process value only seeds the loop until the first stream.
Two things fall out of resolving per launch rather than per process: a profile bound
to a host can finally move the tier in console mode (part of the documented P4 gap),
and the adoption sits in the `Start` arm rather than `StreamState::new`, so the
codec-fallback retry can't snap the overlay back and undo an in-stream cycle.

The `--stats` rule (a floor that lifts Off to Normal and demotes nothing) was written
out three times and is now one tested helper. The rest of the console's latched
presentation tier — touch and mouse model, shortcut inhibit, match-window, render
scale — is unchanged and still needs the models rebuilt per launch.

Gate: clippy --all-targets -D warnings, plain build, and tests for pf-client-core,
pf-presenter and punktfunk-client-session, all green in pf-lxcheck2 (linux/amd64);
clippy proven non-vacuous by touching the four edited files. cargo fmt --all --check
clean.
2026-08-12 17:26:33 +02:00
enricobuehler f373dffb5e chore: migrate the main workspace and pf-vkhdr-layer to edition 2024 (WP20)
The safety half of the rust-safety programme's §8.4: `std::env::set_var`/`remove_var` are
`unsafe fn` in edition 2024, converting the class of bug the programme found the hard way
(the 972af299 environ data race lived in a file with ZERO occurrences of the word
`unsafe`) from invisible to counted and compiler-enforced.

Manifests: [workspace.package] edition 2021→2024, rust-version 1.82→1.85 (the pinned
toolchain is 1.96.0, so no toolchain bump — only the declared floor rises); the 13 crates
pinning `edition = "2021"` literally now inherit it (Trap 1: the root bump alone reaches
only `edition.workspace = true` crates and would have left pf-encode/pf-capture/pf-inject
et al. on 2021 while reading as complete); pf-driver-proto's stale rust-version 1.82 pin
now inherits; pf-vkhdr-layer (a separate workspace, inherits nothing) bumped to 2024. The
four vendored crates (fec-rs, cros-codecs, usbip-sim, the patched ndk) stay on 2021
deliberately — upstream code stays pristine. The excluded usbip-poc standalone PoC is
untouched.

Mechanical, done textually across ALL cfg branches so no platform's half is left behind
(Trap 3 — 44% of the host's unsafe is Windows-only and a one-platform `cargo fix` misses
it): 148 `#[no_mangle]` → `#[unsafe(no_mangle)]` (83 in abi.rs); 12 bare extern blocks →
`unsafe extern`; `gen` is a reserved keyword, so pf-vdisplay's generation stamps
(registry.rs, windows/manager.rs) and the WinUI shell's animation counters rename
gen → generation (internal identifiers only, no serde/wire surface); two
match-ergonomics patterns take the compiler's suggested reference form.

env mutation: every `set_var`/`remove_var` site (20 files) now sits in an `unsafe` block
whose SAFETY comment states the real serialization argument (pf-vdisplay's ENV_LOCK,
CONFIG_DIR_TEST_LOCK, ART_ROOTS_LOCK, vkdecode's gpu_lock, the `--test-threads=1`
contracts of the hardware spikes, or single-threaded startup). Two genuine hazards
surfaced en route — exactly the WP3b-class finds this migration exists to make visible —
and are fixed here:
- windows/service.rs spawned the network-profile warner thread BEFORE `load_host_env()`,
  so a child-spawning thread (child spawn snapshots the env block) was live while
  `set_var` ran in a loop; the load now precedes the spawn.
- pf-console-ui's `fake_home()` re-set HOME outside its OnceLock on EVERY call, so two
  parallel tests could race the write; the set now happens exactly once inside
  `get_or_init`.

cbindgen (Trap 2): 0.29.4 parses `#[unsafe(no_mangle)]` — verified empirically; the
header regenerates byte-identical. The ci.yml drift check could never catch "failed to
regenerate" (build.rs demotes a cbindgen failure to a warning and writes nothing, leaving
the checked-in header untouched and the diff clean), so the step now first asserts the
"punktfunk-core: wrote" line and the absence of "cbindgen failed" (sh -e safe: no `!`
pipeline, no tee-masked exit).

rustfmt: style_edition pinned to 2021 at the root — edition 2024 would otherwise flip the
style edition and reformat ~370 untouched files inside this same commit, burying the
migration diff. The drivers workspace pins its already-current 2024 style. Adopting the
2024 style tree-wide is its own future one-line-plus-reformat commit.

Census: the primary metric moves UP BY DESIGN — 2435 → 2453 operations, unsafe blocks
1534 → 1577, and env_set_var is now a counted category (45 ops). The newly counted env
sites are a truer number, not a regression; baseline snapshot saved as punktfunk-planning
design/rust-safety-census-baseline-2026-08-12-edition-2024.txt. Gate C's env ratchet is
now compiler-enforced (the hygiene-script header says so); the two shrunk file counts
(nvenc_cuda 49→2 via the test helpers, shell/tests 2→1) are lowered in the same commit
per the gate's own rule.

Drop order (the semantic change most likely to bite this codebase): the migration lint
`-W tail-expr-drop-order` reports zero findings on the macOS-visible halves of
pf-encode / pf-zerocopy / pf-capture / pf-frame; the Linux and Windows halves run the
same lint on the gate boxes. The four #[ignore]d alloc/drop-cycle tests on the hardware
boxes remain owed, as before this change.
2026-08-12 16:12:35 +02:00
enricobuehler 28b6633058 Merge pull request 'feat(display): edid_lock policy axis — pin AMD connector EDID emulation while streaming' (#176) from worktree-edid-lock-toggle into main
audit / bun-audit (sdk) (push) Successful in 25s
audit / bun-audit (plugin-kit) (push) Successful in 26s
audit / bun-audit (web) (push) Successful in 23s
audit / docs-site-audit (push) Successful in 22s
audit / pnpm-audit (push) Successful in 13s
apple / swift (push) Successful in 1m36s
audit / license-gate (push) Successful in 4m52s
ci / bun-nix (push) Successful in 23s
audit / miri (push) Successful in 5m50s
apple / screenshots (push) Successful in 5m56s
ci / web (push) Successful in 3m49s
ci / rust-arm64 (push) Successful in 4m6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 16s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 23s
ci / docs-site (push) Successful in 3m50s
audit / c-abi-asan (push) Successful in 5m55s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 16s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 15s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 15s
deb / build-publish-client-arm64 (push) Successful in 2m56s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 2m38s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m54s
audit / cargo-audit (push) Successful in 36s
deb / build-publish-host (push) Successful in 8m38s
release / apple (push) Successful in 9m55s
deb / build-publish (push) Successful in 6m27s
docker / deploy-docs (push) Successful in 37s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Failing after 14m53s
docker / builders-arm64cross (push) Skipped
android / android (push) Successful in 15m34s
ci / rust (push) Failing after 14m34s
flatpak / build-publish (push) Successful in 20m11s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m1s
nix / flake (push) Failing after 20m46s
arch / build-publish (push) Successful in 21m46s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m45s
windows-host / package (push) Successful in 38m25s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 29s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 5m20s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m17s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m45s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 4m20s
2026-08-12 13:12:11 +00:00
enricobuehler c58217e403 Merge pull request 'fix(client): stop the double-arm race re-freezing RFI-healed streams' (#174) from worktree-gate-double-arm-fix into main
android / android (push) Canceled after 0s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
audit / cargo-audit (push) Canceled after 0s
audit / bun-audit (plugin-kit) (push) Canceled after 0s
audit / bun-audit (sdk) (push) Canceled after 0s
audit / bun-audit (web) (push) Canceled after 0s
audit / docs-site-audit (push) Canceled after 0s
audit / pnpm-audit (push) Canceled after 0s
audit / license-gate (push) Canceled after 0s
audit / miri (push) Canceled after 0s
audit / c-abi-asan (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-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 0s
nix / flake (push) Canceled after 0s
release / apple (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
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
2026-08-12 13:11:51 +00:00
enricobuehler 23f9b1130e Merge pull request 'Black-stream fixes: CCD restore snapshot pruning, iOS silent-black recovery, devnode journal retention' (#175) from worktree-black-stream-fixes into main
android / android (push) Canceled after 0s
apple / swift (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-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
release / apple (push) Canceled after 24s
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
2026-08-12 13:11:29 +00:00
enricobuehler c2c71f0ac5 Merge pull request 'fix(vdisplay/driver,pf-frame): no punktfunk process holds REALTIME GPU priority by default' (#173) from worktree-rt-gpu-priority-defaults into main
android / android (push) Canceled after 0s
apple / swift (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-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
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
windows-drivers / probe-and-proto (push) Successful in 24s
windows-drivers / driver-build (push) Successful in 1m54s
2026-08-12 13:11:02 +00:00
enricobuehler 6a506a8fa9 fix(vdisplay/driver,pf-frame): no punktfunk process holds REALTIME GPU priority by default
windows-drivers / probe-and-proto (pull_request) Successful in 30s
ci / bun-nix (pull_request) Successful in 56s
ci / docs-site (pull_request) Successful in 1m16s
ci / web (pull_request) Successful in 1m17s
ci / rust-arm64 (pull_request) Successful in 1m31s
windows-drivers / driver-build (pull_request) Successful in 1m47s
android / android (pull_request) Successful in 4m40s
ci / rust (pull_request) Successful in 9m54s
apple / swift (pull_request) Failing after 13m27s
apple / screenshots (pull_request) Skipped
The RX 9070 XT field A/B (2026-08-11/12 logs) convicted BOTH of our REALTIME
GPU-scheduling levers of generating the metronomic capture-stall class the
stall program has chased for weeks — compose-silence holes of 150-800 ms in
which ETW shows NO process presenting while the GPU stays responsive:

- the vdisplay driver's IddCxSetRealtimeGPUPriority raise beat at ~1.75-1.78 s
  (PFVD_NO_RT_GPU=1 alone removed that metronome: ~0.35 stalls/s metronomic ->
  10 sparse aperiodic over 3.9 min);
- the host auto-gate's HIGH->REALTIME upgrade (pf-frame dxgi.rs, T2.3) beat at
  ~3.58 s in the AV1 sessions where it promoted (vram_pct=1, 12:59:26); pinning
  PUNKTFUNK_GPU_PRIORITY_CLASS=high removed that residual too (13:45 session:
  zero metronomic, stall rate at the clean-run baseline).

Neither period matches any punktfunk clock: the full periodic-actor census
(driver: event-paced drain + 16 ms E_PENDING wait, 33 ms cursor poll, 3 s
watchdog reap; host: 250 ms descriptor poll, 5/50/100 ms probes + ~2 s scanline
retarget, 2 s VRAM gate, 2 s exclusive re-assert, 3.33 s pinger, 1 s stats,
~1 Hz phase-lock, fps/2 LTR marks) has nothing in the 1.69-2.29 s band, and
every host-side actor ran unchanged in the A/B that killed the fast metronome.
The periodicity is emergent from holding an unreachable-priority queue against
the WDDM scheduler on this AMD family (the period even differs by which of our
processes holds REALTIME); it is not a punktfunk cadence being amplified, so
there is nothing punktfunk-periodic to fix - the fix is to stop holding
REALTIME by default, which is also canonical parity (no shipping IDD raises
it, and HIGH was the class that delivered the original Sunshine-parity encode
win).

- Driver: PFVD_NO_RT_GPU (default-ON, opt-OUT) becomes the PFVD_RT_GPU ladder,
  default OFF on every vendor: unset = no raise (canonical IDD behavior);
  =thread = SetGPUThreadPriority(+7), a graduated in-band middle rung for field
  A/B (not default: unmeasured here, and the host measured the same call as "no
  help" for its own starvation case); anything else = the old REALTIME DDI.
  PFVD_NO_RT_GPU stays recognized and WINS over the opt-in, so the field boxes
  that carry it through the default-ON era keep meaning OFF. Both directions
  remain A/B-able without a rebuild (machine env + device restart). The CPU
  half of the original branch-2 hardening (MMCSS / TIME_CRITICAL) is untouched
  - it addressed the delivery holes that were actually observed.
- Host: PUNKTFUNK_GPU_PRIORITY_CLASS default auto -> high. `auto` (the gated
  REALTIME upgrade) stays available as an explicit opt-in, `realtime` still
  pins; unrecognized values now land on the HIGH default instead of silently
  opting into the gate - a typo must not buy the hazard. The VRAM/HAGS gate
  machinery is unchanged for `auto`; it guards the NVENC-hang hazard but cannot
  see this one.
- stall.rs: the no-OS-event METRONOMIC warning now carries rt_gpu_driver /
  rt_gpu_host fields (the machine-env state of both levers) and names clearing
  them as the FIRST cure, ahead of the display-hardware suspects - a field log
  self-answers the triage question this program just spent a week on.

No console policy axis for the driver knob: the lever is default-safe now, the
driver reads config at WUDFHost scope where machine env already matches the
device-restart lifecycle, and a policy axis would need pf-driver-proto churn
(or a device-key registry write) for an experimental lever that only exists to
be A/B-ed. If the `thread` rung ever proves out as a default-worthy raise,
that is the moment to revisit.
2026-08-12 13:57:20 +02:00
enricobuehler 712ee935d6 fix(win-display): a failed devnode re-enable must keep its crash-journal entry
ci / rust-arm64 (pull_request) Successful in 1m36s
ci / docs-site (pull_request) Successful in 1m22s
apple / swift (pull_request) Successful in 1m43s
apple / screenshots (pull_request) Skipped
ci / bun-nix (pull_request) Successful in 3m34s
ci / web (pull_request) Successful in 4m2s
android / android (pull_request) Successful in 4m13s
ci / rust (pull_request) Successful in 5m56s
enable_instances cleared every requested id from the pnp-disabled-monitors
journal unconditionally — a mid-life re-enable failure erased its own
crash-recovery entry, so neither the running host nor the next start would
ever retry, leaving the operator's monitor disabled (invisible to Windows and
every display listing) until a manual Device Manager fix. Field 2026-08-12
(Android-client host running the pnp_disable_monitors axis): displays gone
from the client after 2-3 sessions, only a full host restart bringing them
back — the restart path only works while the journal still has the entry.

Only successfully re-enabled ids clear now; failures stay journaled (and are
logged with the consequence) so startup_recover retries them.
2026-08-12 13:30:04 +02:00
enricobuehler a2aa0a5f97 fix(apple): never sit black AND silent while the video format is unseeded
Both pumps (Stage2Pipeline + the stage-1 StreamPump) dropped every AU in a
silent guard while 'format' was nil — the opening IDR's parameter sets never
arrived or never parsed, and under the host's infinite GOP nothing re-delivers
them unless the client asks. Field 2026-08-12 (iOS, H265): sessions decoding
nothing, requesting nothing, host streaming perfectly — a black stream with
zero recovery requests, randomly self-healing only when some later event
produced a fresh IDR.

While format is nil after the first received AU, the pumps now set awaitingIDR
(routing through the existing 100 ms-throttled recovery.request()) and log the
state once, so a lost/unparsed opening IDR converts to a normal recovery
round-trip instead of a permanent silent black.
2026-08-12 13:16:58 +02:00
enricobuehler d6b9862f1e fix(win-display): prune unplugged targets from the CCD restore snapshot
A monitor unplugged mid-session left the teardown replaying a snapshot whose
paths reference an absent target — SetDisplayConfig rejects the WHOLE array
with 0x57 ERROR_INVALID_PARAMETER, nothing restores, the desk stays dark, and
the next session snapshots the wreckage (the poisoned-snapshot chain; field
2026-08-12: rc=0x57 across a mid-session unplug, then sessions flipping
between black and working at random, incl. one that genuinely encoded black).

The restore now prunes the snapshot against a live QDC_ALL_PATHS availability
sweep before replaying: stale paths drop, the mode table is rebuilt with only
the entries the survivors reference (an orphaned mode entry is itself an
0x57), and an empty survivor set skips the apply so the dark-desk backstop
decides. Verbatim behavior is unchanged when everything is still attached or
when the availability query itself fails.
2026-08-12 13:15:02 +02:00
enricobuehler 2a62fe7857 fix(client): stop the double-arm race re-freezing RFI-healed streams
ci / bun-nix (pull_request) Successful in 30s
ci / docs-site (pull_request) Successful in 1m22s
ci / rust-arm64 (pull_request) Successful in 1m42s
ci / web (pull_request) Successful in 1m51s
apple / swift (pull_request) Successful in 1m50s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m28s
android / android (pull_request) Successful in 6m39s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 13m7s
ci / rust (pull_request) Successful in 14m4s
Every unrecoverable loss armed the client's freeze gate twice: instantly at
frame-index-gap detection (which fires the RFI), and ~120 ms later when the
reassembler ages the lost frame into frames_dropped and poll() re-armed
unconditionally. An LTR-RFI recovery anchor lands in ~60 ms — between the two
signals — so the stale climb re-froze a bit-exact-healed stream, the host
swallowed the re-ask as an RFI echo, and the picture stayed frozen until the
overdue backstop extracted a full IDR: the field 'H265 freezes on every loss,
AV1 fine' signature on AMD hosts (AMF is the only LTR-RFI backend; the slower
IDR path usually lands after the climb and dodged the race).

The gap-arm now pre-credits the expected climb (ReanchorGate::arm_expecting_drops;
credit expires after DROP_CREDIT_WINDOW so a straggler-filled gap can't mask a
later real loss), and poll() consumes credited climbs instead of re-arming.
Plumbed through every embedder: pf-client-core's session pump, Android's
sync/async loops (note_frame_index now returns the gap width), and the Swift
client via new ABI exports punktfunk_connection_note_frame_index_ex +
punktfunk_reanchor_gate_arm_expecting_drops (additive; the bool ABI stays).
2026-08-12 08:11:35 +02:00
enricobuehler 66ba61b12c Merge pull request 'Safety round 2: ASAN+LSAN over the C ABI boundary, two soundness fixes, and WP4's AvFrame RAII' (#172) from worktree-safety-round-2 into main
audit / bun-audit (plugin-kit) (push) Successful in 24s
audit / bun-audit (sdk) (push) Successful in 24s
audit / bun-audit (web) (push) Successful in 27s
audit / cargo-audit (push) Successful in 34s
audit / docs-site-audit (push) Successful in 26s
apple / swift (push) Successful in 1m46s
audit / pnpm-audit (push) Successful in 1m16s
ci / rust-arm64 (push) Successful in 1m35s
ci / web (push) Successful in 1m25s
ci / docs-site (push) Successful in 1m22s
ci / bun-nix (push) Successful in 23s
audit / miri (push) Successful in 5m10s
audit / license-gate (push) Successful in 7m0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
android / android (push) Successful in 8m2s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
deb / build-publish-client-arm64 (push) Successful in 2m34s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
audit / c-abi-asan (push) Successful in 7m51s
arch / build-publish (push) Successful in 8m42s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 16s
deb / build-publish (push) Successful in 5m49s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m25s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m39s
docker / builders-arm64cross (push) Successful in 10s
docker / deploy-docs (push) Failing after 1m4s
deb / build-publish-host (push) Successful in 8m54s
release / apple (push) Successful in 12m10s
windows-host / package (push) Successful in 13m41s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 20s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m19s
apple / screenshots (push) Successful in 5m52s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m0s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m14s
ci / rust (push) Successful in 13m52s
flatpak / build-publish (push) Successful in 15m17s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m54s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m39s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m41s
Reviewed-on: #172
2026-08-12 05:57:16 +00:00
enricobuehler 5002849737 feat(pf-encode): WP4 — AvFrame/AvSwsContext RAII across all three libav backends
ci / bun-nix (pull_request) Successful in 31s
ci / docs-site (pull_request) Successful in 1m17s
ci / rust-arm64 (pull_request) Successful in 1m45s
apple / swift (pull_request) Successful in 1m49s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 2m7s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m56s
android / android (pull_request) Successful in 6m21s
ci / rust (pull_request) Successful in 7m42s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 12m28s
Two newtypes beside AvBuffer/AvFilterGraph (same house shape: alloc/from_raw
rejects the allocator's null once, as_ptr lends, Drop frees, no Clone; NonNull
inside so the Options get a niche). 8 av_frame_alloc + 3 sws_getContext sites
converted; all 22 hand-placed av_frame_free and 5 sws_freeContext calls are
gone, and the three hand-written Drop impls (CpuInner, SystemInner,
NvencEncoder) with them.

The live defect this closes: ZeroCopyInner::submit (ffmpeg_win) leaked the
frame AND one pooled hwframe surface on each of three ? exits between the
pool pull and the send — under a SAFETY comment asserting no leak — and with
POOL=8, eight such failures starved the pool and wedged the encoder with no
error naming the cause. Every exit now returns the surface.

Drop-order care (the hidden cost the survey flagged): NvencEncoder's sws_csc
moved to field #1 (its hand-Drop freed it before all fields; this path runs
on every stall-watchdog recovery via *self = fresh); CpuInner's nv12/sws
declaration order flipped to match its hand-Drop; SystemInner's already
agreed. Pinned by FIELD ORDER comments, not offset_of asserts — the survey's
assert suggestion is the wrong tool: offset_of measures repr(Rust) memory
layout, which the compiler may reorder independently of the declaration
order that drop order actually follows.

The dmabuf path keeps its early descriptor release via an explicit drop()
at the exact point the hand-written free sat.

Gates: .25 clippy -D warnings + tests green (nvenc,vulkan-encode,pyrowave);
.133 check --all-targets + clippy --release -D warnings + 80 tests green
(nvenc,amf-qsv,qsv; test step needs ffmpeg\bin on Path — 0xC0000135
otherwise). Owed on hardware: the #[ignore]d alloc/drop cycles on
.136/.116/.173/.47 and the pool-exhaustion assertion (9th submit succeeds
after 8 forced failures).
2026-08-12 00:31:14 +02:00
enricobuehler 9a59504ba4 fix(punktfunk-core): validate InputKind before forming &InputEvent in the C ABI
abi.rs's two send-input entry points built &InputEvent straight out of
caller memory with ev.as_ref(); InputKind is repr(u8) with 16 valid
discriminants, so a C embedder writing ev->kind = 42 was immediate UB the
moment the reference formed — in a file whose stated principle is that
failures become status codes. New read_input_event() checks null, reads
the tag as a raw byte, validates through the same InputKind::from_u8 the
wire path uses, and only then forms the reference; bad tags return
InvalidArg. Every other field is a plain integer, valid for any pattern.

Test stages the event in MaybeUninit storage so the test itself never
holds a reference to the invalid value. 380 lib tests + the C harness
round-trip + clippy -D warnings green on .25; header regenerated.
2026-08-12 00:12:01 +02:00
enricobuehler e8c306b9c0 fix(punktfunk-host): WP3c/3d — align the TOKEN_USER buffer, make EqualSid fail closed
3c: forming &TOKEN_USER (align 8) out of a bare [u8; 256] (align 1) was UB
by the validity rule whenever the stack slot landed misaligned — shipped
codegen happened to 8-align it, which is luck, not a contract. Fixed with
a repr(align(8)) wrapper that keeps the buffer at 256 BYTES; the comment
records why [u64; 32] is the wrong shape (len() would silently become 32
and misclassify every hand-run host as SYSTEM via ERROR_INSUFFICIENT_BUFFER,
invisibly to a SYSTEM-side test). Length arg now size_of_val.

3d: EqualSid().is_ok() read BOTH 'SIDs differ' and 'EqualSid failed' as
Err, so a genuine failure yielded 'not SYSTEM' — the fail-OPEN direction,
contradicting the documented fail-closed contract. Now split three ways on
the last-error code, with SetLastError(0) cleared first so a stale value
cannot misclassify.

Gate: cargo check -p punktfunk-host + cargo clippy --release -D warnings
both green on .133 (real MSVC, fresh extraction, sentinel-verified).
2026-08-12 00:12:00 +02:00
enricobuehler c3b57438e1 chore(ci): c-abi-asan job in audit.yml — the harness under ASAN+LSAN, weekly + on demand
Same shape as the miri job (dated nightly, own san- cache prefixes,
non-blocking day one via a step-level ::warning::, a proved-it-ran grep).
run.sh gains PF_SAN_TOOLCHAIN so CI can pin its dated nightly — bare
+nightly would ask for the rolling channel the job never installs. Both
the pinned and vanilla paths re-verified green on .25.
2026-08-12 00:02:26 +02:00
enricobuehler e20b614059 chore(safety): PF_SAN sanitizer gate for the C ABI harness
PF_SAN=address builds the punktfunk-core staticlib on nightly with
-Zsanitizer/-Zbuild-std and the C harness with clang -fsanitize, so ASAN
instruments both sides of the boundary at once and LSAN (detect_leaks=1)
becomes the first automated check on abi.rs's Box::into_raw/from_raw leak
contract. Verified on the .25 box: green run passes byte-exact; deleting
one punktfunk_session_free() in the harness makes LSAN report the 308
Rust-side allocations behind the handle and the script exit 1.

The harness binary moves from mktemp to target/ — a debug+ASAN static
binary can exceed a tmpfs /tmp (it did, on .25's 3.6G tmpfs).
2026-08-11 23:58:06 +02:00
450 changed files with 37269 additions and 14582 deletions
+19 -19
View File
@@ -4,12 +4,21 @@
# or an accepted, documented risk. Keep this list TIGHT and justify every entry — an ignore here
# means the audit job stops flagging it, so the reasoning must hold up.
#
# NOTE: `cargo audit` (no `--deny warnings`) fails only on *vulnerabilities*, not on the
# `unmaintained` warnings (audiopus_sys via opus, paste via utoipa-axum). Both are transitive, at
# their latest published version with no successor, so there's nothing to bump — left visible on
# purpose so we keep getting the maintenance signal; they do not fail CI. (rustls-pemfile was dropped
# 2026-06-29 by removing axum-server's unused tls-rustls feature + moving our own PEM parsing to
# rustls-pki-types; memmap2's unsoundness was fixed by the 0.9.11 bump.)
# NOTE: `cargo audit` (no `--deny warnings`) fails only on *vulnerabilities* — `unmaintained` AND
# `unsound` advisories are warnings that do NOT fail CI. That is deliberate for the two unmaintained
# crates below, but it does mean an unsoundness can sit here unnoticed: RUSTSEC-2026-0221
# (event-listener) did exactly that until the 2026-08-13 sweep. Read the job's warnings, not just
# its exit code.
#
# The two unmaintained ones, both transitive with no successor to bump to, left visible on purpose
# so we keep getting the maintenance signal:
# * audiopus_sys via opus (opus itself IS maintained; only its -sys layer is stuck).
# * paste via BOTH utoipa-axum (host) and rav1d (client decode path) — an earlier version of this
# note named only utoipa-axum, which would have made dropping utoipa-axum look like it cleared
# paste. It would not: every client pulls it through rav1d.
# (rustls-pemfile was dropped 2026-06-29 by removing axum-server's unused tls-rustls feature +
# moving our own PEM parsing to rustls-pki-types; memmap2's unsoundness was fixed by the 0.9.11
# bump.)
[advisories]
ignore = [
@@ -34,17 +43,8 @@ ignore = [
# a constant-time rsa ships (then drop this), the host ever signs an attacker-chosen message with
# this key, or any RSA decryption / key-transport using the private key is added.
"RUSTSEC-2023-0071",
# quick-xml DoS advisories (RUSTSEC-2026-0194 quadratic-time duplicate-attribute check;
# RUSTSEC-2026-0195 unbounded namespace-declaration allocation in NsReader). Both are
# exploited by feeding attacker-controlled XML to a running parser. In this tree quick-xml is
# a BUILD-TIME-ONLY, transitive dependency of `wayland-scanner` (a proc-macro that parses the
# TRUSTED wayland protocol XML files shipped with the wayland-rs crates at compile time). It is
# never linked into any shipped binary and never parses runtime/attacker-controlled input, so
# neither DoS is reachable. There is no fix to bump to: wayland-scanner 0.31.10 (latest) pins
# `quick-xml ^0.39`, and the fixes only exist in quick-xml >=0.41. Revisit (drop these) when
# wayland-scanner releases against quick-xml >=0.41, or if quick-xml is ever pulled onto a
# runtime path that parses untrusted XML.
"RUSTSEC-2026-0194",
"RUSTSEC-2026-0195",
# The quick-xml DoS pair (RUSTSEC-2026-0194/0195) used to be ignored here, with the note
# "revisit when wayland-scanner releases against quick-xml >=0.41". It has: wayland-scanner
# 0.31.11 moved to `quick-xml ^0.41` and the lock is on 0.41.0 as of 2026-08-13, so both
# entries were dropped rather than left as permanent exceptions.
]
+36 -15
View File
@@ -1,18 +1,39 @@
# Workspace-wide build flags.
#
# aes_armv8: RustCrypto's `aes` 0.8.x enables ARMv8-Crypto hardware AES on aarch64 only behind
# this cfg (x86_64 AES-NI is runtime-detected with no flag; the 0.9 line will make aarch64
# automatic too). Without it every aarch64 client (all Apple + virtually all Android) ran
# SOFTWARE AES on the per-packet decrypt path — measured 2026-07-14 on an M3 Ultra at
# ~240 MiB/s/core (~7 µs per 1.4 KB datagram), which single-handedly capped receive throughput
# at ~1.57 Gbps wire. The cfg still runtime-detects via `cpufeatures`, so a chip without the
# extensions falls back safely.
# THERE ARE DELIBERATELY NONE. This file is kept as a tombstone so the aarch64 AES cfgs are not
# reintroduced — read this before adding rustflags here.
#
# NOTE: a RUSTFLAGS environment variable OVERRIDES config rustflags entirely — build scripts /
# CI lanes that set RUSTFLAGS for aarch64 targets (cargo-ndk, xcframework) must carry
# `--cfg aes_armv8` themselves.
# polyval_armv8: same story for GCM's other half — `polyval` 0.6.x gates its PMULL (carry-less
# multiply) GHASH path behind this cfg on aarch64. AES alone took open_in_place from 240 to
# ~790 MiB/s on the M3 Ultra; software GHASH still dominated until this flag joined it.
[target.'cfg(target_arch = "aarch64")']
rustflags = ["--cfg", "aes_armv8", "--cfg", "polyval_armv8"]
# Until 2026-08-13 this file carried:
#
# [target.'cfg(target_arch = "aarch64")']
# rustflags = ["--cfg", "aes_armv8", "--cfg", "polyval_armv8"]
#
# because RustCrypto's `aes` 0.8.x enabled the ARMv8-Crypto hardware AES backend on aarch64 ONLY
# behind `--cfg aes_armv8`, and `polyval` 0.6.x gated its PMULL (carry-less multiply) GHASH path
# behind `--cfg polyval_armv8`. That was a live footgun, not just boilerplate: a RUSTFLAGS
# ENVIRONMENT VARIABLE OVERRIDES CONFIG RUSTFLAGS ENTIRELY — it does not merge and does not
# append — so every aarch64 lane that set its own RUSTFLAGS silently dropped both and fell back to
# SOFTWARE AES on the per-packet decrypt path. cargo-ndk sets RUSTFLAGS internally for its linker
# configuration, which means every Android arm64-v8a build was hitting exactly that.
#
# `aes` 0.9 removed the cfg: on aarch64 it runtime-detects with `cpufeatures::new!(features_aes,
# "aes")` (lib.rs), the same way x86_64 AES-NI always did. `polyval` 0.7 likewise selects
# `backend/intrinsics/armv8.rs` by `target_arch` alone. Neither cfg exists any more — passing them
# is inert.
#
# Measured here before deleting them, `crypto/open_in_place` from benches/pipeline.rs (one 1408-byte
# MTU shard, AES-128-GCM, single core, Mac15,14 M3 Ultra, all four runs back to back under the same
# background load):
#
# aes 0.8 + both cfgs 2.19 GiB/s <- what the cfgs bought
# aes 0.8, cfgs stripped 225 MiB/s <- the footgun: ~10x slower, software AES
# aes 0.9 + both cfgs 5.28 GiB/s
# aes 0.9, cfgs stripped 5.28 GiB/s <- identical to 4 s.f.; the cfgs do nothing
#
# The ChaCha20-Poly1305 series of the same bench was the control: it moved 0.07% across the cfg
# toggle at both versions, confirming the toggle reached only the AES path.
#
# So 0.9 without the cfgs is not merely as fast as 0.8 with them — it is ~2.4x faster, and ~24x
# the software fallback. Do not re-add these flags; if a future aarch64 slowdown is suspected,
# re-run `cargo bench -p punktfunk-core --bench pipeline -- in_place` and compare against the
# table above rather than reaching for a cfg.
+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
+54 -14
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
@@ -63,6 +82,15 @@ env:
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# The C/C++ half of the cache. The one that pays here is the CMake-built vendored libopus
# (audiopus_sys), which kit/build.gradle.kts drives through cargo-ndk once per ABI — three
# from-scratch libopus builds per run until now. The per-ABI compilers come from the NDK via
# cargo-ndk's own CC_<android-triple> vars, which this does not touch; CC_x86_64_unknown_linux_gnu
# covers only the HOST build scripts and proc macros.
CMAKE_C_COMPILER_LAUNCHER: sccache
CMAKE_CXX_COMPILER_LAUNCHER: sccache
CC_x86_64_unknown_linux_gnu: sccache cc
CXX_x86_64_unknown_linux_gnu: sccache c++
# sccache and incremental compilation are mutually exclusive; CI wants the shared
# cache, dev boxes keep incremental.
CARGO_INCREMENTAL: "0"
@@ -85,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: |
@@ -213,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.
@@ -231,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)
@@ -265,11 +299,13 @@ jobs:
retention-days: 30
# Publish BEFORE the Play upload so artifacts land even while the Play step is still failing.
# Generic registry is public for reads — matches windows-msix.yml / deb.yml (REGISTRY_TOKEN, user enricobuehler).
# Generic registry is public for reads — matches windows-client.yml / deb.yml (REGISTRY_TOKEN, user enricobuehler).
# 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
@@ -303,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
@@ -311,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 "$@"
+600 -51
View File
@@ -1,12 +1,86 @@
# Apple client CI runs on the self-hosted macOS runner (home-mac-mini-1, host mode;
# see scripts/ci/setup-macos-runner.sh). Builds the Rust core into
# PunktfunkCore.xcframework, then builds + tests the Swift package. Network-dependent
# tests (RemoteFirstLightTests) self-skip without PUNKTFUNK_REMOTE_HOST.
# Apple client CI **and** distribution — everything that runs on the self-hosted macOS runner
# (home-mac-mini-1, host mode; see scripts/ci/setup-macos-runner.sh), in dependency order:
#
# A second job (`screenshots`) captures the App Store Connect screenshots of the REAL UI
# (mac window + iOS/iPad/tvOS Simulators, see clients/apple/tools/screenshots.sh) and attaches
# them to the run as a single zip artifact (`punktfunk-appstore-screenshots`). It is isolated
# from the build/test job and best-effort, so a capture gap never reds the core signal.
# swift — build the Rust core into PunktfunkCore.xcframework, then build + test the Swift
# package. Network-dependent tests (RemoteFirstLightTests) self-skip without
# PUNKTFUNK_REMOTE_HOST. Runs on pushes, tags AND pull requests.
# distribute — needs: swift. The signed/notarized artifacts:
# macOS (Developer ID) -> sandboxed, signed, notarized + stapled .dmg, attached
# to the Gitea release on tag pushes
# macOS (App Store) -> archive + upload to TestFlight (App Store Connect)
# iOS -> archive + upload to TestFlight, plus an exported .ipa
# tvOS -> archive + upload to TestFlight (Rust core built from tier-3 targets,
# nightly -Zbuild-std, in build-xcframework.sh)
# screenshots — needs: swift. App Store Connect screenshots of the REAL UI, attached to the run
# as a zip artifact. Best-effort, so a capture gap never reds the core signal.
#
# ⚠ WHY THIS FILE IS ONE FILE. `distribute` used to live in its own workflow called `release.yml` —
# a name that described neither what it did (Apple only) nor how releases actually work here (every
# platform's packaging workflow attaches to the same Gitea release on a v* tag, and announce.yml is
# the manual "go"). The name was the smaller problem. The real one: Gitea has no cross-workflow
# `needs`, so nothing sequenced it against apple.yml's tests — a canary main push uploaded iOS,
# macOS and tvOS builds to TestFlight even when `swift test` had just failed on the same commit,
# and the two files' `paths:` filters had already drifted apart, so it was possible for one to fire
# without the other. Merging is what makes `needs: swift` expressible. Do not split them again.
#
# The trigger list is deliberately NARROW on crates/: everything here is built from
# `crates/punktfunk-core` (via scripts/build-xcframework.sh) and nothing else in the workspace.
# VERIFY THAT BEFORE WIDENING OR TRUSTING IT — punktfunk-core's only path dependency is its own
# vendored fec-rs, under crates/punktfunk-core/vendor/:
# sed -n '/^\[dependencies\]/,/^\[/p' crates/punktfunk-core/Cargo.toml | grep path
# If punktfunk-core ever gains a path dep on a sibling crate, add that crate here. Cargo.lock is a
# partial safety net (it moves when the dep is ADDED) but not a complete one — later edits to that
# crate would not fire this workflow. This is the same class of gap flatpak.yml documents.
#
# ── Signing / distribution notes (all of these belong to `distribute`) ────────────────────────────
#
# One App Store listing for all platforms (universal purchase): every target shares the
# bundle ID io.unom.punktfunk.
#
# The macOS app is App-SANDBOXED for both channels (Config/Punktfunk-macOS.entitlements —
# app-sandbox + network client/server + audio-input + bluetooth/usb device access; the
# shared Config/Punktfunk.entitlements stays iOS/tvOS-only, where app-sandbox is invalid).
# The Developer ID DMG is codesigned with the SAME macOS entitlements as the App Store build,
# BUT it must ALSO embed a Developer ID provisioning profile: keychain-access-groups is a
# MANAGED entitlement that AMFI only honors when an embedded profile authorizes it. A DMG
# without one is SIGKILLed at spawn ("Launchd job spawn failed", POSIX errno 163) even though
# it is validly signed AND notarized. ⌘R hides this (Xcode embeds a development profile); the
# raw Developer ID codesign path does NOT, so ⌘R is NOT equivalent to the shipped DMG here.
#
# macOS App Store prerequisites (one-time, Apple portal — NOT done by this workflow; the
# step is continue-on-error until they exist):
# * App Store Connect: add the macOS platform to the io.unom.punktfunk app record
# (universal purchase).
# * A "Punktfunk macOS App Store Distribution" provisioning profile installed on the
# runner (under ~/Library/Developer/Xcode/UserData/Provisioning Profiles/).
# * The "3rd Party Mac Developer Installer" (Mac Installer Distribution) certificate in
# the runner's login keychain, in addition to "Apple Distribution" — the App Store
# .pkg is installer-signed with it.
#
# macOS Developer ID (DMG) prerequisite (one-time, Apple portal — the DMG step embeds it):
# * A "Punktfunk macOS Developer ID" provisioning profile (Distribution -> Developer ID,
# App ID io.unom.punktfunk, with the Keychain Sharing capability) installed on the runner
# under ~/Library/Developer/Xcode/UserData/Provisioning Profiles/. It authorizes the
# managed keychain-access-groups entitlement; without it the DMG is SIGKILLed at launch
# (errno 163). If it is missing the DMG step warns and strips that entitlement (the app
# then uses ClientIdentityStore's legacy file-keychain fallback) so the build still ships
# a launchable app.
#
# Signing setup (NOT secret-based anymore): the runner is a LaunchAgent in the user's
# logged-in Aqua session, so it uses the **login keychain** directly. Install the signing
# identities there once via Xcode (Settings -> Accounts -> Manage Certificates): Developer
# ID Application + Apple Distribution, with the WWDR intermediate present (so they show as
# *valid*). xcodebuild/codesign then sign exactly like a local build — no throwaway keychain.
# One-time, to avoid headless "codesign wants to use the key" prompts, grant codesign access:
# security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k <login-pw> \
# ~/Library/Keychains/login.keychain-db
#
# Secrets: only ASC_API_KEY_P8 / ASC_API_KEY_ID / ASC_API_ISSUER_ID (App Store Connect API
# key — notarization, TestFlight upload, automatic-signing profile fetch).
#
# Needs a RELEASE Xcode on the runner (App Store rejects beta-SDK builds); the workflow
# picks the first non-beta /Applications/Xcode*.app and only falls back to a beta with a
# loud warning.
name: apple
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
@@ -19,30 +93,39 @@ concurrency:
on:
push:
# Canary: a relevant main push builds + tests, then uploads the iOS + macOS + tvOS builds to
# TestFlight (Apple's own canary channel) — no notarized DMG (that's stable-only; see the
# per-step gates). Heavy on the shared mac-mini runner, hence the tight paths filter.
branches: [main]
# Scope canary builds to what this artifact is built FROM — a docs-only or
# web-only push should not light up the whole fleet. Applies to branch pushes;
# tag runs are matched by `tags:` (proven by flatpak/windows-msix releases).
paths:
- 'crates/**'
- 'crates/punktfunk-core/**'
- 'clients/apple/**'
- 'scripts/build-xcframework.sh'
- 'scripts/ci/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
- 'scripts/ci/**'
- '.gitea/workflows/apple.yml'
# Stable: a `vX.Y.Z` tag is THE release — notarized DMG attached to the unified Gitea Release
# + macOS/iOS/tvOS to TestFlight for manual promotion to the App Store. Tag runs are matched by
# `tags:` and are NOT subject to the paths filter above.
tags: ['v*']
pull_request:
paths:
- 'crates/**'
- 'crates/punktfunk-core/**'
- 'clients/apple/**'
- 'scripts/build-xcframework.sh'
- 'scripts/ci/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
- 'scripts/ci/**'
- '.gitea/workflows/apple.yml'
workflow_dispatch:
inputs:
testflight:
description: "Upload the iOS/macOS/tvOS builds to TestFlight (true/false)"
required: false
default: "true"
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io — the mini resolves it via
# the router, i.e. the hairpin path whose TLS always validated). Covers every cargo/rustc
@@ -61,11 +144,11 @@ env:
jobs:
# SECURITY: builds/tests PULL-REQUEST code on the host-mode, persistent `macos-arm64` runner shared
# with the release-signing job (release.yml, which loads the App Store Connect key). Untrusted PR
# code could persist on it or harvest signing material. Definitive fix is server-side: enable Gitea's
# "require approval for PRs from outside collaborators/forks", and/or isolate PR CI on ephemeral
# runners. The `if:` is a fail-open backstop — it skips fork PRs where Gitea reports the fork flag and
# still runs same-repo PRs (and where the flag is absent), so it never blocks internal PR CI.
# with the release-signing job below (which loads the App Store Connect key). Untrusted PR code could
# persist on it or harvest signing material. Definitive fix is server-side: enable Gitea's "require
# approval for PRs from outside collaborators/forks", and/or isolate PR CI on ephemeral runners. The
# `if:` is a fail-open backstop — it skips fork PRs where Gitea reports the fork flag and still runs
# same-repo PRs (and where the flag is absent), so it never blocks internal PR CI.
swift:
runs-on: macos-arm64
if: >-
@@ -85,17 +168,10 @@ jobs:
dirname "$RUSTUP" >> "$GITHUB_PATH"
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin
# Shared compile cache. ~/.local/bin is on the runner daemon's PATH; GITHUB_PATH is
# belt-and-braces. bsdtar (macOS) globs by default — no --wildcards.
# Shared compile cache. The script handles the macOS side (user-prefix install +
# GITHUB_PATH, bsdtar globbing) — see scripts/ci/ensure-sccache.sh.
- name: sccache (self-healing install)
run: |
if ! command -v sccache >/dev/null; then
mkdir -p "$HOME/.local/bin"
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-aarch64-apple-darwin.tar.gz \
| tar -xz --strip-components=1 -C "$HOME/.local/bin" '*/sccache'
fi
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
sccache --version
run: sh scripts/ci/ensure-sccache.sh
# `punktfunk-core` now decodes Opus in-core for the Apple client (surround), pulling
# `audiopus_sys`, which builds a vendored static libopus via CMake when pkg-config can't find a
@@ -127,24 +203,496 @@ jobs:
working-directory: clients/apple
run: swift test
- name: sccache stats (visibility only)
if: always()
run: sccache --show-stats
# ── Distribution ────────────────────────────────────────────────────────────────────────────────
# `needs: swift` is the entire reason this lives here rather than in its own file: it is what makes
# a failed `swift test` stop a TestFlight upload. Never demote it to a parallel job.
distribute:
needs: swift
# Pushes to main (canary), v* tags (stable) and manual dispatch — never pull requests.
if: gitea.event_name != 'pull_request'
runs-on: macos-arm64
timeout-minutes: 120
env:
TEAM_ID: F4H37KF6WC
PROJECT: clients/apple/Punktfunk.xcodeproj
steps:
- uses: actions/checkout@v4
- name: Select release Xcode
run: |
DEV_DIR=""
for app in /Applications/Xcode.app /Applications/Xcode_*.app /Applications/Xcode-*.app; do
case "$app" in *beta*|*Beta*) continue;; esac
[ -x "$app/Contents/Developer/usr/bin/xcodebuild" ] && DEV_DIR="$app/Contents/Developer" && break
done
if [ -z "$DEV_DIR" ]; then
for app in /Applications/Xcode*.app; do
[ -x "$app/Contents/Developer/usr/bin/xcodebuild" ] && DEV_DIR="$app/Contents/Developer" && break
done
echo "::warning::No release Xcode found — using $DEV_DIR. TestFlight/App Store REJECTS beta-SDK builds."
fi
[ -n "$DEV_DIR" ] || { echo "no usable Xcode found" >&2; exit 1; }
# Scoped to xcodebuild steps only (XCODE_DEV_DIR, not DEVELOPER_DIR): cargo must
# keep the system-default linker — a newer-than-OS Xcode's ld produces dylibs the
# running dyld rejects, killing proc-macro loads (see build-xcframework.sh).
echo "XCODE_DEV_DIR=$DEV_DIR" >> "$GITHUB_ENV"
DEVELOPER_DIR="$DEV_DIR" xcodebuild -version
- name: Version from tag
run: |
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE, PF_CHANNEL, PF_STABLE_TAG (single source of truth)
case "$GITHUB_REF" in
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; V="${V%%-*}" ;; # App Store marketing version is numeric X.Y.Z (drop -rc)
*) V="$PF_BASE" ;; # canary marketing version = one minor ahead of the latest stable tag; the build number disambiguates
esac
echo "VERSION=$V" >> "$GITHUB_ENV"
# GITHUB_RUN_NUMBER is REPO-WIDE in Gitea (not per-workflow as on GitHub): consecutive runs
# of different workflows get consecutive numbers. That is why folding the old release.yml
# into this file could not reset the build number and strand TestFlight, which rejects a
# non-increasing CFBundleVersion. It also means this climbs by ~8 per push rather than by 1
# — monotonic either way, which is all App Store Connect asks.
echo "BUILD_NUM=$GITHUB_RUN_NUMBER" >> "$GITHUB_ENV"
echo "version $V build $GITHUB_RUN_NUMBER (channel $PF_CHANNEL, latest stable ${PF_STABLE_TAG})"
- name: Rust toolchain (mac + iOS + tvOS slices)
run: |
RUSTUP="$(command -v rustup || echo "$HOME/.cargo/bin/rustup")"
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.
"$RUSTUP" toolchain install nightly --profile minimal
"$RUSTUP" component add rust-src --toolchain nightly
# The in-core Opus decode (surround) pulls audiopus_sys, which builds a vendored static libopus
# via CMake — keep the xcframework self-contained (no runtime libopus.dylib on end-user devices).
- name: CMake (for the vendored libopus audiopus_sys builds)
run: |
# Runner steps run with `bash --noprofile --norc`, so Homebrew's bin dir isn't on PATH —
# locate brew explicitly, install cmake if missing, and export its bin dir to GITHUB_PATH so
# the xcframework build step (audiopus_sys → vendored libopus) finds `cmake`.
for B in /opt/homebrew/bin/brew /usr/local/bin/brew; do [ -x "$B" ] && BREW="$B" && break; done
if [ -z "$BREW" ]; then echo "::error::Homebrew not found on the runner"; exit 1; fi
BREW_BIN="$(dirname "$BREW")"; export PATH="$BREW_BIN:$PATH"
command -v cmake >/dev/null || "$BREW" install cmake
echo "$BREW_BIN" >> "$GITHUB_PATH"
# Homebrew's CMake 4 dropped compatibility with the vendored libopus's pre-3.5
# `cmake_minimum_required`; treat 3.5 as the policy minimum (the cmake crate's child cmake
# inherits this from the env during the xcframework build).
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
# Shared compile cache. The script handles the macOS side — see scripts/ci/ensure-sccache.sh.
- name: sccache (self-healing install)
run: sh scripts/ci/ensure-sccache.sh
- name: Pin + prune Xcode DerivedData
# Without -derivedDataPath, xcodebuild derives its DerivedData directory name from the
# PROJECT'S ABSOLUTE PATH — and act_runner rotates its workspace
# (~/.cache/act/<hash>/hostexecutor), so each rotation minted a brand new ~760 MB tree
# under ~/Library that nothing ever collected. 31 of them piled up in three days
# (~32 GB with the shared ModuleCache), filled the runner's boot volume, and failed
# v0.16.0's xcframework build with "No space left on device". Pinning one path makes the
# tree REUSED instead of multiplied — it also keeps the module cache warm between runs.
#
# The directory is still named `release` after the workflow this job used to live in. Left
# alone deliberately: renaming it would orphan a warm ~760 MB tree and buy nothing.
run: |
DD="$HOME/ci/derived-data/release"
mkdir -p "$DD"
echo "DERIVED_DATA=$DD" >> "$GITHUB_ENV"
# Safety net for trees the pin does not own: the legacy per-path ones from before this
# change, and anything another job leaves in the default root. Untouched for a week ⇒ gone.
if [ -d "$HOME/Library/Developer/Xcode/DerivedData" ]; then
find "$HOME/Library/Developer/Xcode/DerivedData" -mindepth 1 -maxdepth 1 \
-mtime +7 -exec rm -rf {} + 2>/dev/null || true
fi
echo "disk after prune:"; df -h /System/Volumes/Data | tail -1
- name: Build PunktfunkCore.xcframework (mac + iOS + tvOS)
# tvOS is a tier-3 target (nightly -Zbuild-std): slow on the first build, then cached on
# the self-hosted runner. Built on canary too so the tvOS archive/upload below runs on the
# same track as iOS/macOS (the nightly toolchain is installed unconditionally above).
#
# This repeats the `swift` job's mac-slice build, and that is the intended trade: the two
# jobs share the runner's sccache and DerivedData, so the overlap is cheap, whereas passing
# an xcframework between jobs would mean uploading/downloading it through Gitea's artifact
# backend (the one that already forces upload-artifact@v3) on every run.
run: BUILD_IOS=1 BUILD_TVOS=1 bash scripts/build-xcframework.sh
- name: Stage App Store Connect API key
env:
ASC_P8: ${{ secrets.ASC_API_KEY_P8 }}
run: |
printf '%s' "$ASC_P8" > "$RUNNER_TEMP/asc.p8"
chmod 600 "$RUNNER_TEMP/asc.p8"
- name: macOS — archive, codesign Developer ID, notarize, DMG
# Stable releases only — the notarized DMG is a Gatekeeper/direct-download artifact, not
# relevant to TestFlight testers (the canary channel). Skipped on canary main pushes.
if: startsWith(gitea.ref, 'refs/tags/v')
run: |
# Archive UNSIGNED, then codesign with the Developer ID Application identity from the
# login keychain. Unsigned archive sidesteps Xcode's keychain-access-groups
# provisioning-profile gate at archive time; we re-assert that authorization below by
# EMBEDDING a Developer ID profile before codesign (see the keychain note further down).
# Bundle is a single static binary.
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
-project "$PROJECT" -scheme Punktfunk \
-destination 'generic/platform=macOS' \
-archivePath "$RUNNER_TEMP/Punktfunk-macos.xcarchive" \
-derivedDataPath "$DERIVED_DATA" \
-skipMacroValidation -skipPackagePluginValidation \
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
CODE_SIGNING_ALLOWED=NO
APP="$RUNNER_TEMP/Punktfunk-macos.xcarchive/Products/Applications/Punktfunk.app"
# Sandboxed Developer ID: sign with the SAME macOS entitlements the App Store build
# uses. codesign won't expand $(AppIdentifierPrefix) — resolve it to the team prefix.
RESOLVED="$RUNNER_TEMP/macos.entitlements"
sed "s/\$(AppIdentifierPrefix)/${TEAM_ID}./g" \
clients/apple/Config/Punktfunk-macOS.entitlements > "$RESOLVED"
# keychain-access-groups is a MANAGED (restricted) entitlement: App Sandbox and the
# network/device keys are self-asserted for Developer ID, but a keychain access group
# must be AUTHORIZED by an embedded provisioning profile. Without one, AMFI refuses to
# spawn the sandboxed process at launch — "Launchd job spawn failed" (POSIX errno 163),
# SIGKILL before main() — even though the bundle is validly signed and notarized. Embed
# a "Developer ID" distribution profile for io.unom.punktfunk (Keychain Sharing) so its
# entitlements authorize the access group, exactly like the App Store build's profile
# does. Located by profile Name among the profiles installed on the runner (see header).
DEVID_PROFILE_NAME="Punktfunk macOS Developer ID"
PROFILE_SRC=""
for p in "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles/"*.provisionprofile \
"$HOME/Library/MobileDevice/Provisioning Profiles/"*.provisionprofile; do
[ -e "$p" ] || continue
NAME=$(security cms -D -i "$p" 2>/dev/null | plutil -extract Name raw - 2>/dev/null || true)
[ "$NAME" = "$DEVID_PROFILE_NAME" ] && PROFILE_SRC="$p" && break
done
if [ -n "$PROFILE_SRC" ]; then
# Must land BEFORE codesign so it's sealed into the bundle.
cp "$PROFILE_SRC" "$APP/Contents/embedded.provisionprofile"
echo "embedded Developer ID profile: $PROFILE_SRC"
else
# Fallback so a missing/expired profile NEVER reships the errno-163 brick: drop the
# managed entitlement and let ClientIdentityStore fall back to the legacy file keychain
# (its errSecMissingEntitlement path). Degraded (one Keychain prompt) but launchable.
echo "::warning::Developer ID profile '$DEVID_PROFILE_NAME' not installed on the runner — stripping keychain-access-groups so the DMG still launches (legacy file keychain). Create it in the Apple portal + install it on the runner to restore the no-prompt data-protection keychain."
/usr/libexec/PlistBuddy -c "Delete :keychain-access-groups" "$RESOLVED" 2>/dev/null || true
fi
codesign --force --options runtime --timestamp \
--entitlements "$RESOLVED" \
--sign "Developer ID Application" "$APP"
codesign --verify --strict --verbose=2 "$APP"
# Notarized DMG.
STAGE="$RUNNER_TEMP/dmg-stage"
mkdir -p "$STAGE"
cp -R "$APP" "$STAGE/"
ln -s /Applications "$STAGE/Applications"
DMG="$RUNNER_TEMP/Punktfunk-$VERSION.dmg"
hdiutil create -volname "Punktfunk" -srcfolder "$STAGE" -ov -format UDZO "$DMG"
DEVELOPER_DIR="$XCODE_DEV_DIR" xcrun notarytool submit "$DMG" --wait \
--key "$RUNNER_TEMP/asc.p8" \
--key-id "${{ secrets.ASC_API_KEY_ID }}" \
--issuer "${{ secrets.ASC_API_ISSUER_ID }}"
DEVELOPER_DIR="$XCODE_DEV_DIR" xcrun stapler staple "$DMG"
echo "DMG=$DMG" >> "$GITHUB_ENV"
- name: Attach DMG to the Gitea release (stable tags only)
if: startsWith(gitea.ref, 'refs/tags/v')
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
. scripts/ci/gitea-release.sh
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
upsert_asset "$RID" "$DMG" "Punktfunk-$VERSION.dmg"
- name: macOS App Store — archive + upload to TestFlight
if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true'
# Best-effort until the App Store Connect record has the macOS platform + the
# "Punktfunk macOS App Store Distribution" profile and the "3rd Party Mac Developer
# Installer" cert are on the runner (see the header). The macOS app is sandboxed
# (Config/Punktfunk-macOS.entitlements) — mandatory for the Mac App Store.
continue-on-error: true
run: |
# Separate archive from the Developer ID one above: App Store needs a signed, entitled
# archive that -exportArchive can re-sign for distribution, not the unsigned-then-codesign
# DMG path. Archive with AUTOMATIC signing (development). Why not a manually-specified
# profile (as this step used to do): the in-app license screens added a SwiftPM resource
# bundle (PunktfunkKit_PunktfunkKit), and a resource bundle is a product type that cannot
# carry a provisioning profile — a global PROVISIONING_PROFILE_SPECIFIER (here) or an
# sdk-scoped one (iOS/tvOS) lands on it and fails the archive ("does not support
# provisioning profiles"). Automatic signing assigns a profile only to the app and leaves
# the resource bundle (and the macOS-host macro plugins) alone, and bakes the sandbox
# entitlements in. -allowProvisioningUpdates lets Xcode sync the App ID capabilities and
# regenerate the managed *development* profile — needed because the App Groups capability
# (group.io.unom.punktfunk, in Config/Punktfunk-macOS.entitlements) invalidated the cached
# one. This is DEVELOPMENT signing against the Apple Development cert already in the
# keychain, so the App-Manager ASC key suffices. DISTRIBUTION signing happens in the export
# step below
# (manual, via the plist). Quit Xcode so it can't prune the manually-installed App Store
# distribution profile that export needs.
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
pkill -x Xcode 2>/dev/null || true
PROFILE="Punktfunk macOS App Store Distribution"
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
-project "$PROJECT" -scheme Punktfunk \
-destination 'generic/platform=macOS' \
-archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \
-derivedDataPath "$DERIVED_DATA" \
-skipMacroValidation -skipPackagePluginValidation \
-allowProvisioningUpdates \
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" \
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
CODE_SIGN_STYLE=Automatic \
DEVELOPMENT_TEAM="$TEAM_ID"
cat > "$RUNNER_TEMP/export-macos-appstore.plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key><string>app-store-connect</string>
<key>destination</key><string>upload</string>
<key>teamID</key><string>$TEAM_ID</string>
<key>signingStyle</key><string>manual</string>
<key>signingCertificate</key><string>Apple Distribution</string>
<key>installerSigningCertificate</key><string>3rd Party Mac Developer Installer</string>
<key>provisioningProfiles</key>
<dict><key>io.unom.punktfunk</key><string>$PROFILE</string></dict>
</dict>
</plist>
EOF
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -exportArchive \
-archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \
-exportOptionsPlist "$RUNNER_TEMP/export-macos-appstore.plist" \
-exportPath "$RUNNER_TEMP/export-macos-appstore" \
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}"
- name: iOS — archive + upload to TestFlight
if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true'
# Best-effort until the App Store Connect app record for io.unom.punktfunk exists.
continue-on-error: true
run: |
# Archive with AUTOMATIC signing (development) — see the macOS App Store step for the full
# rationale. The SwiftPM resource bundle (PunktfunkKit_PunktfunkKit, added with the in-app
# license screens) builds for iphoneos, so even the sdk-scoped PROVISIONING_PROFILE_SPECIFIER
# this step used to set matched it and failed the archive ("does not support provisioning
# profiles"). Automatic signing profiles only the app and leaves the resource bundle (and
# the macOS-host macro plugins) alone. -allowProvisioningUpdates lets Xcode sync the App ID
# capabilities and regenerate the managed *development* profiles for both io.unom.punktfunk
# AND the embedded io.unom.punktfunk.widgets — needed because adding the App Groups
# capability (group.io.unom.punktfunk, shared with the Widget/Live-Activity extension)
# invalidated the cached managed dev profile, which had no widgets profile at all. This is
# DEVELOPMENT signing against the Apple Development cert already in the keychain — no cert
# creation, so the App-Manager ASC key is sufficient (it only manages App IDs/dev profiles).
# DISTRIBUTION signing is the export step below (manual, via the plist) and is unaffected.
# A running Xcode.app prunes unrecognized profiles — quit it so the manually-installed
# App Store distribution profile survives for export.
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
pkill -x Xcode 2>/dev/null || true
PROFILE="Punktfunk iOS App Store Distribution"
# The embedded PunktfunkWidgetsExtension (bundle io.unom.punktfunk.widgets) is a second
# distribution artifact in the .ipa, so manual signing must map its App ID to its own
# App Store profile too — else exportArchive fails ("no profile for io.unom.punktfunk.widgets").
WIDGET_PROFILE="Punktfunk iOS Widgets App Store Distribution"
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
-project "$PROJECT" -scheme Punktfunk-iOS \
-destination 'generic/platform=iOS' \
-archivePath "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \
-derivedDataPath "$DERIVED_DATA" \
-skipMacroValidation -skipPackagePluginValidation \
-allowProvisioningUpdates \
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" \
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
CODE_SIGN_STYLE=Automatic \
DEVELOPMENT_TEAM="$TEAM_ID"
cat > "$RUNNER_TEMP/export-appstore.plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key><string>app-store-connect</string>
<key>destination</key><string>upload</string>
<key>teamID</key><string>$TEAM_ID</string>
<key>signingStyle</key><string>manual</string>
<key>signingCertificate</key><string>Apple Distribution</string>
<key>provisioningProfiles</key>
<dict>
<key>io.unom.punktfunk</key><string>$PROFILE</string>
<key>io.unom.punktfunk.widgets</key><string>$WIDGET_PROFILE</string>
</dict>
</dict>
</plist>
EOF
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -exportArchive \
-archivePath "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \
-exportOptionsPlist "$RUNNER_TEMP/export-appstore.plist" \
-exportPath "$RUNNER_TEMP/export-appstore" \
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}"
- name: iOS — export .ipa (Gitea release + run artifact)
# The TestFlight step above uploads straight to App Store Connect (destination=upload) and
# leaves NO .ipa on disk. Re-export the SAME archive with destination=export to get an
# App Store distribution-signed .ipa for the Gitea release + the run artifacts. Same gate as
# that archive; a warn+skip (never fails the best-effort iOS leg) if the archive is absent,
# e.g. a workflow_dispatch with testflight=false. NOTE: an App Store-signed .ipa installs
# only via TestFlight/App Store, not by direct sideload — it's a release/archival artifact.
if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true'
id: ios_ipa
run: |
ARCHIVE="$RUNNER_TEMP/Punktfunk-ios.xcarchive"
if [ ! -d "$ARCHIVE" ]; then
echo "::warning::iOS archive not found — skipping .ipa export"
exit 0
fi
PROFILE="Punktfunk iOS App Store Distribution"
WIDGET_PROFILE="Punktfunk iOS Widgets App Store Distribution"
# destination=export writes the .ipa to -exportPath; otherwise identical manual signing to
# the upload plist (both profiles, Apple Distribution). No ASC key needed — no network.
cat > "$RUNNER_TEMP/export-appstore-ipa.plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key><string>app-store-connect</string>
<key>destination</key><string>export</string>
<key>teamID</key><string>$TEAM_ID</string>
<key>signingStyle</key><string>manual</string>
<key>signingCertificate</key><string>Apple Distribution</string>
<key>provisioningProfiles</key>
<dict>
<key>io.unom.punktfunk</key><string>$PROFILE</string>
<key>io.unom.punktfunk.widgets</key><string>$WIDGET_PROFILE</string>
</dict>
</dict>
</plist>
EOF
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -exportArchive \
-archivePath "$ARCHIVE" \
-exportOptionsPlist "$RUNNER_TEMP/export-appstore-ipa.plist" \
-exportPath "$RUNNER_TEMP/export-ipa"
SRC=$(ls "$RUNNER_TEMP/export-ipa/"*.ipa 2>/dev/null | head -1)
[ -n "$SRC" ] || { echo "::warning::no .ipa was produced by export"; exit 0; }
mkdir -p "$GITHUB_WORKSPACE/dist"
IPA="$GITHUB_WORKSPACE/dist/Punktfunk-$VERSION.ipa"
mv "$SRC" "$IPA"
echo "IPA=$IPA" >> "$GITHUB_ENV"
echo "ipa=dist/Punktfunk-$VERSION.ipa" >> "$GITHUB_OUTPUT"
echo "exported $IPA"
- name: Attach .ipa to the workflow run
if: steps.ios_ipa.outputs.ipa != ''
# v3, not v4: Gitea's artifact backend identifies as GHES, which upload-artifact@v4 refuses
# (same reason as android.yml / the screenshots job below). Download is a zip of the .ipa.
uses: actions/upload-artifact@v3
with:
name: punktfunk-ios-ipa
path: ${{ steps.ios_ipa.outputs.ipa }}
if-no-files-found: warn
retention-days: 30
- name: Attach .ipa to the Gitea release (stable tags only)
if: startsWith(gitea.ref, 'refs/tags/v') && steps.ios_ipa.outputs.ipa != ''
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
. scripts/ci/gitea-release.sh
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
upsert_asset "$RID" "$IPA" "Punktfunk-$VERSION.ipa"
- name: tvOS — archive + upload to TestFlight
# Canary + stable, the same track as iOS/macOS — the tvOS xcframework slice is now built
# on every apple push (above), so this matches the iOS step's gate exactly.
if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true'
# Needs tvOS added to the App Store Connect app record + the tvOS platform installed
# on the runner (xcodebuild -downloadPlatform tvOS).
continue-on-error: true
run: |
# Archive with AUTOMATIC signing (development) — see the macOS App Store step. The SwiftPM
# resource bundle (PunktfunkKit_PunktfunkKit) builds for appletvos and rejected the
# sdk-scoped profile this step used to set; Automatic signing profiles only the app and
# leaves the resource bundle + the macOS-host macro plugins (OnceMacro/SwizzlingMacro/
# AssociationMacro) alone. -allowProvisioningUpdates lets Xcode sync the App ID capabilities
# and regenerate the managed *development* profile — the tvOS app carries the App Groups key
# (group.io.unom.punktfunk) too, which invalidated the cached one. DEVELOPMENT signing against
# the Apple Development cert already in the keychain, so the App-Manager ASC key suffices.
# DISTRIBUTION signing is the export step below (manual, plist).
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
pkill -x Xcode 2>/dev/null || true
PROFILE="Punktfunk tvOS App Store Distribution"
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
-project "$PROJECT" -scheme Punktfunk-tvOS \
-destination 'generic/platform=tvOS' \
-archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \
-derivedDataPath "$DERIVED_DATA" \
-skipMacroValidation -skipPackagePluginValidation \
-allowProvisioningUpdates \
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" \
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
CODE_SIGN_STYLE=Automatic \
DEVELOPMENT_TEAM="$TEAM_ID"
cat > "$RUNNER_TEMP/export-tvos.plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key><string>app-store-connect</string>
<key>destination</key><string>upload</string>
<key>teamID</key><string>$TEAM_ID</string>
<key>signingStyle</key><string>manual</string>
<key>signingCertificate</key><string>Apple Distribution</string>
<key>provisioningProfiles</key>
<dict><key>io.unom.punktfunk</key><string>$PROFILE</string></dict>
</dict>
</plist>
EOF
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -exportArchive \
-archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \
-exportOptionsPlist "$RUNNER_TEMP/export-tvos.plist" \
-exportPath "$RUNNER_TEMP/export-tvos" \
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}"
- name: sccache stats (visibility only)
if: always()
run: sccache --show-stats
# App Store screenshots of the real UI, zipped and attached to the run as a build artifact.
# 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 \
@@ -154,18 +702,15 @@ 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. ~/.local/bin is on the runner daemon's PATH; GITHUB_PATH is
# belt-and-braces. bsdtar (macOS) globs by default — no --wildcards.
# Shared compile cache. The script handles the macOS side (user-prefix install +
# GITHUB_PATH, bsdtar globbing) — see scripts/ci/ensure-sccache.sh.
- name: sccache (self-healing install)
run: |
if ! command -v sccache >/dev/null; then
mkdir -p "$HOME/.local/bin"
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-aarch64-apple-darwin.tar.gz \
| tar -xz --strip-components=1 -C "$HOME/.local/bin" '*/sccache'
fi
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
sccache --version
run: sh scripts/ci/ensure-sccache.sh
# See the swift job: audiopus_sys (via the in-core Opus decode) builds vendored libopus with CMake.
- name: CMake (for the vendored libopus audiopus_sys builds)
@@ -183,7 +728,7 @@ jobs:
# inherits this from the env during the xcframework build).
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
- name: Pin + prune DerivedData (same disease release.yml already cures)
- name: Pin + prune DerivedData (same disease the distribute job already cures)
# screenshots.sh builds into a throwaway mktemp DerivedData per invocation — two
# fresh ~1 GB trees per run, zero reuse. Pin one stable root (PF_SHOT_DERIVED_DATA,
# honored by the script) so repeat runs are incremental, and GC anything a week old
@@ -197,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
@@ -208,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)
+52
View File
@@ -81,6 +81,14 @@ env:
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# The C/C++ half of the cache (aws-lc-sys, vendored libopus, openh264's C++). Safe at workflow
# level — no cross-compiling job here (see ci.yml's `rust` job for that trap). NOTE these ALSO
# have to be named in the makepkg step's `sudo -u builder env …` list: sudo's env_reset drops
# everything not listed, which is why the sccache vars are already spelled out there.
CMAKE_C_COMPILER_LAUNCHER: sccache
CMAKE_CXX_COMPILER_LAUNCHER: sccache
CC_x86_64_unknown_linux_gnu: sccache cc
CXX_x86_64_unknown_linux_gnu: sccache c++
CARGO_INCREMENTAL: "0"
jobs:
@@ -220,6 +228,39 @@ jobs:
echo "REPO=$REPO" >> "$GITHUB_ENV"
echo "pacman $V-$R -> repo '$REPO'"
# ── The web console, built once per (web+sdk content, bun) instead of once per job ─────────
# Shares deb.yml's key family — see the fuller note there. Unlike the RPM leg this needs no
# hand-off macro: makepkg builds with PF_SRCDIR pointing at this workspace, so a restored
# web/.output is already exactly where PKGBUILD's build-if-missing guard looks for it.
#
# Built here as root, BEFORE the makepkg step's `chown -R builder:` sweeps the tree, so the
# bundle ends up owned like everything else the builder user is handed.
- name: Web console cache key
run: echo "bunver=$(bun --version 2>/dev/null || echo none)" >> "$GITHUB_ENV"
- name: Cache the built web console
id: webconsole
uses: actions/cache@v4
with:
path: web/.output
key: web-console-linux-bun${{ env.bunver }}-${{ hashFiles('web/**', 'sdk/**') }}
- name: Build the web console (cache miss only)
if: steps.webconsole.outputs.cache-hit != 'true'
run: |
cd web
bun install --frozen-lockfile --ignore-scripts
bun run build
- name: The console must exist (cache hit or fresh build)
run: |
if [ ! -f web/.output/server/index.mjs ]; then
echo "::error::web/.output is missing — neither the cache restore nor the build produced it"
exit 1
fi
grep -q 'Bun\.serve' web/.output/server/index.mjs || {
echo "::error::web/.output is not a bun bundle (wrong nitro preset)"; exit 1; }
echo "web console present: $(du -sh web/.output | cut -f1)"
- name: Build packages (makepkg)
run: |
git config --global --add safe.directory "$PWD"
@@ -252,8 +293,19 @@ jobs:
SCCACHE_BUCKET="$SCCACHE_BUCKET" SCCACHE_ENDPOINT="$SCCACHE_ENDPOINT" \
SCCACHE_REGION="$SCCACHE_REGION" \
AWS_ACCESS_KEY_ID="$AWS_ACCESS_KEY_ID" AWS_SECRET_ACCESS_KEY="$AWS_SECRET_ACCESS_KEY" \
CMAKE_C_COMPILER_LAUNCHER="$CMAKE_C_COMPILER_LAUNCHER" \
CMAKE_CXX_COMPILER_LAUNCHER="$CMAKE_CXX_COMPILER_LAUNCHER" \
CC_x86_64_unknown_linux_gnu="$CC_x86_64_unknown_linux_gnu" \
CXX_x86_64_unknown_linux_gnu="$CXX_x86_64_unknown_linux_gnu" \
makepkg -f -d --holdver
ls -lh "$GITHUB_WORKSPACE/dist"
# Visibility only. The stats have to be read as the SAME user that ran the compiles —
# sccache keeps its stats in a per-user server process, so a root `--show-stats` here
# would report an idle server and zero everything.
sudo -u builder env SCCACHE_BUCKET="$SCCACHE_BUCKET" SCCACHE_ENDPOINT="$SCCACHE_ENDPOINT" \
SCCACHE_REGION="$SCCACHE_REGION" \
AWS_ACCESS_KEY_ID="$AWS_ACCESS_KEY_ID" AWS_SECRET_ACCESS_KEY="$AWS_SECRET_ACCESS_KEY" \
sccache --show-stats || true
# The host must ship a VERSIONED libav soname dep, and nothing else in this pipeline proves
# it. packaging/arch/PKGBUILD lists bare `libavcodec.so` etc. and relies on makepkg rewriting
+108 -9
View File
@@ -2,16 +2,21 @@
# license-allowlist gate (CRA Annex I Part II: know your components; catch a bad dep the moment
# it lands).
# * cargo-audit → the (network-facing, crypto-heavy) Rust tree, against the RustSec advisory DB.
# ⚠ ALL FIVE Rust lockfiles, each named with its own `--file`: a bare `cargo audit`
# reads only the root one, which is how the drivers lock went unscanned for so
# long despite already being in this job's `paths:` filter.
# * bun audit → each Bun-managed tree that ships or publishes: web (the mgmt console BFF —
# 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.
# * cargo-about → license-allowlist gate over BOTH Rust workspaces (about.toml `accepted`);
# * 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
# nothing scans it — see the CRA roadmap.)
@@ -21,6 +26,11 @@
# workflow_dispatch, the rust-ci container, the same cache pattern) and because
# ci.yml runs on every push against a fleet where 37 of 46 jobs contend for
# ubuntu-24.04. See the `miri:` job below for what it does and does not buy.
# * c-abi-asan → NON-BLOCKING ASAN+LSAN run of the C ABI harness (tests/c/run.sh under
# PF_SAN=address): both sides of the abi.rs boundary instrumented at once, and
# the only automated check on its Box::into_raw/from_raw leak contract. Same
# here-not-ci.yml reasoning as miri — plus -Zbuild-std defeats sccache, so it
# must not ride the per-push leg.
# Triggers: weekly (catch newly-disclosed CVEs in pinned deps), on every lockfile/allowlist
# change, and on demand.
# To silence a known-unfixable Rust advisory, add it to `.cargo/audit.toml` ([advisories] ignore=[…]).
@@ -42,6 +52,9 @@ on:
paths:
- 'Cargo.lock'
- 'packaging/windows/drivers/Cargo.lock'
- 'packaging/windows/pf-vkhdr-layer/Cargo.lock'
- 'tools/win-input-matrix/Cargo.lock'
- 'tools/hid-descriptor-dump/Cargo.lock'
- 'web/bun.lock'
- 'docs-site/bun.lock'
- 'sdk/bun.lock'
@@ -78,7 +91,15 @@ jobs:
run: |
git config --global --add safe.directory "$PWD"
command -v cargo-audit >/dev/null 2>&1 || cargo install --locked cargo-audit
# Bare `cargo audit` scans ONLY the root Cargo.lock. The other three Rust workspaces are
# separate locks and were silently never scanned — the drivers one despite already being
# in this job's `paths:` filter, so edits to it triggered a run that then ignored it.
# Each needs its own `--file`. `pf-vkhdr-layer` had no lockfile at all until 2026-08-13.
cargo audit
cargo audit --file packaging/windows/drivers/Cargo.lock
cargo audit --file packaging/windows/pf-vkhdr-layer/Cargo.lock
cargo audit --file tools/win-input-matrix/Cargo.lock
cargo audit --file tools/hid-descriptor-dump/Cargo.lock
bun-audit:
strategy:
@@ -350,9 +371,10 @@ jobs:
# way — Miri does not implement it — so the gfni branch is simply not covered here.
#
# ⚠ x86_64 ONLY, and it must stay that way. A RUSTFLAGS env var OVERRIDES config rustflags
# ENTIRELY (.cargo/config.toml:11-13 says so), and that config carries `--cfg aes_armv8` /
# `--cfg polyval_armv8` for aarch64 — worth a measured ~3x decrypt-throughput cliff if
# dropped. Harmless here because this job pins ubuntu-24.04/x86_64; fatal on mac-mini-1.
# ENTIRELY — it does not merge. That used to cost the aarch64 `--cfg aes_armv8` /
# `--cfg polyval_armv8` decrypt flags; the aes 0.9 / polyval 0.7 bump retired those cfgs
# (see the tombstone in .cargo/config.toml), so there is nothing left for an override to
# drop here. Keep the pin anyway: these target-features are meaningless off x86_64.
# Narrow selection is mandatory, not an optimisation: see the punktfunk-core note above.
- name: miri — punktfunk-core fec::gf8, taking the real AVX2/SSSE3 branches
env:
@@ -364,3 +386,80 @@ jobs:
-p punktfunk-core --lib -- fec::gf8 2>&1 | tee /tmp/miri-gf8.log || ok=0
grep -qE 'test result: ok\. [1-9][0-9]* passed' /tmp/miri-gf8.log || ok=0
[ "$ok" = 1 ] || echo "::warning::miri (punktfunk-core fec::gf8, AVX2/SSSE3) did not pass — non-blocking; see design/rust-safety-programme.md §7"
# ASAN + LSAN over the C ABI harness — §6.1 of design/rust-safety-programme.md, its rank-1
# tooling item. crates/punktfunk-core/tests/c/run.sh already proves the staticlib links and
# round-trips 4 frames byte-exact from C on every push (ci.yml); PF_SAN=address rebuilds BOTH
# sides instrumented — the staticlib on nightly with -Zsanitizer/-Zbuild-std (std itself
# included), the harness with clang -fsanitize — so ASAN sees the seam a Rust-only tool cannot,
# and LSAN (detect_leaks=1, the script's default) becomes the one automated check on abi.rs's
# Box::into_raw/from_raw leak contract.
# Proven to fail on 192.168.1.25: deleting a single punktfunk_session_free() from harness.c
# makes LSAN report the ~308 Rust-side allocations behind the handle and run.sh exit 1.
# What it does NOT see: the invalid-InputKind-discriminant UB at abi.rs (that needs the
# validator, tracked in §5 of the programme doc), and nothing GPU/Windows — this is the
# default-feature (quic-less, opus-less) core only.
c-abi-asan:
runs-on: ubuntu-24.04
container:
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
timeout-minutes: 30
env:
# The SAME dated pin as the miri job above, deliberately — one nightly date to bump for
# both jobs (they have no toolchain interaction; sharing the date just halves the chores).
SAN_TOOLCHAIN: nightly-2026-08-10
# Same guard as the miri job: audit.yml sets no sccache today, and -Zbuild-std could not
# use it anyway. Keeps a future workflow-level sccache from becoming a puzzle.
RUSTC_WRAPPER: ""
steps:
- uses: actions/checkout@v4
# Own `san-` key prefixes — never shared with the miri caches, per the cache-poisoning
# note there (and so an incomplete save from one job can never starve the other).
- name: cache the nightly toolchain
uses: actions/cache@v4
with:
path: /usr/local/rustup/toolchains/${{ env.SAN_TOOLCHAIN }}-x86_64-unknown-linux-gnu
key: san-toolchain-v1-${{ env.SAN_TOOLCHAIN }}
- name: cache the cargo registry
uses: actions/cache@v4
with:
path: /usr/local/cargo/registry
key: san-registry-v1-${{ hashFiles('Cargo.lock') }}
restore-keys: san-registry-v1-
# rust-src is required: -Zbuild-std compiles std from source so it is instrumented too —
# without that, LSAN cannot attribute allocations made inside std (Vec, Box, HashMap).
- name: install the pinned nightly + rust-src
run: |
git config --global --add safe.directory "$PWD"
rustup toolchain install "$SAN_TOOLCHAIN" --profile minimal --component rust-src
echo "root pin, untouched by this job: $(grep -E '^channel' rust-toolchain.toml)"
cargo +"$SAN_TOOLCHAIN" --version
# The image installs clang but Ubuntu does not always pull the compiler-rt sanitizer
# runtime with it (verified absent on a stock 26.04 box). Probe with an actual ASAN link
# and self-heal via apt if it fails — container jobs on this fleet run as root (the
# bun-audit job's apt-get above relies on the same fact).
- name: ensure clang's ASAN runtime
run: |
if ! echo 'int main(void){return 0;}' | clang -fsanitize=address -x c - -o /tmp/asan-probe 2>/dev/null; then
apt-get update && apt-get install -y --no-install-recommends "libclang-rt-$(clang -dumpversion | cut -d. -f1)-dev"
echo 'int main(void){return 0;}' | clang -fsanitize=address -x c - -o /tmp/asan-probe
fi
# run.sh handles everything behind PF_SAN (nightly build, target path, clang flags,
# ASAN_OPTIONS=detect_leaks=1) and exits non-zero on any report. The grep is the
# proved-it-ran guard, same reasoning as the miri steps: a script change that silently
# skips the harness must not read as green. run.sh expects bash and PATH cargo — both true
# in this container. PF_SAN_TOOLCHAIN pins the script's `cargo +<toolchain>` to the dated
# nightly installed above — without it the script would ask for the ROLLING `nightly`
# channel, which this job deliberately does not install.
- name: C ABI harness under ASAN+LSAN
run: |
set -o pipefail
ok=1
PF_SAN=address PF_SAN_TOOLCHAIN="$SAN_TOOLCHAIN" \
bash crates/punktfunk-core/tests/c/run.sh 2>&1 | tee /tmp/asan-harness.log || ok=0
grep -q 'PASS: 4 frames round-tripped byte-exact' /tmp/asan-harness.log || ok=0
[ "$ok" = 1 ] || echo "::warning::c-abi-asan did not pass — non-blocking on day one; see design/rust-safety-programme.md §6.1. An LSAN report here means the abi.rs into_raw/from_raw contract broke."
+2 -7
View File
@@ -38,14 +38,9 @@ jobs:
- uses: actions/checkout@v4
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this fetch keeps the job green while the running :latest predates the bake.
# images; this heals the job while the running :latest predates the bake.
- name: sccache (no-op once the image bakes it)
run: |
command -v sccache >/dev/null 2>&1 || {
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'
}
sccache --version
run: sh scripts/ci/ensure-sccache.sh
- name: Prep
run: |
+67 -19
View File
@@ -23,6 +23,12 @@ on:
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io, LAN-pinned via ci-core's
# unbound). Keys include compiler hash + target + flags, so cross-OS/arch entries can
# never collide; every Rust job on every host feeds and reads one warm cache.
#
# RUSTC_WRAPPER covers RUST compilations and nothing else. The C/C++ half of this workspace —
# aws-lc-sys, openh264-sys2's vendored C++, the CMake-built libopus behind audiopus_sys, pyrowave —
# was paid in full on every run until the CMAKE_*_COMPILER_LAUNCHER / CC_* wiring below existed.
# Linking is the third phase and is cacheable by nothing: that one is addressed in the builder
# images with mold (ci/cargo-config-mold.toml).
env:
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: unom-ci-sccache
@@ -30,6 +36,11 @@ env:
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# Route CMake-driven C/C++ through the same cache. Safe at workflow level: it names no
# triple, and cmake-rs overrides it per-invocation with a `-D` flag when cc-rs reports a
# wrapper, so the two can never double-wrap into `sccache sccache cc`.
CMAKE_C_COMPILER_LAUNCHER: sccache
CMAKE_CXX_COMPILER_LAUNCHER: sccache
# sccache and incremental compilation are mutually exclusive; CI wants the shared
# cache, dev boxes keep incremental.
CARGO_INCREMENTAL: "0"
@@ -40,18 +51,25 @@ jobs:
container:
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
timeout-minutes: 90
env:
# cc-rs recognises `sccache` as a compiler wrapper when it leads CC/CXX, and cmake-rs then
# forwards it as -DCMAKE_C_COMPILER_LAUNCHER, so this covers both build-script styles.
#
# ⚠ JOB-scoped, NOT workflow-scoped, and it must stay that way: the `rust-arm64` job below
# runs in the cross image, which sets CC_x86_64_unknown_linux_gnu=/usr/local/bin/pf-host-cc
# (ci/rust-ci-arm64cross.Dockerfile) — a wrapper that strips arm64 include dirs off
# HOST-targeted compiles so ffmpeg-sys-next's probe resolves against the amd64 headers.
# Setting this at workflow level would silently overwrite that wrapper and break the cross
# build in a way that looks like a header mismatch, not a CI config error.
CC_x86_64_unknown_linux_gnu: sccache cc
CXX_x86_64_unknown_linux_gnu: sccache c++
steps:
- uses: actions/checkout@v4
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this fetch keeps the job green while the running :latest predates the bake.
# images; this heals the job while the running :latest predates the bake.
- name: sccache (no-op once the image bakes it)
run: |
command -v sccache >/dev/null 2>&1 || {
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'
}
sccache --version
run: sh scripts/ci/ensure-sccache.sh
# punktfunk-client-linux link deps. Also baked into rust-ci.Dockerfile — but ci.yml
# runs against the image from the PREVIOUS push (docker.yml bootstrap note), so this
@@ -105,8 +123,17 @@ jobs:
# out of disk mid-build and actions/cache saved a truncated target/ (a dep's .rmeta
# went missing -> E0463 "can't find crate"). A suffix bump wouldn't help — restore-keys
# would fall back to the poisoned prefix — so the prefix itself is versioned.
key: cargo-target-v3-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-target-v3-${{ env.rustc }}-
#
# `-debug-`: THIS JOB BUILDS DEBUG. deb.yml builds RELEASE and used to share this exact
# key, with a comment claiming the release build "reuses ci.yml's clean artifacts" — it
# never could. actions/cache is first-saver-wins on an exact key and this job is the
# faster of the two, so what landed under the shared key was always a debug-only target/
# (target/debug, no target/release). deb.yml restored a tree containing nothing it could
# use and, because the key was already taken, never got to save its own — so every
# release build re-linked from scratch, forever. Splitting the profiles into separate key
# families is the fix; do not merge them again, however tempting the dedupe looks.
key: cargo-target-debug-v3-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-target-debug-v3-${{ env.rustc }}-
- name: Format
run: cargo fmt --all --check
@@ -187,7 +214,17 @@ jobs:
- name: Verify generated header is committed & up to date
run: |
cargo build -p punktfunk-core --locked
cargo build -p punktfunk-core --locked >/tmp/core-build.log 2>&1 \
|| { cat /tmp/core-build.log; exit 1; }
cat /tmp/core-build.log
# build.rs demotes a cbindgen failure to a warning and then writes NOTHING — the
# checked-in header stays untouched and the drift check below stays green while the
# header is silently stale. So first assert the regeneration actually happened.
# (cargo replays build-script warnings from cache, so this holds on cached builds too.)
grep -q "punktfunk-core: wrote" /tmp/core-build.log
if grep -q "cbindgen failed" /tmp/core-build.log; then
echo "cbindgen failed to parse the ABI surface — header NOT regenerated" && exit 1
fi
git config --global --add safe.directory "$PWD"
git diff --exit-code include/punktfunk_core.h \
|| (echo "include/punktfunk_core.h is stale — commit the regenerated header" && exit 1)
@@ -210,14 +247,9 @@ jobs:
- uses: actions/checkout@v4
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this fetch keeps the job green while the running :latest predates the bake.
# images; this heals the job while the running :latest predates the bake.
- name: sccache (no-op once the image bakes it)
run: |
command -v sccache >/dev/null 2>&1 || {
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'
}
sccache --version
run: sh scripts/ci/ensure-sccache.sh
- name: Cache keys
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
@@ -232,8 +264,16 @@ jobs:
with:
path: target
# Its OWN prefix: aarch64 artifacts must never share the amd64 jobs' target cache.
key: cargo-target-arm64-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-target-arm64-v1-${{ env.rustc }}-
#
# `-debug-` in the name is load-bearing. This job builds DEBUG (clippy + a
# `cargo build`), while deb.yml's arm64 leg builds RELEASE into the same
# target/aarch64-unknown-linux-gnu tree. They used to share this exact key, and
# actions/cache is first-saver-wins on an exact key: this job finishes in ~1.5 min and
# saved a debug-only tree, so the .deb leg's release artifacts were NEVER persisted and
# it re-linked everything from sccache on every run. Same disease as the amd64 pair —
# see the note on deb.yml's `cargo-target-release-v1-` key.
key: cargo-target-arm64-debug-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-target-arm64-debug-v1-${{ env.rustc }}-
- name: Clippy for aarch64 (deny warnings)
run: |
@@ -249,6 +289,14 @@ jobs:
cargo build --release --target aarch64-unknown-linux-gnu --locked \
-p punktfunk-client-session --no-default-features
# Visibility only — but the ONLY way to tell a warm cache from a cold one after the fact.
# Every Rust job in this repo ends with this line for that reason; a hit rate that quietly
# collapses (a toolchain bump, a flag change, an S3 outage) is otherwise invisible and just
# looks like "CI got slower".
- name: sccache stats (visibility only)
if: always()
run: sccache --show-stats
web:
runs-on: ubuntu-24.04
container:
+290 -131
View File
@@ -1,6 +1,6 @@
# Build the punktfunk .debs and publish them to Gitea's Debian package registry, so Ubuntu
# boxes get new builds via `apt update && apt upgrade`. Three jobs, all publishing to the same
# apt distribution/component:
# Build the punktfunk .debs and publish them to Gitea's Debian package registry, so Debian and
# Ubuntu boxes get new builds via `apt update && apt upgrade`. Five jobs; the four builders all
# publish to the same apt distribution/component, and the fifth checks the result:
#
# build-publish — client + web + scripting, on the Ubuntu 26.04 rust-ci image (the client
# needs 24.04-absent libs: SDL3, GTK4 ≥ 4.20).
@@ -11,8 +11,17 @@
# build-publish-host — the HOST, on the Ubuntu 24.04 rust-ci-noble image with a from-source
# FFmpeg 8 BUNDLED into the .deb. This lowers the host's glibc floor to 2.39
# and removes the hard `Depends: libavcodec62`, so the ONE host .deb installs
# on Ubuntu 24.04 LTS through 26.04. (A 26.04-built host .deb is uninstallable
# on 24.04 — the reason this job exists; see packaging/debian/README.md.)
# on Ubuntu 24.04 LTS through 26.04 — and, for free, on Debian 13.
# (A 26.04-built host .deb is uninstallable on 24.04 — the reason this job
# exists; see packaging/debian/README.md.)
# build-publish-gamescope
# — the patched `punktfunk-gamescope`, on DEBIAN 13. It lived in the host job
# until 2026-08 and never once succeeded there: noble's wayland is 1.22.0
# and the vendored wlroots floors it at 1.23.1, so v0.26.0 and v0.27.0 both
# shipped without the package while the docs told apt users to install it.
# smoke-install — installs what was just published, from the registry, in pristine
# ubuntu:24.04 / ubuntu:26.04 / debian:trixie images. Nothing here used to
# install a package it built, which is how both of the above survived.
#
# Both compute VERSION identically (scripts/ci/pf-version.sh is deterministic per commit), so the
# host and client packages always share a version line. The release-attach helpers are race-safe
@@ -70,6 +79,11 @@ env:
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# CMake-driven C/C++ through the same cache (aws-lc-sys, the vendored libopus). Workflow level
# is safe — it names no triple; the CC_*/CXX_* half is per-job below, because the arm64 leg's
# image needs its own CC_x86_64 wrapper. See ci.yml's `rust` job for the full note.
CMAKE_C_COMPILER_LAUNCHER: sccache
CMAKE_CXX_COMPILER_LAUNCHER: sccache
# sccache and incremental compilation are mutually exclusive; CI wants the shared
# cache, dev boxes keep incremental.
CARGO_INCREMENTAL: "0"
@@ -80,18 +94,18 @@ jobs:
container:
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
timeout-minutes: 90
env:
# ⚠ Job-scoped, never workflow-scoped: build-publish-client-arm64 runs in the cross image,
# whose own CC_x86_64_unknown_linux_gnu=pf-host-cc must survive. See ci.yml's `rust` job.
CC_x86_64_unknown_linux_gnu: sccache cc
CXX_x86_64_unknown_linux_gnu: sccache c++
steps:
- uses: actions/checkout@v4
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this fetch keeps the job green while the running :latest predates the bake.
# images; this heals the job while the running :latest predates the bake.
- name: sccache (no-op once the image bakes it)
run: |
command -v sccache >/dev/null 2>&1 || {
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'
}
sccache --version
run: sh scripts/ci/ensure-sccache.sh
- name: Version + channel
# vX.Y.Z tag -> X.Y.Z, published to the `stable` apt distribution (a real release).
@@ -129,7 +143,9 @@ jobs:
apt-get install -y --no-install-recommends dpkg-dev python3 \
libgtk-4-dev libadwaita-1-dev libsdl3-dev
# Share ci.yml's cache keys so the release build reuses its registry + target artifacts.
# The cargo-home (registry/git) cache IS shared with ci.yml — those are pure downloads,
# profile-independent, and deduping them across the fleet is the whole point. The target
# cache is NOT; see below.
- name: Cache keys
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
- uses: actions/cache@v4
@@ -142,10 +158,20 @@ jobs:
- uses: actions/cache@v4
with:
path: target
# -v3-: bypass a target cache poisoned by a disk-full build (see ci.yml). Shares the
# key with ci.yml so the release build reuses its clean artifacts.
key: cargo-target-v3-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-target-v3-${{ env.rustc }}-
# -v3-: bypass a target cache poisoned by a disk-full build (see ci.yml).
#
# `-release-`, and NOT ci.yml's key. This step used to read
# `cargo-target-v3-<rustc>-<lock>` — byte-identical to ci.yml's — under a comment saying
# the release build "reuses its clean artifacts". It never did, and could not: ci.yml
# builds DEBUG, this job builds RELEASE, and actions/cache is first-saver-wins on an
# exact key. ci.yml's `rust` job finishes in ~6 min against this job's ~7-8, so ci.yml
# always won the save, this job always restored a target/ with an empty target/release,
# and — the expensive half — its own release artifacts were then never persisted,
# because the key it would have saved under was already taken. Every canary .deb has
# therefore been a from-scratch release build (sccache-assisted, but every link and
# every build script re-run) for as long as both keys have existed.
key: cargo-target-release-v3-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-target-release-v3-${{ env.rustc }}-
- name: Build release clients
env:
@@ -163,7 +189,31 @@ jobs:
cargo build --release --locked \
-p punktfunk-client-linux -p punktfunk-client-session -p punktfunk-cli -p pf-update
# ── The web console, built ONCE per (web+sdk content, bun) rather than once per job ────────
# This bundle was being rebuilt six times on every push — ci.yml, here, both RPM legs, arch,
# and the docker app image — at ~2.5 min each, for output that is a pure function of web/ and
# sdk/. windows-host.yml has cached it this way for a while; this is the same arrangement for
# the Linux packaging legs, sharing one key family so a hit in one warms the others.
#
# The bun version is IN THE KEY. Each builder image installs its own bun (rust-ci, fedora-rpm
# and arch-ci each run the bun.sh installer at image-build time), so without it a bundle built
# by one image's bun could be shipped by a job running a different one. They are usually the
# same version and do share; when they diverge, they simply stop sharing instead of silently
# mixing.
- name: Web console cache key
run: echo "bunver=$(bun --version 2>/dev/null || echo none)" >> "$GITHUB_ENV"
- name: Cache the built web console
id: webconsole
uses: actions/cache@v4
with:
path: web/.output
key: web-console-linux-bun${{ env.bunver }}-${{ hashFiles('web/**', 'sdk/**') }}
- name: Build + smoke-boot web console (bun preset)
# Skipped when the cache already holds this exact (web+sdk, bun) bundle — the assertion step
# below is what makes that safe. The bundle in the cache was smoke-booted by the run that
# produced it, and ci.yml's `web` job still builds and tests the console on every push.
if: steps.webconsole.outputs.cache-hit != 'true'
# Gate the .deb on a real bun boot: the punktfunk-web .deb runs the Nitro `bun` preset
# (our Bun.serve TLS entry), so prove the build IS a bun bundle and serves /login.
# No TLS env here, so the custom entry binds plain HTTP — the smoke curl stays simple.
@@ -176,7 +226,12 @@ jobs:
}
export PATH="$HOME/.bun/bin:$PATH"
cd web
bun install --frozen-lockfile
# --ignore-scripts, like every other web install in CI (ci.yml, web-screenshots.yml,
# windows-host.yml, the SDK installs). This was the ONE site still running lifecycle
# scripts, and web's `postinstall` is `bun2nix -o bun.nix` — a Nix codegen step this job
# neither consumes nor commits, whose only effect here is to make the install depend on
# bun2nix resolving. `build` re-runs its own `prebuild` codegen regardless.
bun install --frozen-lockfile --ignore-scripts
bun run build
if ! grep -q 'Bun\.serve' .output/server/index.mjs; then
echo "ERROR: web build is not a bun bundle — need the 'bun' preset + custom entry"; exit 1
@@ -188,6 +243,22 @@ jobs:
echo "web console smoke: /login -> $code"
[ "$code" = 200 ] || { echo "ERROR: web console failed to boot under bun"; exit 1; }
# ⚠ NOT optional, and it must run on BOTH paths (cache hit and fresh build). The packaging
# scripts treat a missing web/.output as "build it yourself", which is right for a local run
# and wrong here: it would silently turn a broken cache restore into a slow, quiet rebuild, or
# — with the build step skipped and the restore empty — into a package with no console at all.
# windows-host.yml shipped exactly that in 0.22.1/0.22.2 (an unset WEB_OUTPUT_DIR behind a
# single Write-Host), which is why its equivalent step throws too. Fail loudly instead.
- name: The console must exist (cache hit or fresh build)
run: |
if [ ! -f web/.output/server/index.mjs ]; then
echo "::error::web/.output is missing — neither the cache restore nor the build produced it"
exit 1
fi
grep -q 'Bun\.serve' web/.output/server/index.mjs || {
echo "::error::web/.output is not a bun bundle (wrong nitro preset)"; exit 1; }
echo "web console present: $(du -sh web/.output | cut -f1)"
- name: Build .debs
run: |
export PATH="$HOME/.bun/bin:$PATH"
@@ -198,6 +269,13 @@ jobs:
# The plugin/script runner (bun-bundled Effect SDK) — same vendored-bun mechanics.
VERSION="$VERSION" BUN_BIN="$(command -v bun || true)" bash packaging/debian/build-scripting-deb.sh
# Visibility only. With the target cache now actually saving release artifacts (see the
# cache key note above), this is how a regression in that arrangement becomes visible:
# a run that suddenly reports thousands of misses is a cache that stopped restoring.
- name: sccache stats (visibility only)
if: always()
run: sccache --show-stats
- name: Publish to the Gitea apt registry
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
@@ -242,18 +320,17 @@ jobs:
container:
image: 192.168.1.58:5010/punktfunk-rust-ci-noble:latest
timeout-minutes: 90
env:
# ⚠ Job-scoped — see build-publish above and ci.yml's `rust` job.
CC_x86_64_unknown_linux_gnu: sccache cc
CXX_x86_64_unknown_linux_gnu: sccache c++
steps:
- uses: actions/checkout@v4
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this fetch keeps the job green while the running :latest predates the bake.
# images; this heals the job while the running :latest predates the bake.
- name: sccache (no-op once the image bakes it)
run: |
command -v sccache >/dev/null 2>&1 || {
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'
}
sccache --version
run: sh scripts/ci/ensure-sccache.sh
- name: Version + channel
run: |
@@ -289,9 +366,12 @@ jobs:
with:
path: target
# Own key: this target dir is built against 24.04's glibc/toolchain and must NOT share
# ci.yml's 26.04 target cache (mixing would poison both).
key: cargo-target-noble-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-target-noble-v1-${{ env.rustc }}-
# ci.yml's 26.04 target cache (mixing would poison both). It is also the only job using
# this prefix, so — unlike the amd64/arm64 pairs above — it has always been able to save
# and restore its own release artifacts. `-release-` is spelled out anyway so the whole
# file reads consistently and nobody "unifies" it back into a shared key later.
key: cargo-target-noble-release-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-target-noble-release-v1-${{ env.rustc }}-
- name: Build release host
env:
@@ -326,6 +406,12 @@ jobs:
run: |
VERSION="$VERSION" BUNDLE_FFMPEG=1 bash packaging/debian/build-deb.sh
# Visibility only — placed here, right after the last compile, rather than at the end of the
# job: the gamescope gate below must stay the final step (see its own note).
- name: sccache stats (visibility only)
if: always()
run: sccache --show-stats
# Read the capability matrix out of the BUILT .deb before it is published. dpkg carries no
# capability metadata — the postinst applies them — so this reads the postinst that will
# actually run on a user's box, plus the payload. 0.26.0-1 granted the host cap_sys_nice=ep
@@ -337,82 +423,6 @@ jobs:
bash scripts/ci/assert-cap-matrix.sh --self-test
bash scripts/ci/assert-cap-matrix.sh dist/punktfunk-host_*.deb
# punktfunk-gamescope for apt. Same reasoning as the RPM leg in rpm.yml: without a packaged
# build, a Debian/Ubuntu box has no route to the patched gamescope except compiling it, and a
# stock gamescope streams SDR, cursorless, and tells every game its display is 60 Hz.
#
# CACHED on packaging/gamescope/** alone — it depends on nothing else in this repo, so a
# normal push restores a binary instead of spending ~10 minutes on someone else's tree.
- uses: actions/cache@v4
id: gamescope
with:
path: gs-cache
key: punktfunk-gamescope-noble-${{ hashFiles('packaging/gamescope/**') }}
- name: Build the patched gamescope
if: steps.gamescope.outputs.cache-hit != 'true'
# Best-effort, exactly like rpm.yml: the host packages above are the primary delivery and
# work without this binary, so a hiccup building an unrelated tree must not fail the job.
# `build-dep gamescope` resolves the distro's much older packaged version, so it can come up
# short — that is what the `|| true`s absorb, and the marker check downstream is what makes
# a half-built result impossible to ship.
run: |
set -x
apt-get update
apt-get install -y --no-install-recommends meson ninja-build glslc git || true
apt-get build-dep -y gamescope || true
# NOT best-effort. `build-dep gamescope` resolves the distro's much older packaged
# gamescope — where noble has one at all — so it misses what the master tree needs, and
# wayland-protocols is the gap that actually stops the build: meson dies in
# protocol/meson.build with "Neither a subproject directory nor a wayland-protocols.wrap
# file was found", because the tree has no wrap fallback for it. That is what happened on
# the v0.26.0 tag: the step warned and skipped, the job stayed green, and the release
# shipped with no gamescope .deb while the notes said it had one.
apt-get install -y --no-install-recommends wayland-protocols
# The remaining Arch makedepends the older packaged gamescope does not necessarily pull.
# Best-effort: meson falls back or does without, and a name that moves between Ubuntu
# releases should not fail the job. (No libstdc++ static package is needed here — g++
# ships libstdc++.a, which is why only Fedora tripped the sanity check.)
# `build-dep gamescope` gives noble almost nothing — the distro has no comparable package
# — so the tree's real dependency set has to be named outright. One `apt-get` per name on
# purpose: a single transaction aborts wholesale on one unknown package, which would
# install NOTHING and hide the real gap behind a name typo. Best-effort per package, with
# the missing one named; the end-of-job gate below is what actually decides.
for p in 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 \
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 \
glslang-tools libvulkan-dev libwayland-dev libxcb1-dev libxcb-composite0-dev \
libxcb-xfixes0-dev libxcb-res0-dev libxcb-ewmh-dev libxcb-icccm4-dev \
libxcb-errors-dev libpixman-1-dev libdisplay-info-dev libgbm-dev libegl-dev \
cmake xwayland; do
apt-get install -y --no-install-recommends "$p" \
|| echo "::warning::no such noble package: $p (gamescope may still build without it)"
done
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
else
# Warn only, even on a tag. The hard gate moved to the END of this job: failing HERE
# skips the host .deb's own publish + release-attach steps below, which is how the
# v0.26.0 release ended up still carrying the pre-CAP_SYS_NICE host .deb from an
# earlier tag commit — a KDE-breaking artifact withheld from replacement by a gate
# meant to protect the release. Never let a missing EXTRA stop a good artifact
# shipping; go red afterwards instead.
echo "::warning::punktfunk-gamescope failed to build on noble — no .deb this run (gamescope sessions stay SDR)"
fi
- name: Build punktfunk-gamescope .deb
# Picked up by the publish loop below, which globs dist/*.deb.
run: |
if [ -x gs-cache/punktfunk-gamescope ] && gs-cache/punktfunk-gamescope --version >/dev/null 2>&1; then
bash packaging/debian/build-gamescope-deb.sh --binary gs-cache/punktfunk-gamescope
else
# Warn only — see the note on the build step. The gate is the last step of this job.
echo "::warning::no usable punktfunk-gamescope — skipping its .deb"
fi
- name: Publish to the Gitea apt registry
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
@@ -440,25 +450,111 @@ jobs:
upsert_asset "$RID" "$DEB"
done
# A release must not be able to make a claim its own CI silently dropped: v0.26.0's notes and
# docs-site said the patched gamescope was apt-installable while no .deb had ever been built,
# because every failure on this path was a `::warning::` that returned 0.
#
# ⚠ LAST step on purpose. The first version of this gate failed at the build step instead, and
# that skipped the host .deb's own publish + attach below — so the release kept the PREVIOUS
# tag commit's host .deb, which still carried the CAP_SYS_NICE postinst that breaks KDE. A
# gate protecting the release withheld the fix for it. Everything good ships first; the job
# goes red afterwards.
- name: A stable tag must ship the gamescope .deb
if: startsWith(gitea.ref, 'refs/tags/v')
# ---------------------------------------------------------------------------------------------
# `punktfunk-gamescope` for apt — the patched build that gives a gamescope session HDR, a cursor,
# and the client's real refresh rate instead of a hardcoded 60 Hz. Same reasoning as the RPM leg
# in rpm.yml: without a package, a Debian/Ubuntu box has no route to it except compiling by hand.
#
# ⚠ THIS IS ITS OWN JOB, ON DEBIAN 13, BECAUSE THE NOBLE BUILD COULD NEVER HAVE WORKED.
# It used to be a few best-effort steps inside build-publish-host (Ubuntu 24.04), where it failed
# every single time:
# wlroots| Dependency wayland-server found: NO found 1.22.0 but need: '>=1.23.1'
# Our pin vendors wlroots 0.19.3, which floors wayland-server at 1.23.1; noble ships 1.22.0 and
# always will. v0.26.0 AND v0.27.0 both shipped with no gamescope .deb — while the release notes
# and docs-site told apt users to install it — because every rung of that path was a `::warning::`
# that returned 0, and the one hard gate ran last by design (so good artifacts still publish) and
# was simply never acted on. Moving it to trixie makes the build possible; making it a job of its
# own makes its failure visible instead of a footnote in someone else's log.
#
# Debian 13 is the OLDEST apt distro the tree configures on (wayland 1.23.1, libxcb-errors,
# libdisplay-info 0.2). The binary it produces needs GLIBC_2.38 and links no libstdc++, so what
# actually bounds it is wayland: Debian 13 and Ubuntu 26.04 YES, Ubuntu 24.04 NO — and 24.04
# could not run this binary however it was built, so nothing was lost by leaving noble.
build-publish-gamescope:
runs-on: ubuntu-24.04
container:
image: 192.168.1.58:5010/punktfunk-gamescope-trixie:latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
# Byte-identical to the other jobs' version step (pf-version.sh is deterministic per commit)
# — but only DISTRIBUTION is used here. The package version is the gamescope upstream
# version + our patch level, which build-gamescope-deb.sh derives itself; it deliberately
# does NOT follow the punktfunk version line, because this package moves on its own cadence.
- name: Channel
run: |
shopt -s nullglob
built=(dist/punktfunk-gamescope_*.deb)
if [ ${#built[@]} -eq 0 ]; then
echo "::error::no punktfunk-gamescope .deb was built — a stable tag must not ship without it (the release notes and docs-site say it is apt-installable). Everything else in this job published normally; see the gamescope build step above for the meson error."
exit 1
fi
echo "gamescope .deb present: ${built[*]}"
git config --global --add safe.directory "$PWD"
case "$GITHUB_REF" in
refs/tags/v*) DIST=stable ;;
*) DIST=canary ;;
esac
echo "DISTRIBUTION=$DIST" >> "$GITHUB_ENV"
echo "gamescope -> apt distribution '$DIST'"
# CACHED on packaging/gamescope/** alone — it depends on nothing else in this repo, so a
# normal push restores a binary instead of spending ~10 minutes on someone else's tree.
# Keyed `-trixie-` so the noble cache entries (which only ever held misses) can't be hit.
- uses: actions/cache@v4
id: gamescope
with:
path: gs-cache
key: punktfunk-gamescope-trixie-${{ hashFiles('packaging/gamescope/**') }}
# NOT best-effort, unlike the noble version of this step. Every dependency now comes from the
# image (which asserts the wayland floor at build time), so a failure here is a real
# regression in the tree or the pin — exactly the thing the previous arrangement hid.
- name: Build the patched gamescope
if: steps.gamescope.outputs.cache-hit != 'true'
# `--extra-fallback libdisplay-info` is what makes ONE .deb serve both Debian 13 and
# Ubuntu 26.04. Built against the distro's copy, the package picks up
# `Depends: libdisplay-info2 (>= 0.2.0)` on trixie — and Ubuntu 26.04 carries
# libdisplay-info **3** (0.3.0), so apt refuses it there ("Depends libdisplay-info2 …" —
# measured, not predicted). gamescope vendors the library as a submodule, so linking the
# vendored copy drops the dependency entirely. Same reasoning the build script already
# applies to wlroots: a binary we SHIP must not follow the build host's shared libraries.
run: |
bash packaging/gamescope/build-punktfunk-gamescope.sh \
--destdir "$PWD/gs-stage" --prefix /usr --jobs "$(nproc)" \
--extra-fallback libdisplay-info
# 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/usr/bin/punktfunk-gamescope --version
bash packaging/debian/build-gamescope-deb.sh --stage gs-cache
- name: Publish to the Gitea apt registry
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
for DEB in dist/punktfunk-gamescope_*.deb; do
echo "uploading $DEB"
NAME=$(dpkg-deb -f "$DEB" Package)
VER=$(dpkg-deb -f "$DEB" Version)
ARCH=$(dpkg-deb -f "$DEB" Architecture)
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/$NAME/$VER/$ARCH" || true
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$DEB" \
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/upload"
done
echo "published gamescope to $OWNER/debian $DISTRIBUTION/$COMPONENT"
- name: Attach the gamescope .deb to the Gitea release (stable tags only)
if: startsWith(gitea.ref, 'refs/tags/v')
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
. scripts/ci/gitea-release.sh
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
for DEB in dist/punktfunk-gamescope_*.deb; do
upsert_asset "$RID" "$DEB"
done
# ---------------------------------------------------------------------------------------------
# The aarch64 CLIENT .deb. Cross-compiled on the ordinary amd64 runner in the
@@ -476,14 +572,14 @@ jobs:
- uses: actions/checkout@v4
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this fetch keeps the job green while the running :latest predates the bake.
# images; this heals the job while the running :latest predates the bake.
#
# NOTE this job deliberately sets no CC_x86_64_unknown_linux_gnu: the cross image already
# points it at /usr/local/bin/pf-host-cc, which is what keeps ffmpeg-sys-next's HOST probe
# from picking up arm64 include dirs. The target-side compiles go through
# CC_aarch64_unknown_linux_gnu (also set by the image) and are not sccache-wrapped.
- name: sccache (no-op once the image bakes it)
run: |
command -v sccache >/dev/null 2>&1 || {
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'
}
sccache --version
run: sh scripts/ci/ensure-sccache.sh
# Byte-identical to build-publish's version step (pf-version.sh is deterministic per
# commit), so the arm64 package always shares the amd64 version line.
@@ -520,8 +616,13 @@ jobs:
path: target
# Its OWN key — these are aarch64 artifacts under target/aarch64-unknown-linux-gnu/
# and must never share the amd64 jobs' target cache.
key: cargo-target-arm64-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-target-arm64-v1-${{ env.rustc }}-
#
# `-release-`: this key was previously identical to ci.yml's `rust-arm64` key, which
# builds DEBUG (clippy) and finishes in ~1.5 min against this job's ~5. Exactly the
# amd64 collision described on the release key above — ci.yml won every save, this job
# restored a tree with no release artifacts and could never persist its own.
key: cargo-target-arm64-release-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-target-arm64-release-v1-${{ env.rustc }}-
- name: Build the arm64 client .deb
env:
@@ -534,6 +635,10 @@ jobs:
readelf -h target/aarch64-unknown-linux-gnu/release/punktfunk-session \
| grep -q AArch64 || { echo "ERROR: session binary is not AArch64"; exit 1; }
- name: sccache stats (visibility only)
if: always()
run: sccache --show-stats
- name: Publish to the Gitea apt registry
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
@@ -560,3 +665,57 @@ jobs:
for DEB in dist/*.deb; do
upsert_asset "$RID" "$DEB"
done
# ---------------------------------------------------------------------------------------------
# Does the thing we just published actually INSTALL? Nothing in this repo asked that before, and
# the cost of not asking was two independent, long-lived facts nobody knew:
# * `punktfunk-host` has installed cleanly on Debian 13 for a long time — while docs-site said
# "Debian isn't a supported target … nobody has verified it".
# * `punktfunk-gamescope` was missing from apt entirely across two releases.
# Both are exactly what a five-minute install check catches, so it is now a job.
#
# It runs on the RUNNER (no `container:`) and drives docker directly — the same access
# docker.yml's image builds use — because the check must happen in a pristine distro image, not
# in a builder image with the deps already present.
#
# It installs FROM THE REGISTRY, after the publish jobs, rather than from a local .deb: that
# exercises the real path a user takes (repo key, apt distribution, dependency resolution against
# the distro's own package set), and it matches this workflow's established order — publish the
# good artifacts first, go red afterwards, never let a gate withhold a shipping fix.
smoke-install:
needs: [build-publish, build-publish-host, build-publish-gamescope]
# `needs` for ORDER only — this must still run when a builder went red, or the failure that
# matters most (a package that did not publish) is exactly the one that skips its own check and
# leaves the run looking merely "partly red" instead of saying what a user would hit.
if: ${{ !cancelled() }}
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
# Recomputes the SAME version string the builder jobs stamped — pf-version.sh is
# deterministic per commit and GITHUB_RUN_NUMBER is shared across a run's jobs — so the check
# below can insist the registry is serving THIS run's build. Without that, a smoke job that
# beats the index regeneration installs the previous build, passes, and proves nothing.
- name: Channel + expected version
run: |
git config --global --add safe.directory "$PWD"
eval "$(bash scripts/ci/pf-version.sh)"
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
case "$GITHUB_REF" in
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; DIST=stable ;;
*) V="${PF_BASE}~ci${GITHUB_RUN_NUMBER}.g${SHORT}"; DIST=canary ;;
esac
echo "DISTRIBUTION=$DIST" >> "$GITHUB_ENV"
echo "EXPECT_VERSION=$V" >> "$GITHUB_ENV"
echo "expecting $V in apt distribution '$DIST'"
# The support matrix, asserted rather than asserted-in-prose. Each row names the packages
# that MUST install on that distro; anything absent from the row is expected not to and is
# not checked here (the client's glibc 2.43 floor keeps it off 24.04 and Debian 13 —
# see docs-site/content/docs/debian.md).
- name: Install from the apt registry on every supported distro
run: bash scripts/ci/deb-install-smoke.sh
env:
PF_APT_DISTRIBUTION: ${{ env.DISTRIBUTION }}
PF_EXPECT_VERSION: ${{ env.EXPECT_VERSION }}
+6
View File
@@ -96,6 +96,12 @@ jobs:
# (rust-ci's 26.04 build is uninstallable there). Consumed by deb.yml's build-publish-host job.
- image: punktfunk-rust-ci-noble
dockerfile: ci/rust-ci-noble.Dockerfile
# Debian 13 gamescope builder. The gamescope .deb used to be built in the noble image
# and NEVER once succeeded there — noble's wayland is 1.22.0 and the vendored wlroots
# 0.19.3 floors it at 1.23.1, so two releases shipped without the package. trixie is the
# oldest apt distro the tree configures on. Consumed by deb.yml's build-publish-gamescope.
- image: punktfunk-gamescope-trixie
dockerfile: ci/gamescope-trixie.Dockerfile
- image: punktfunk-fedora-rpm
dockerfile: ci/fedora-rpm.Dockerfile
# Fedora 44 builder (Fedora KDE spin): same Dockerfile, newer base → libavcodec.so.62.
+2 -2
View File
@@ -7,7 +7,7 @@
# Gitea has NO flatpak/ostree registry, so the bundle lives in the generic registry:
# PUT https://git.unom.io/api/packages/unom/generic/punktfunk-client-flatpak/<version>/<file>
# GET https://git.unom.io/api/packages/unom/generic/punktfunk-client-flatpak/<version>/<file>
# On tags the bundle is ALSO attached to the Gitea release (mirrors release.yml's DMG).
# On tags the bundle is ALSO attached to the Gitea release (mirrors apple.yml's DMG).
#
# PRIVILEGED-BUILD CONSTRAINT: flatpak-builder runs bubblewrap, which needs user namespaces.
# In a Gitea/act_runner Docker executor that means the job container must be --privileged
@@ -37,7 +37,7 @@ on:
# binary's dependency closure must be listed here — including the native decode rungs, or a
# commit that only touches the decoder never rebuilds the bundle and the Deck canary quietly
# stops tracking it. pf-dxvadec is absent on purpose: it is `cfg(windows)` in pf-client-core
# and never enters the Linux closure (windows.yml / windows-msix.yml carry it instead).
# and never enters the Linux closure (windows-client.yml carries it instead).
paths:
- 'clients/linux/**'
- 'clients/session/**'
+24 -10
View File
@@ -29,6 +29,12 @@ env:
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# The C/C++ half of the cache — same wiring as ci.yml/deb.yml. Safe at workflow level: no
# cross-compiling job here.
CMAKE_C_COMPILER_LAUNCHER: sccache
CMAKE_CXX_COMPILER_LAUNCHER: sccache
CC_x86_64_unknown_linux_gnu: sccache cc
CXX_x86_64_unknown_linux_gnu: sccache c++
# sccache and incremental compilation are mutually exclusive; CI wants the shared
# cache, dev boxes keep incremental.
CARGO_INCREMENTAL: "0"
@@ -45,14 +51,9 @@ jobs:
- uses: actions/checkout@v4
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this fetch keeps the job green while the running :latest predates the bake.
# images; this heals the job while the running :latest predates the bake.
- name: sccache (no-op once the image bakes it)
run: |
command -v sccache >/dev/null 2>&1 || {
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'
}
sccache --version
run: sh scripts/ci/ensure-sccache.sh
# Client link deps (baked into the image; kept here so the job is green across image
# rebuilds — a no-op once present) PLUS the headless-render extras: a virtual X server,
@@ -67,7 +68,8 @@ jobs:
libgl1-mesa-dri mesa-vulkan-drivers \
adwaita-icon-theme fonts-cantarell fonts-dejavu-core
# Reuse the workspace cargo caches (same keys as ci.yml/deb.yml).
# Reuse the workspace cargo caches. The cargo-home (download) cache is shared verbatim —
# it is profile-independent.
- name: Cache keys
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
- uses: actions/cache@v4
@@ -80,8 +82,20 @@ jobs:
- uses: actions/cache@v4
with:
path: target
key: cargo-target-v3-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-target-v3-${{ env.rustc }}-
# This job builds RELEASE (see the build step) in the same image and target layout as
# deb.yml's `build-publish`, so it wants THAT tree — it used to name ci.yml's key, which
# holds a debug build and gave it nothing. (Third participant in the collision documented
# on ci.yml's `cargo-target-debug-v3-` key.)
#
# Its OWN exact key with deb's prefix as a FALLBACK restore-key, deliberately: both
# workflows run on a v* tag, and an exact-key match would make them race for the single
# save slot — this job builds one crate, so if it won that race it would replace deb's
# full release tree with a nearly empty one for the rest of the lockfile's life. This way
# it always READS the warm tree and never blocks the job that fills it.
key: cargo-target-shots-release-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
restore-keys: |
cargo-target-shots-release-v1-${{ env.rustc }}-
cargo-target-release-v3-${{ env.rustc }}-
- name: Build client
run: cargo build --release -p punktfunk-client-linux --locked
-555
View File
@@ -1,555 +0,0 @@
# Production Apple client builds — runs on the macos-arm64 runner (home-mac-mini-1).
#
# Tag v* (or workflow_dispatch):
# macOS (Developer ID) -> sandboxed, signed, notarized + stapled .dmg, attached to a
# Gitea release on tag pushes
# macOS (App Store) -> archive + upload to TestFlight (App Store Connect)
# iOS -> archive + upload straight to TestFlight (App Store Connect)
# tvOS -> archive + upload to TestFlight (Rust core built from tier-3 targets,
# nightly -Zbuild-std, in build-xcframework.sh)
#
# One App Store listing for all platforms (universal purchase): every target shares the
# bundle ID io.unom.punktfunk.
#
# The macOS app is App-SANDBOXED for both channels (Config/Punktfunk-macOS.entitlements —
# app-sandbox + network client/server + audio-input + bluetooth/usb device access; the
# shared Config/Punktfunk.entitlements stays iOS/tvOS-only, where app-sandbox is invalid).
# The Developer ID DMG is codesigned with the SAME macOS entitlements as the App Store build,
# BUT it must ALSO embed a Developer ID provisioning profile: keychain-access-groups is a
# MANAGED entitlement that AMFI only honors when an embedded profile authorizes it. A DMG
# without one is SIGKILLed at spawn ("Launchd job spawn failed", POSIX errno 163) even though
# it is validly signed AND notarized. ⌘R hides this (Xcode embeds a development profile); the
# raw Developer ID codesign path does NOT, so ⌘R is NOT equivalent to the shipped DMG here.
#
# macOS App Store prerequisites (one-time, Apple portal — NOT done by this workflow; the
# step is continue-on-error until they exist):
# * App Store Connect: add the macOS platform to the io.unom.punktfunk app record
# (universal purchase).
# * A "Punktfunk macOS App Store Distribution" provisioning profile installed on the
# runner (under ~/Library/Developer/Xcode/UserData/Provisioning Profiles/).
# * The "3rd Party Mac Developer Installer" (Mac Installer Distribution) certificate in
# the runner's login keychain, in addition to "Apple Distribution" — the App Store
# .pkg is installer-signed with it.
#
# macOS Developer ID (DMG) prerequisite (one-time, Apple portal — the DMG step embeds it):
# * A "Punktfunk macOS Developer ID" provisioning profile (Distribution -> Developer ID,
# App ID io.unom.punktfunk, with the Keychain Sharing capability) installed on the runner
# under ~/Library/Developer/Xcode/UserData/Provisioning Profiles/. It authorizes the
# managed keychain-access-groups entitlement; without it the DMG is SIGKILLed at launch
# (errno 163). If it is missing the DMG step warns and strips that entitlement (the app
# then uses ClientIdentityStore's legacy file-keychain fallback) so the build still ships
# a launchable app.
#
# Signing setup (NOT secret-based anymore): the runner is a LaunchAgent in the user's
# logged-in Aqua session, so it uses the **login keychain** directly. Install the signing
# identities there once via Xcode (Settings -> Accounts -> Manage Certificates): Developer
# ID Application + Apple Distribution, with the WWDR intermediate present (so they show as
# *valid*). xcodebuild/codesign then sign exactly like a local build — no throwaway keychain.
# One-time, to avoid headless "codesign wants to use the key" prompts, grant codesign access:
# security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k <login-pw> \
# ~/Library/Keychains/login.keychain-db
#
# Secrets: only ASC_API_KEY_P8 / ASC_API_KEY_ID / ASC_API_ISSUER_ID (App Store Connect API
# key — notarization, TestFlight upload, automatic-signing profile fetch).
#
# Needs a RELEASE Xcode on the runner (App Store rejects beta-SDK builds); the workflow
# picks the first non-beta /Applications/Xcode*.app and only falls back to a beta with a
# loud warning.
name: release
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
# Canary: a relevant main push uploads the iOS + macOS + tvOS builds to TestFlight (Apple's
# own canary channel) — no notarized DMG (that's stable-only; see the per-step gates).
# Heavy on the shared mac-mini runner, so paths-filtered; the TestFlight steps are
# continue-on-error until the App Store Connect record exists, so this no-ops until then.
branches: [main]
paths:
- 'clients/apple/**'
- 'crates/punktfunk-core/**'
- 'scripts/build-xcframework.sh'
- 'Cargo.lock'
- '.gitea/workflows/release.yml'
# Stable: a `vX.Y.Z` tag is THE release — notarized DMG attached to the unified Gitea Release
# + macOS/iOS/tvOS to TestFlight for manual promotion to the App Store.
tags: ['v*']
workflow_dispatch:
inputs:
testflight:
description: "Upload the iOS build to TestFlight (true/false)"
required: false
default: "true"
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io — the mini resolves it via
# the router, i.e. the hairpin path whose TLS always validated). Covers every cargo/rustc
# invocation build-xcframework.sh makes, incl. the tvOS -Zbuild-std std builds; the Swift
# side stays on DerivedData (sccache doesn't cache swiftc).
env:
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: unom-ci-sccache
SCCACHE_ENDPOINT: https://storage.unom.io
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# sccache and incremental compilation are mutually exclusive; the shared cache makes the
# runner's persistent target/ disposable instead of precious.
CARGO_INCREMENTAL: "0"
jobs:
apple:
runs-on: macos-arm64
timeout-minutes: 120
env:
TEAM_ID: F4H37KF6WC
PROJECT: clients/apple/Punktfunk.xcodeproj
steps:
- uses: actions/checkout@v4
- name: Select release Xcode
run: |
DEV_DIR=""
for app in /Applications/Xcode.app /Applications/Xcode_*.app /Applications/Xcode-*.app; do
case "$app" in *beta*|*Beta*) continue;; esac
[ -x "$app/Contents/Developer/usr/bin/xcodebuild" ] && DEV_DIR="$app/Contents/Developer" && break
done
if [ -z "$DEV_DIR" ]; then
for app in /Applications/Xcode*.app; do
[ -x "$app/Contents/Developer/usr/bin/xcodebuild" ] && DEV_DIR="$app/Contents/Developer" && break
done
echo "::warning::No release Xcode found — using $DEV_DIR. TestFlight/App Store REJECTS beta-SDK builds."
fi
[ -n "$DEV_DIR" ] || { echo "no usable Xcode found" >&2; exit 1; }
# Scoped to xcodebuild steps only (XCODE_DEV_DIR, not DEVELOPER_DIR): cargo must
# keep the system-default linker — a newer-than-OS Xcode's ld produces dylibs the
# running dyld rejects, killing proc-macro loads (see build-xcframework.sh).
echo "XCODE_DEV_DIR=$DEV_DIR" >> "$GITHUB_ENV"
DEVELOPER_DIR="$DEV_DIR" xcodebuild -version
- name: Version from tag
run: |
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE, PF_CHANNEL, PF_STABLE_TAG (single source of truth)
case "$GITHUB_REF" in
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; V="${V%%-*}" ;; # App Store marketing version is numeric X.Y.Z (drop -rc)
*) V="$PF_BASE" ;; # canary marketing version = one minor ahead of the latest stable tag; the build number disambiguates
esac
echo "VERSION=$V" >> "$GITHUB_ENV"
echo "BUILD_NUM=$GITHUB_RUN_NUMBER" >> "$GITHUB_ENV"
echo "version $V build $GITHUB_RUN_NUMBER (channel $PF_CHANNEL, latest stable ${PF_STABLE_TAG})"
- name: Rust toolchain (mac + iOS + tvOS slices)
run: |
RUSTUP="$(command -v rustup || echo "$HOME/.cargo/bin/rustup")"
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.
"$RUSTUP" toolchain install nightly --profile minimal
"$RUSTUP" component add rust-src --toolchain nightly
# The in-core Opus decode (surround) pulls audiopus_sys, which builds a vendored static libopus
# via CMake — keep the xcframework self-contained (no runtime libopus.dylib on end-user devices).
- name: CMake (for the vendored libopus audiopus_sys builds)
run: |
# Runner steps run with `bash --noprofile --norc`, so Homebrew's bin dir isn't on PATH —
# locate brew explicitly, install cmake if missing, and export its bin dir to GITHUB_PATH so
# the xcframework build step (audiopus_sys → vendored libopus) finds `cmake`.
for B in /opt/homebrew/bin/brew /usr/local/bin/brew; do [ -x "$B" ] && BREW="$B" && break; done
if [ -z "$BREW" ]; then echo "::error::Homebrew not found on the runner"; exit 1; fi
BREW_BIN="$(dirname "$BREW")"; export PATH="$BREW_BIN:$PATH"
command -v cmake >/dev/null || "$BREW" install cmake
echo "$BREW_BIN" >> "$GITHUB_PATH"
# Homebrew's CMake 4 dropped compatibility with the vendored libopus's pre-3.5
# `cmake_minimum_required`; treat 3.5 as the policy minimum (the cmake crate's child cmake
# inherits this from the env during the xcframework build).
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
# Shared compile cache. ~/.local/bin is on the runner daemon's PATH; GITHUB_PATH is
# belt-and-braces. bsdtar (macOS) globs by default — no --wildcards.
- name: sccache (self-healing install)
run: |
if ! command -v sccache >/dev/null; then
mkdir -p "$HOME/.local/bin"
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-aarch64-apple-darwin.tar.gz \
| tar -xz --strip-components=1 -C "$HOME/.local/bin" '*/sccache'
fi
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
sccache --version
- name: Pin + prune Xcode DerivedData
# Without -derivedDataPath, xcodebuild derives its DerivedData directory name from the
# PROJECT'S ABSOLUTE PATH — and act_runner rotates its workspace
# (~/.cache/act/<hash>/hostexecutor), so each rotation minted a brand new ~760 MB tree
# under ~/Library that nothing ever collected. 31 of them piled up in three days
# (~32 GB with the shared ModuleCache), filled the runner's boot volume, and failed
# v0.16.0's xcframework build with "No space left on device". Pinning one path makes the
# tree REUSED instead of multiplied — it also keeps the module cache warm between runs.
run: |
DD="$HOME/ci/derived-data/release"
mkdir -p "$DD"
echo "DERIVED_DATA=$DD" >> "$GITHUB_ENV"
# Safety net for trees the pin does not own: the legacy per-path ones from before this
# change, and anything another job leaves in the default root. Untouched for a week ⇒ gone.
if [ -d "$HOME/Library/Developer/Xcode/DerivedData" ]; then
find "$HOME/Library/Developer/Xcode/DerivedData" -mindepth 1 -maxdepth 1 \
-mtime +7 -exec rm -rf {} + 2>/dev/null || true
fi
echo "disk after prune:"; df -h /System/Volumes/Data | tail -1
- name: Build PunktfunkCore.xcframework (mac + iOS + tvOS)
# tvOS is a tier-3 target (nightly -Zbuild-std): slow on the first build, then cached on
# the self-hosted runner. Built on canary too so the tvOS archive/upload below runs on the
# same track as iOS/macOS (the nightly toolchain is installed unconditionally above).
run: BUILD_IOS=1 BUILD_TVOS=1 bash scripts/build-xcframework.sh
- name: Stage App Store Connect API key
env:
ASC_P8: ${{ secrets.ASC_API_KEY_P8 }}
run: |
printf '%s' "$ASC_P8" > "$RUNNER_TEMP/asc.p8"
chmod 600 "$RUNNER_TEMP/asc.p8"
- name: macOS — archive, codesign Developer ID, notarize, DMG
# Stable releases only — the notarized DMG is a Gatekeeper/direct-download artifact, not
# relevant to TestFlight testers (the canary channel). Skipped on canary main pushes.
if: startsWith(gitea.ref, 'refs/tags/v')
run: |
# Archive UNSIGNED, then codesign with the Developer ID Application identity from the
# login keychain. Unsigned archive sidesteps Xcode's keychain-access-groups
# provisioning-profile gate at archive time; we re-assert that authorization below by
# EMBEDDING a Developer ID profile before codesign (see the keychain note further down).
# Bundle is a single static binary.
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
-project "$PROJECT" -scheme Punktfunk \
-destination 'generic/platform=macOS' \
-archivePath "$RUNNER_TEMP/Punktfunk-macos.xcarchive" \
-derivedDataPath "$DERIVED_DATA" \
-skipMacroValidation -skipPackagePluginValidation \
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
CODE_SIGNING_ALLOWED=NO
APP="$RUNNER_TEMP/Punktfunk-macos.xcarchive/Products/Applications/Punktfunk.app"
# Sandboxed Developer ID: sign with the SAME macOS entitlements the App Store build
# uses. codesign won't expand $(AppIdentifierPrefix) — resolve it to the team prefix.
RESOLVED="$RUNNER_TEMP/macos.entitlements"
sed "s/\$(AppIdentifierPrefix)/${TEAM_ID}./g" \
clients/apple/Config/Punktfunk-macOS.entitlements > "$RESOLVED"
# keychain-access-groups is a MANAGED (restricted) entitlement: App Sandbox and the
# network/device keys are self-asserted for Developer ID, but a keychain access group
# must be AUTHORIZED by an embedded provisioning profile. Without one, AMFI refuses to
# spawn the sandboxed process at launch — "Launchd job spawn failed" (POSIX errno 163),
# SIGKILL before main() — even though the bundle is validly signed and notarized. Embed
# a "Developer ID" distribution profile for io.unom.punktfunk (Keychain Sharing) so its
# entitlements authorize the access group, exactly like the App Store build's profile
# does. Located by profile Name among the profiles installed on the runner (see header).
DEVID_PROFILE_NAME="Punktfunk macOS Developer ID"
PROFILE_SRC=""
for p in "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles/"*.provisionprofile \
"$HOME/Library/MobileDevice/Provisioning Profiles/"*.provisionprofile; do
[ -e "$p" ] || continue
NAME=$(security cms -D -i "$p" 2>/dev/null | plutil -extract Name raw - 2>/dev/null || true)
[ "$NAME" = "$DEVID_PROFILE_NAME" ] && PROFILE_SRC="$p" && break
done
if [ -n "$PROFILE_SRC" ]; then
# Must land BEFORE codesign so it's sealed into the bundle.
cp "$PROFILE_SRC" "$APP/Contents/embedded.provisionprofile"
echo "embedded Developer ID profile: $PROFILE_SRC"
else
# Fallback so a missing/expired profile NEVER reships the errno-163 brick: drop the
# managed entitlement and let ClientIdentityStore fall back to the legacy file keychain
# (its errSecMissingEntitlement path). Degraded (one Keychain prompt) but launchable.
echo "::warning::Developer ID profile '$DEVID_PROFILE_NAME' not installed on the runner — stripping keychain-access-groups so the DMG still launches (legacy file keychain). Create it in the Apple portal + install it on the runner to restore the no-prompt data-protection keychain."
/usr/libexec/PlistBuddy -c "Delete :keychain-access-groups" "$RESOLVED" 2>/dev/null || true
fi
codesign --force --options runtime --timestamp \
--entitlements "$RESOLVED" \
--sign "Developer ID Application" "$APP"
codesign --verify --strict --verbose=2 "$APP"
# Notarized DMG.
STAGE="$RUNNER_TEMP/dmg-stage"
mkdir -p "$STAGE"
cp -R "$APP" "$STAGE/"
ln -s /Applications "$STAGE/Applications"
DMG="$RUNNER_TEMP/Punktfunk-$VERSION.dmg"
hdiutil create -volname "Punktfunk" -srcfolder "$STAGE" -ov -format UDZO "$DMG"
DEVELOPER_DIR="$XCODE_DEV_DIR" xcrun notarytool submit "$DMG" --wait \
--key "$RUNNER_TEMP/asc.p8" \
--key-id "${{ secrets.ASC_API_KEY_ID }}" \
--issuer "${{ secrets.ASC_API_ISSUER_ID }}"
DEVELOPER_DIR="$XCODE_DEV_DIR" xcrun stapler staple "$DMG"
echo "DMG=$DMG" >> "$GITHUB_ENV"
- name: Attach DMG to the Gitea release (stable tags only)
if: startsWith(gitea.ref, 'refs/tags/v')
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
. scripts/ci/gitea-release.sh
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
upsert_asset "$RID" "$DMG" "Punktfunk-$VERSION.dmg"
- name: macOS App Store — archive + upload to TestFlight
if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true'
# Best-effort until the App Store Connect record has the macOS platform + the
# "Punktfunk macOS App Store Distribution" profile and the "3rd Party Mac Developer
# Installer" cert are on the runner (see the header). The macOS app is sandboxed
# (Config/Punktfunk-macOS.entitlements) — mandatory for the Mac App Store.
continue-on-error: true
run: |
# Separate archive from the Developer ID one above: App Store needs a signed, entitled
# archive that -exportArchive can re-sign for distribution, not the unsigned-then-codesign
# DMG path. Archive with AUTOMATIC signing (development). Why not a manually-specified
# profile (as this step used to do): the in-app license screens added a SwiftPM resource
# bundle (PunktfunkKit_PunktfunkKit), and a resource bundle is a product type that cannot
# carry a provisioning profile — a global PROVISIONING_PROFILE_SPECIFIER (here) or an
# sdk-scoped one (iOS/tvOS) lands on it and fails the archive ("does not support
# provisioning profiles"). Automatic signing assigns a profile only to the app and leaves
# the resource bundle (and the macOS-host macro plugins) alone, and bakes the sandbox
# entitlements in. -allowProvisioningUpdates lets Xcode sync the App ID capabilities and
# regenerate the managed *development* profile — needed because the App Groups capability
# (group.io.unom.punktfunk, in Config/Punktfunk-macOS.entitlements) invalidated the cached
# one. This is DEVELOPMENT signing against the Apple Development cert already in the
# keychain, so the App-Manager ASC key suffices. DISTRIBUTION signing happens in the export
# step below
# (manual, via the plist). Quit Xcode so it can't prune the manually-installed App Store
# distribution profile that export needs.
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
pkill -x Xcode 2>/dev/null || true
PROFILE="Punktfunk macOS App Store Distribution"
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
-project "$PROJECT" -scheme Punktfunk \
-destination 'generic/platform=macOS' \
-archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \
-derivedDataPath "$DERIVED_DATA" \
-skipMacroValidation -skipPackagePluginValidation \
-allowProvisioningUpdates \
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" \
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
CODE_SIGN_STYLE=Automatic \
DEVELOPMENT_TEAM="$TEAM_ID"
cat > "$RUNNER_TEMP/export-macos-appstore.plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key><string>app-store-connect</string>
<key>destination</key><string>upload</string>
<key>teamID</key><string>$TEAM_ID</string>
<key>signingStyle</key><string>manual</string>
<key>signingCertificate</key><string>Apple Distribution</string>
<key>installerSigningCertificate</key><string>3rd Party Mac Developer Installer</string>
<key>provisioningProfiles</key>
<dict><key>io.unom.punktfunk</key><string>$PROFILE</string></dict>
</dict>
</plist>
EOF
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -exportArchive \
-archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \
-exportOptionsPlist "$RUNNER_TEMP/export-macos-appstore.plist" \
-exportPath "$RUNNER_TEMP/export-macos-appstore" \
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}"
- name: iOS — archive + upload to TestFlight
if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true'
# Best-effort until the App Store Connect app record for io.unom.punktfunk exists.
continue-on-error: true
run: |
# Archive with AUTOMATIC signing (development) — see the macOS App Store step for the full
# rationale. The SwiftPM resource bundle (PunktfunkKit_PunktfunkKit, added with the in-app
# license screens) builds for iphoneos, so even the sdk-scoped PROVISIONING_PROFILE_SPECIFIER
# this step used to set matched it and failed the archive ("does not support provisioning
# profiles"). Automatic signing profiles only the app and leaves the resource bundle (and
# the macOS-host macro plugins) alone. -allowProvisioningUpdates lets Xcode sync the App ID
# capabilities and regenerate the managed *development* profiles for both io.unom.punktfunk
# AND the embedded io.unom.punktfunk.widgets — needed because adding the App Groups
# capability (group.io.unom.punktfunk, shared with the Widget/Live-Activity extension)
# invalidated the cached managed dev profile, which had no widgets profile at all. This is
# DEVELOPMENT signing against the Apple Development cert already in the keychain — no cert
# creation, so the App-Manager ASC key is sufficient (it only manages App IDs/dev profiles).
# DISTRIBUTION signing is the export step below (manual, via the plist) and is unaffected.
# A running Xcode.app prunes unrecognized profiles — quit it so the manually-installed
# App Store distribution profile survives for export.
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
pkill -x Xcode 2>/dev/null || true
PROFILE="Punktfunk iOS App Store Distribution"
# The embedded PunktfunkWidgetsExtension (bundle io.unom.punktfunk.widgets) is a second
# distribution artifact in the .ipa, so manual signing must map its App ID to its own
# App Store profile too — else exportArchive fails ("no profile for io.unom.punktfunk.widgets").
WIDGET_PROFILE="Punktfunk iOS Widgets App Store Distribution"
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
-project "$PROJECT" -scheme Punktfunk-iOS \
-destination 'generic/platform=iOS' \
-archivePath "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \
-derivedDataPath "$DERIVED_DATA" \
-skipMacroValidation -skipPackagePluginValidation \
-allowProvisioningUpdates \
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" \
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
CODE_SIGN_STYLE=Automatic \
DEVELOPMENT_TEAM="$TEAM_ID"
cat > "$RUNNER_TEMP/export-appstore.plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key><string>app-store-connect</string>
<key>destination</key><string>upload</string>
<key>teamID</key><string>$TEAM_ID</string>
<key>signingStyle</key><string>manual</string>
<key>signingCertificate</key><string>Apple Distribution</string>
<key>provisioningProfiles</key>
<dict>
<key>io.unom.punktfunk</key><string>$PROFILE</string>
<key>io.unom.punktfunk.widgets</key><string>$WIDGET_PROFILE</string>
</dict>
</dict>
</plist>
EOF
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -exportArchive \
-archivePath "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \
-exportOptionsPlist "$RUNNER_TEMP/export-appstore.plist" \
-exportPath "$RUNNER_TEMP/export-appstore" \
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}"
- name: iOS — export .ipa (Gitea release + run artifact)
# The TestFlight step above uploads straight to App Store Connect (destination=upload) and
# leaves NO .ipa on disk. Re-export the SAME archive with destination=export to get an
# App Store distribution-signed .ipa for the Gitea release + the run artifacts. Same gate as
# that archive; a warn+skip (never fails the best-effort iOS leg) if the archive is absent,
# e.g. a workflow_dispatch with testflight=false. NOTE: an App Store-signed .ipa installs
# only via TestFlight/App Store, not by direct sideload — it's a release/archival artifact.
if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true'
id: ios_ipa
run: |
ARCHIVE="$RUNNER_TEMP/Punktfunk-ios.xcarchive"
if [ ! -d "$ARCHIVE" ]; then
echo "::warning::iOS archive not found — skipping .ipa export"
exit 0
fi
PROFILE="Punktfunk iOS App Store Distribution"
WIDGET_PROFILE="Punktfunk iOS Widgets App Store Distribution"
# destination=export writes the .ipa to -exportPath; otherwise identical manual signing to
# the upload plist (both profiles, Apple Distribution). No ASC key needed — no network.
cat > "$RUNNER_TEMP/export-appstore-ipa.plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key><string>app-store-connect</string>
<key>destination</key><string>export</string>
<key>teamID</key><string>$TEAM_ID</string>
<key>signingStyle</key><string>manual</string>
<key>signingCertificate</key><string>Apple Distribution</string>
<key>provisioningProfiles</key>
<dict>
<key>io.unom.punktfunk</key><string>$PROFILE</string>
<key>io.unom.punktfunk.widgets</key><string>$WIDGET_PROFILE</string>
</dict>
</dict>
</plist>
EOF
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -exportArchive \
-archivePath "$ARCHIVE" \
-exportOptionsPlist "$RUNNER_TEMP/export-appstore-ipa.plist" \
-exportPath "$RUNNER_TEMP/export-ipa"
SRC=$(ls "$RUNNER_TEMP/export-ipa/"*.ipa 2>/dev/null | head -1)
[ -n "$SRC" ] || { echo "::warning::no .ipa was produced by export"; exit 0; }
mkdir -p "$GITHUB_WORKSPACE/dist"
IPA="$GITHUB_WORKSPACE/dist/Punktfunk-$VERSION.ipa"
mv "$SRC" "$IPA"
echo "IPA=$IPA" >> "$GITHUB_ENV"
echo "ipa=dist/Punktfunk-$VERSION.ipa" >> "$GITHUB_OUTPUT"
echo "exported $IPA"
- name: Attach .ipa to the workflow run
if: steps.ios_ipa.outputs.ipa != ''
# v3, not v4: Gitea's artifact backend identifies as GHES, which upload-artifact@v4 refuses
# (same reason as android.yml / apple.yml). Download is a zip of the .ipa.
uses: actions/upload-artifact@v3
with:
name: punktfunk-ios-ipa
path: ${{ steps.ios_ipa.outputs.ipa }}
if-no-files-found: warn
retention-days: 30
- name: Attach .ipa to the Gitea release (stable tags only)
if: startsWith(gitea.ref, 'refs/tags/v') && steps.ios_ipa.outputs.ipa != ''
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
. scripts/ci/gitea-release.sh
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
upsert_asset "$RID" "$IPA" "Punktfunk-$VERSION.ipa"
- name: tvOS — archive + upload to TestFlight
# Canary + stable, the same track as iOS/macOS — the tvOS xcframework slice is now built
# on every apple push (above), so this matches the iOS step's gate exactly.
if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true'
# Needs tvOS added to the App Store Connect app record + the tvOS platform installed
# on the runner (xcodebuild -downloadPlatform tvOS).
continue-on-error: true
run: |
# Archive with AUTOMATIC signing (development) — see the macOS App Store step. The SwiftPM
# resource bundle (PunktfunkKit_PunktfunkKit) builds for appletvos and rejected the
# sdk-scoped profile this step used to set; Automatic signing profiles only the app and
# leaves the resource bundle + the macOS-host macro plugins (OnceMacro/SwizzlingMacro/
# AssociationMacro) alone. -allowProvisioningUpdates lets Xcode sync the App ID capabilities
# and regenerate the managed *development* profile — the tvOS app carries the App Groups key
# (group.io.unom.punktfunk) too, which invalidated the cached one. DEVELOPMENT signing against
# the Apple Development cert already in the keychain, so the App-Manager ASC key suffices.
# DISTRIBUTION signing is the export step below (manual, plist).
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
pkill -x Xcode 2>/dev/null || true
PROFILE="Punktfunk tvOS App Store Distribution"
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
-project "$PROJECT" -scheme Punktfunk-tvOS \
-destination 'generic/platform=tvOS' \
-archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \
-derivedDataPath "$DERIVED_DATA" \
-skipMacroValidation -skipPackagePluginValidation \
-allowProvisioningUpdates \
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" \
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
CODE_SIGN_STYLE=Automatic \
DEVELOPMENT_TEAM="$TEAM_ID"
cat > "$RUNNER_TEMP/export-tvos.plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key><string>app-store-connect</string>
<key>destination</key><string>upload</string>
<key>teamID</key><string>$TEAM_ID</string>
<key>signingStyle</key><string>manual</string>
<key>signingCertificate</key><string>Apple Distribution</string>
<key>provisioningProfiles</key>
<dict><key>io.unom.punktfunk</key><string>$PROFILE</string></dict>
</dict>
</plist>
EOF
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -exportArchive \
-archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \
-exportOptionsPlist "$RUNNER_TEMP/export-tvos.plist" \
-exportPath "$RUNNER_TEMP/export-tvos" \
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}"
+73 -14
View File
@@ -51,6 +51,14 @@ env:
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# The C/C++ half of the cache (aws-lc-sys, the vendored libopus, openh264's C++). Safe at
# workflow level here: unlike ci.yml/deb.yml this workflow has no cross-compiling job whose
# image sets its own CC_x86_64_unknown_linux_gnu. See ci.yml's `rust` job for that trap.
# This matters twice per push — the f43 and f44 legs are the two longest jobs in the fleet.
CMAKE_C_COMPILER_LAUNCHER: sccache
CMAKE_CXX_COMPILER_LAUNCHER: sccache
CC_x86_64_unknown_linux_gnu: sccache cc
CXX_x86_64_unknown_linux_gnu: sccache c++
# sccache and incremental compilation are mutually exclusive; CI wants the shared
# cache, dev boxes keep incremental.
CARGO_INCREMENTAL: "0"
@@ -80,14 +88,9 @@ jobs:
- uses: actions/checkout@v4
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this fetch keeps the job green while the running :latest predates the bake.
# images; this heals the job while the running :latest predates the bake.
- name: sccache (no-op once the image bakes it)
run: |
command -v sccache >/dev/null 2>&1 || {
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'
}
sccache --version
run: sh scripts/ci/ensure-sccache.sh
# rpmbuild + git archive need the checkout trusted; cache the crates download.
# The client link deps are also baked into the fedora-rpm image, but this job runs
@@ -145,11 +148,63 @@ jobs:
echo "GROUP=$GROUP" >> "$GITHUB_ENV"
echo "rpm $V-$R -> group '$GROUP'"
# ── The web console, built once per (web+sdk content, bun) instead of once per leg ─────────
# Two legs run here (f43 + f44) and each built its own identical copy of a bundle that is a
# pure function of web/ and sdk/ — see the fuller note in deb.yml, whose key family this
# shares, so whichever job builds it first warms the rest of the fleet.
#
# ⚠ The build has to happen HERE, in the workspace, rather than being left to the spec. Two
# reasons, and both are load-bearing:
# * build-rpm.sh packages a `git archive` tarball and web/.output is gitignored, so a
# bundle sitting in the workspace is invisible to rpmbuild — it must be handed over by
# absolute path (PF_PREBUILT_WEB_OUTPUT -> the spec's pf_prebuilt_web macro).
# * the reverse direction is worse: the spec builds into rpmbuild's %{_topdir}, which
# build-rpm.sh creates with mktemp and removes on EXIT. A console built in there is gone
# before actions/cache's post step runs, so the cache would never populate and every run
# would be a miss that quietly rebuilt — the cache would look present and do nothing.
- name: Web console cache key
run: echo "bunver=$(bun --version 2>/dev/null || echo none)" >> "$GITHUB_ENV"
- name: Cache the built web console
id: webconsole
uses: actions/cache@v4
with:
path: web/.output
key: web-console-linux-bun${{ env.bunver }}-${{ hashFiles('web/**', 'sdk/**') }}
- name: Build the web console (cache miss only)
if: steps.webconsole.outputs.cache-hit != 'true'
run: |
cd web
bun install --frozen-lockfile --ignore-scripts
bun run build
# Same mandatory assertion as deb.yml — a missing or wrong-preset bundle must fail here, not
# become a quietly console-less RPM. The spec re-checks the marker on whatever it packages.
- name: The console must exist (cache hit or fresh build)
run: |
if [ ! -f web/.output/server/index.mjs ]; then
echo "::error::web/.output is missing — neither the cache restore nor the build produced it"
exit 1
fi
grep -q 'Bun\.serve' web/.output/server/index.mjs || {
echo "::error::web/.output is not a bun bundle (wrong nitro preset)"; exit 1; }
echo "web console present: $(du -sh web/.output | cut -f1)"
- name: Build RPM
# PF_WITH_WEB=1 / PF_WITH_SCRIPTING=1 → also build the punktfunk-web console + the
# punktfunk-scripting runner subpackages (the publish loop globs them in; the host RPM
# Recommends both). Both need bun (ensured in Prep).
run: PF_VERSION="$PF_VERSION" PF_RELEASE="$PF_RELEASE" PF_WITH_WEB=1 PF_WITH_SCRIPTING=1 bash packaging/rpm/build-rpm.sh
run: |
PF_VERSION="$PF_VERSION" PF_RELEASE="$PF_RELEASE" \
PF_WITH_WEB=1 PF_WITH_SCRIPTING=1 \
PF_PREBUILT_WEB_OUTPUT="$GITHUB_WORKSPACE/web/.output" \
bash packaging/rpm/build-rpm.sh
# Visibility only — the two RPM legs are the longest jobs in the fleet, so a cache
# regression here is the most expensive one to leave undetected.
- name: sccache stats (visibility only)
if: always()
run: sccache --show-stats
# Signs with packages@unom.io (org secret) and self-verifies before publish. On a v* tag a
# missing key FAILS the build rather than publishing unsigned RPMs into a gpgcheck=1 repo.
@@ -238,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
@@ -256,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.
@@ -317,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
+1 -1
View File
@@ -67,7 +67,7 @@ jobs:
. scripts/ci/gitea-release.sh
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
upsert_asset "$RID" "$SBOM_FILE"
# v3, not v4: Gitea's artifact backend rejects upload-artifact@v4 (see release.yml).
# v3, not v4: Gitea's artifact backend rejects upload-artifact@v4 (see apple.yml).
- name: Upload artifact (non-tag runs)
if: "!startsWith(github.ref, 'refs/tags/')"
uses: actions/upload-artifact@v3
+340
View File
@@ -0,0 +1,340 @@
# Windows CLIENT — build, lint, test and package, on a self-hosted windows-amd64 runner (host mode;
# the generic runner + MSVC/WinUI toolchain come from unom/infra's windows-runner/, punktfunk's own
# extras — WDK, Inno Setup, the ARM64 rustup target — self-provision via the "Ensure Windows
# toolchain" step, a fast no-op once present, so any runner with that label works).
#
# Covers BOTH client binaries: the WinUI 3 shell (windows-reactor + WASAPI + SDL3) and the
# punktfunk-session Vulkan client (pf-presenter/pf-client-core/pf-console-ui — every stream runs in
# it, spawned by the shell), plus punktfunk-cli, whose `punktfunk.exe` alias the MSIX manifest
# references.
#
# ⚠ WHY THIS IS ONE FILE. This was `windows.yml` (build+lint+test, DEBUG, x64 + arm64) and
# `windows-msix.yml` (build+package, RELEASE, x64 + arm64) — four full compiles of the same crates
# per client push, on ONE runner, from three copies of the same `paths:` list that had already
# started to drift. windows-host.yml learned the hard way that debug trees on this machine are pure
# liability: a second dep tree tips it into `cabac_decoder.cpp: fatal error C1069` building
# openh264-sys2's vendored C++, which is disk/temp exhaustion, not a source error. So there is now
# ONE release build per arch, and clippy/fmt/test run against it. Do not reintroduce a debug leg.
#
# Renamed from windows-msix.yml deliberately, and safely: `github.run_number` is REPO-WIDE in Gitea
# (consecutive runs of DIFFERENT workflows get consecutive numbers), so the canary MSIX version
# `<minor>.<run>.0` keeps climbing across the rename — on GitHub, where run_number is per-workflow,
# this same rename would have reset it to 1 and made every canary sort below the published ones.
#
# Two architectures from ONE x64 runner: x86_64-pc-windows-msvc natively and aarch64-pc-windows-msvc
# by cross-compiling. The x64 MSVC toolset ships an ARM64 cross compiler
# (VC\Tools\MSVC\<ver>\bin\Hostx64\arm64\cl.exe) and aarch64-pc-windows-msvc is a tier-2 Rust target
# with host tools, so no ARM64 runner is needed — the cc/cmake crates pick the ARM64 compiler from
# the target triple (SDL3 + libopus build-from-source cross-compile fine). The one thing the aarch64
# build can't do is *run* on the x64 host, so fmt + test run only for x64.
#
# ARM64 note: rust-skia publishes no aarch64-pc-windows-msvc prebuilt binaries, so the session builds
# --no-default-features there (no Skia console UI; streaming is unaffected) — flip when
# skia-binaries adds the target.
#
# NO FFmpeg here since M10 (design/client-native-decode.md §6): the client decodes with
# pf-vkdecode / pf-dxvadec / openh264+rav1d and links no libav* at all, so this workflow sets
# no FFMPEG_DIR, no PF_FFVK_VULKAN_INCLUDE and prepends nothing to PATH. The provisioning
# script still fetches the FFmpeg trees because the HOST keeps FFmpeg — windows-host.yml's
# `amf-qsv` leg link-imports them.
#
# The MSVC/WinUI toolchain (cargo/rustup on ASCII paths, NASM, CMake, LLVM, CARGO_HOME,
# CMAKE_POLICY_VERSION_MINIMUM, …) is baked into the runner's daemon env. Per-checkout / per-arch
# vars are set in a step:
# - CARGO_TARGET_DIR=C:\t… the runner's host workdir is buried deep under
# C:\Windows\System32\config\systemprofile\.cache\act\<hash>\hostexecutor\,
# so the default target\ path blows past Windows' MAX_PATH (260) inside the
# CMake-from-source builds (audiopus_sys / SDL3) — MSBuild's tracker then
# can't create its .tlog (DirectoryNotFoundException -> MSB6003). A short
# root keeps every nested path well under the limit (per-arch so the two
# matrix legs don't share a target dir).
#
# Steps use `shell: pwsh` (PowerShell 7) deliberately: Windows PowerShell 5.1's
# `Out-File -Encoding utf8` prepends a UTF-8 BOM that corrupts the first GITHUB_ENV line (that
# var silently never gets set). pwsh writes no BOM.
# The runner's daemon wrapper puts C:\Program Files\PowerShell\7 on PATH so the job finds pwsh.
#
# ── Packaging (the `Pack + sign MSIX` step onward; skipped on pull requests) ──────────────────────
#
# Publishes signed MSIX packages (x64 + ARM64) to Gitea's generic package registry, so Windows boxes
# can install a real package (Start tile, clean install/uninstall) instead of a loose exe.
#
# Registry (public, unom org): https://git.unom.io/unom/-/packages (generic group)
# Packaging internals: clients/windows/packaging/README.md.
#
# Versioning — single project version; MSIX requires a strictly 4-part numeric version, so:
# vX.Y.Z tag -> X.Y.Z.0 (THE release; any -rc/+meta pre-release suffix is dropped for MSIX).
# Published to the generic registry + the stable `latest/` alias + attached to the
# unified Gitea Release alongside every other platform's artifact.
# main push / dispatch -> <next-minor>.<run_number>.0 (canary; base is one minor ahead of the
# 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), 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.
#
# 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
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: [main]
# ONE list now, not three. The old windows.yml + windows-msix.yml pair carried this same set
# three times (push, pull_request, and the second file), which is exactly how a crate goes
# missing from one copy — windows-host.yml documents the "Cargo.lock luck" gap that produced.
paths:
- 'clients/windows/**'
- 'clients/session/**'
- 'crates/punktfunk-core/**'
- 'crates/pf-client-core/**'
- 'crates/pf-presenter/**'
- 'crates/pf-console-ui/**'
- 'crates/pf-bitstream/**'
- 'crates/pf-vkdecode/**'
- 'crates/pf-dxvadec/**'
- 'Cargo.lock'
- 'Cargo.toml'
- '.gitea/workflows/windows-client.yml'
tags: ['v*']
pull_request:
paths:
- 'clients/windows/**'
- 'clients/session/**'
- 'crates/punktfunk-core/**'
- 'crates/pf-client-core/**'
- 'crates/pf-presenter/**'
- 'crates/pf-console-ui/**'
- 'crates/pf-bitstream/**'
- 'crates/pf-vkdecode/**'
- 'crates/pf-dxvadec/**'
- 'Cargo.lock'
- 'Cargo.toml'
- '.gitea/workflows/windows-client.yml'
workflow_dispatch:
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io, LAN-pinned via ci-core's
# unbound). Keys include compiler hash + target + flags, so cross-OS/arch entries can
# never collide; every Rust job on every host feeds and reads one warm cache.
#
# NOTE the C/C++ launcher wiring the Linux workflows carry (CMAKE_*_COMPILER_LAUNCHER, CC_*) is
# deliberately NOT set here. This runner's failure mode under extra compiler processes is the
# C1069 disk/temp exhaustion documented in windows-host.yml, so sccache-for-MSVC is its own
# change, to be made with a measurement rather than folded into a reorganisation.
env:
REGISTRY: git.unom.io
OWNER: unom
PKG: punktfunk-client-windows
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: unom-ci-sccache
SCCACHE_ENDPOINT: https://storage.unom.io
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# sccache and incremental compilation are mutually exclusive; CI wants the shared
# cache, dev boxes keep incremental.
CARGO_INCREMENTAL: "0"
jobs:
# SECURITY: this job builds PULL-REQUEST code (attacker-controllable build.rs / cargo build) on the
# host-mode, persistent `windows-amd64` runner that the release-SIGNING steps below and
# windows-host.yml (which decrypt MSIX_CERT_PFX_B64 + REGISTRY_TOKEN to disk) also run on. Untrusted
# PR code could therefore persist on that machine or harvest signing material a later job exposes.
# The DEFINITIVE fix is operational and lives outside this file: enable Gitea's "require approval to
# run workflows for PRs from outside collaborators/forks", and/or route PR CI to isolated ephemeral
# runners. The `if:` below is only a backstop — it skips fork PRs where Gitea reports the fork flag,
# and FAILS OPEN (still runs) for same-repo PRs and on Gitea versions that don't populate it, so it
# never blocks internal PR CI.
client:
runs-on: windows-amd64
if: >-
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.fork != true
timeout-minutes: 90
strategy:
fail-fast: false
matrix:
include:
- arch: x64
target: x86_64-pc-windows-msvc
td: C:\t
session_flags: ''
- arch: arm64
target: aarch64-pc-windows-msvc
td: C:\t-a64
# No skia-binaries prebuilt for aarch64-pc-windows-msvc: the session ships
# without the Skia console UI on ARM64 (streaming unaffected) — flip when
# rust-skia adds the target.
session_flags: '--no-default-features'
steps:
- uses: actions/checkout@v4
- name: Ensure Windows toolchain (WDK, Inno Setup, ARM64 target)
shell: pwsh
run: ./scripts/ci/ensure-windows-toolchain.ps1
- name: Configure + version
shell: pwsh
run: |
# CARGO_TARGET_DIR (per-arch, short) dodges the MAX_PATH wall in the CMake-from-source
# crates (see this file's header). No FFMPEG_DIR: nothing in this package links libav*
# (M10), and pack-msix.ps1 no longer copies runtime DLLs from one.
"CARGO_TARGET_DIR=${{ matrix.td }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
rustup target add ${{ matrix.target }}
rustc --version
cargo --version
$pf = & "$env:GITHUB_WORKSPACE/scripts/ci/pf-version.ps1" # single source of truth: base is one minor ahead of the latest stable tag
$parts = if ($env:GITHUB_REF -like 'refs/tags/v*') {
# MSIX needs a purely-numeric 4-part version: drop any -rc/+meta pre-release suffix.
(($env:GITHUB_REF_NAME -replace '^v', '') -replace '[-+].*$', '').Split('.')
} else {
# Canary: <major>.<minor>.<run>.0 — major.minor track one minor ahead of stable, run climbs monotonically.
@($pf.PF_MAJOR, $pf.PF_MINOR, $env:GITHUB_RUN_NUMBER)
}
while ($parts.Count -lt 4) { $parts += '0' }
$v = ($parts[0..3] -join '.')
"MSIX_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
Write-Output "MSIX version $v arch ${{ matrix.arch }} target ${{ matrix.target }} target-dir ${{ matrix.td }}"
# All three client binaries, ONCE, in release. The shell spawns punktfunk-session.exe (a
# package sibling) for every stream, and punktfunk-cli builds the `punktfunk.exe` the manifest
# aliases and pack-msix.ps1 requires (bf981027 added the requirement without the build — the
# same gap 90c84ef4 closed for deb). --no-default-features on ARM64 is a no-op for the shell.
#
# Release, not debug, even for the lint/test legs below: a debug build here would compile the
# whole dep tree into a SECOND target dir and re-run openh264-sys2's vendored C++ through
# cc-rs's cl.exe fan-out, which is what tips this runner into C1069 (see the header).
- name: Build (release)
shell: pwsh
run: cargo build --release -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli ${{ matrix.session_flags }} --target ${{ matrix.target }}
- name: Clippy (-D warnings)
shell: pwsh
run: |
# Every crate in the `paths:` trigger above is named here: `cargo clippy -p X` BUILDS a
# dependency but only LINTS the packages it is given, so a decode crate that starts the
# run but is missing from this list would be gated by nothing.
$pkgs = @('-p','punktfunk-client-windows','-p','punktfunk-client-session','-p','punktfunk-cli','-p','pf-client-core','-p','pf-presenter','-p','pf-bitstream','-p','pf-vkdecode','-p','pf-dxvadec')
$sf = @()
if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { $sf = @('--no-default-features') } else { $pkgs += @('-p','pf-console-ui') }
cargo clippy --release @pkgs --all-targets @sf --target ${{ matrix.target }} -- -D warnings
if ($LASTEXITCODE) { throw "clippy" }
- name: Rustfmt check
if: matrix.arch == 'x64'
shell: pwsh
run: |
cargo fmt -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-dxvadec -- --check
if ($LASTEXITCODE) { throw "rustfmt" }
- name: Test
# x64 only: the aarch64 binaries cross-compile here but cannot RUN on this host.
if: matrix.arch == 'x64'
shell: pwsh
run: |
cargo test --release -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-dxvadec --target ${{ matrix.target }}
if ($LASTEXITCODE) { throw "tests" }
- name: sccache stats (visibility only)
if: always()
shell: pwsh
run: sccache --show-stats
# ── Packaging: pushes, tags and dispatch only. A PR gets the build/lint/test signal above and
# stops there — packing would sign with a throwaway cert and publish nothing.
- name: Pack + sign MSIX
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: |
& clients/windows/packaging/pack-msix.ps1 `
-Version $env:MSIX_VERSION -Arch ${{ matrix.arch }} `
-TargetDir ${{ matrix.td }}\${{ matrix.target }}\release -OutDir ${{ matrix.td }}\msix
- name: Publish to Gitea generic registry
if: github.event_name != 'pull_request'
shell: pwsh
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
$PSNativeCommandUseErrorActionPreference = $false
$base = "https://$($env:REGISTRY)/api/packages/$($env:OWNER)/generic/$($env:PKG)"
# 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.
# 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) {
# The generic registry makes a versioned path immutable and 409s a re-upload, so a tag
# re-run re-publishing the identical artifact must be tolerated as a no-op. (The channel
# alias below is delete-then-reuploaded and never 409s.) No curl -f, so we can read the
# status code instead of aborting on it.
$code = [int](curl.exe -sS -o NUL -w "%{http_code}" --user "enricobuehler:$($env:REGISTRY_TOKEN)" --upload-file "$f" "$url")
if ($LASTEXITCODE -ne 0) { throw "upload failed (curl exit $LASTEXITCODE): $url" }
if ($code -eq 409) { Write-Output "already published (409, immutable): $url"; return }
if ($code -lt 200 -or $code -ge 300) { throw "upload failed (HTTP $code): $url" }
Write-Output "published ($code): $url"
}
foreach ($f in $files) {
$name = Split-Path $f -Leaf
# 1) immutable, versioned path
Put $f "$base/$($env:MSIX_VERSION)/$name"
# 2) channel alias (delete-then-reupload; the generic registry 409s on an existing file)
$an = $aliasNames["$f"]
curl.exe -fsS -o NUL --user "enricobuehler:$($env:REGISTRY_TOKEN)" -X DELETE "$base/$alias/$an" 2>$null
Put $f "$base/$alias/$an"
}
# On a real release, also attach the MSIX (+ its .cer) to the unified Gitea Release. Both
# arch legs attach to the same release concurrently — the helper's create-or-fetch handles
# the race, and x64/arm64 filenames differ so the assets don't collide.
- name: Attach MSIX to the Gitea release (stable tags only)
if: startsWith(gitea.ref, 'refs/tags/v')
shell: pwsh
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
. scripts/ci/gitea-release.ps1
$rid = Ensure-GiteaRelease -Tag $env:GITHUB_REF_NAME -Name $env:GITHUB_REF_NAME -Prerelease 'auto'
foreach ($f in @($env:MSIX_PATH, $env:MSIX_CER_PATH)) {
if ($f -and (Test-Path $f)) { Upsert-GiteaAsset -ReleaseId $rid -File $f }
}
+4 -4
View File
@@ -9,7 +9,7 @@
# only live NVENC encode does, which defers to the RTX box.
#
# shell: pwsh deliberately (PowerShell 5.1's Out-File -Encoding utf8 prepends a BOM that corrupts the
# first GITHUB_ENV line — see windows.yml).
# first GITHUB_ENV line — see windows-client.yml).
name: windows-drivers
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
@@ -39,7 +39,7 @@ on:
jobs:
# SECURITY: builds PULL-REQUEST code on the host-mode, persistent `windows-amd64` runner shared with
# the release-signing jobs (windows-host.yml / windows-msix.yml). See windows.yml for the full
# the release-signing jobs (windows-host.yml / windows-client.yml). See windows-client.yml for the full
# rationale. Definitive fix is server-side (Gitea outside-collaborator approval + isolated PR
# runners); the `if:` is a fail-open backstop that never blocks internal PR CI.
probe-and-proto:
@@ -111,7 +111,7 @@ jobs:
- name: Build + test pf-driver-proto (MSVC)
run: |
# Short target dir to dodge MAX_PATH inside the deep act host workdir (see windows.yml).
# Short target dir to dodge MAX_PATH inside the deep act host workdir (see windows-client.yml).
$env:CARGO_TARGET_DIR = "C:\t\drv"
cargo build -p pf-driver-proto
cargo test -p pf-driver-proto
@@ -144,7 +144,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Ensure Windows toolchain (WDK, FFmpeg, Inno Setup, ARM64 target)
# Shared self-provision step (also used by windows.yml/windows-msix.yml/windows-host.yml) so
# Shared self-provision step (also used by windows-client.yml/windows-host.yml) so
# driver-build is self-sufficient on any windows-amd64 runner and never races a manually
# dispatched provisioning workflow landing on a different one. Path is relative to the job
# working-directory (packaging/windows/drivers). Near-noop once the toolchain is present.
+32 -6
View File
@@ -3,7 +3,7 @@
# pf-vdisplay virtual-display driver + the web management console + the opt-in plugin/script runner,
# run by scheduled tasks on a bundled bun) from one signed setup.exe. Runs on a self-hosted
# windows-amd64 runner
# (host mode; same MSVC/Windows-SDK/LLVM env as windows.yml — generic from unom/infra's
# (host mode; same MSVC/Windows-SDK/LLVM env as windows-client.yml — generic from unom/infra's
# windows-runner/, FFmpeg/Inno Setup self-provision via the "Ensure Windows toolchain" step below).
#
# Why an installer and not MSIX (like the client): the host installs a LocalSystem SCM service that
@@ -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
@@ -143,7 +149,7 @@ jobs:
"CMAKE_POLICY_VERSION_MINIMUM=3.5" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# FFMPEG_DIR: the BtbN lgpl-shared x64 tree, provisioned by
# scripts/ci/provision-windows-punktfunk-extras.ps1. The CLIENT used to link it too; since M10
# it links no libav* at all (windows.yml sets no FFMPEG_DIR), so this tree is the HOST's alone
# it links no libav* at all (windows-client.yml sets no FFMPEG_DIR), so this tree is the HOST's alone
# and the provisioning step keeps fetching it for that reason. The host's AMD/Intel AMF/QSV encode backend
# (--features amf-qsv) link-imports avcodec/avutil/swscale from it; pack-host-installer.ps1
# then bundles its bin\*.dll into the installer. LIBCLANG_PATH is in the runner daemon env.
@@ -415,12 +421,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 +472,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
-196
View File
@@ -1,196 +0,0 @@
# Build the punktfunk Windows client as signed MSIX packages (x64 + ARM64) and publish them to
# Gitea's generic package registry, so Windows boxes can download + install a real package (Start
# tile, clean install/uninstall) instead of a loose exe. Runs on a self-hosted windows-amd64
# runner (host mode; the MSVC/WinUI toolchain comes from unom/infra's windows-runner/, the rest
# self-provisions via the "Ensure Windows toolchain" step below, same as windows.yml) — the
# Windows SDK's makeappx/signtool are baked into the runner's daemon env.
#
# Both arches come off the ONE x64 runner: x86_64 natively, aarch64 cross-compiled (the x64 MSVC
# toolset has the ARM64 cross compiler). See windows.yml for the cross-build rationale + the
# BOM/MAX_PATH runner gotchas.
#
# NO FFmpeg since M10 (design/client-native-decode.md §6): the client decodes natively, so the
# package carries no libav* DLLs and this workflow sets no FFMPEG_DIR. The host installer
# (windows-host.yml) is unchanged.
#
# Registry (public, unom org): https://git.unom.io/unom/-/packages (generic group)
# Packaging internals: clients/windows/packaging/README.md.
#
# Versioning — single project version; MSIX requires a strictly 4-part numeric version, so:
# vX.Y.Z tag -> X.Y.Z.0 (THE release; any -rc/+meta pre-release suffix is dropped for MSIX).
# Published to the generic registry + the stable `latest/` alias + attached to the
# unified Gitea Release alongside every other platform's artifact.
# main push / dispatch -> <next-minor>.<run_number>.0 (canary; base is one minor ahead of the
# latest stable tag via scripts/ci/pf-version.ps1, run number climbs monotonically).
# Published to the generic registry + the `canary/` alias.
# 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).
#
# That fallback is 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-msix
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: [main]
paths:
- 'clients/windows/**'
- 'clients/session/**'
- 'crates/punktfunk-core/**'
- 'crates/pf-client-core/**'
- 'crates/pf-presenter/**'
- 'crates/pf-console-ui/**'
- 'crates/pf-bitstream/**'
- 'crates/pf-vkdecode/**'
- 'crates/pf-dxvadec/**'
- 'Cargo.lock'
- 'Cargo.toml'
- '.gitea/workflows/windows-msix.yml'
tags: ['v*']
workflow_dispatch:
env:
REGISTRY: git.unom.io
OWNER: unom
PKG: punktfunk-client-windows
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: unom-ci-sccache
SCCACHE_ENDPOINT: https://storage.unom.io
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# sccache and incremental compilation are mutually exclusive; CI wants the shared
# cache, dev boxes keep incremental.
CARGO_INCREMENTAL: "0"
jobs:
package:
runs-on: windows-amd64
timeout-minutes: 90
strategy:
fail-fast: false
matrix:
include:
- arch: x64
target: x86_64-pc-windows-msvc
td: C:\t
session_flags: ''
- arch: arm64
target: aarch64-pc-windows-msvc
td: C:\t-a64
# No skia-binaries prebuilt for aarch64-pc-windows-msvc: the session ships
# without the Skia console UI on ARM64 (streaming unaffected) — flip when
# rust-skia adds the target.
session_flags: '--no-default-features'
steps:
- uses: actions/checkout@v4
- name: Ensure Windows toolchain (WDK, Inno Setup, ARM64 target)
shell: pwsh
run: ./scripts/ci/ensure-windows-toolchain.ps1
- name: Configure + version
shell: pwsh
run: |
# CARGO_TARGET_DIR (per-arch, short) dodges the MAX_PATH wall in the CMake-from-source
# crates (see windows.yml). No FFMPEG_DIR: nothing in this package links libav* (M10),
# and pack-msix.ps1 no longer copies runtime DLLs from one.
"CARGO_TARGET_DIR=${{ matrix.td }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
rustup target add ${{ matrix.target }}
$pf = & "$env:GITHUB_WORKSPACE/scripts/ci/pf-version.ps1" # single source of truth: base is one minor ahead of the latest stable tag
$parts = if ($env:GITHUB_REF -like 'refs/tags/v*') {
# MSIX needs a purely-numeric 4-part version: drop any -rc/+meta pre-release suffix.
(($env:GITHUB_REF_NAME -replace '^v', '') -replace '[-+].*$', '').Split('.')
} else {
# Canary: <major>.<minor>.<run>.0 — major.minor track one minor ahead of stable, run climbs monotonically.
@($pf.PF_MAJOR, $pf.PF_MINOR, $env:GITHUB_RUN_NUMBER)
}
while ($parts.Count -lt 4) { $parts += '0' }
$v = ($parts[0..3] -join '.')
"MSIX_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
Write-Output "MSIX version $v arch ${{ matrix.arch }} target ${{ matrix.target }}"
# All three client binaries — the shell spawns punktfunk-session.exe (a package
# sibling) for every stream, and punktfunk-console.exe is the couch Start-menu tile's
# hand-off shim. --no-default-features on ARM64 is a no-op for the shell.
- name: Build (release)
shell: pwsh
# punktfunk-cli builds the `punktfunk.exe` the manifest aliases and pack-msix.ps1
# requires (bf981027 added the requirement without the build — same gap 90c84ef4
# closed for deb).
run: cargo build --release -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli ${{ matrix.session_flags }} --target ${{ matrix.target }}
- name: Pack + sign MSIX
shell: pwsh
env:
MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }}
MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }}
run: |
& clients/windows/packaging/pack-msix.ps1 `
-Version $env:MSIX_VERSION -Arch ${{ matrix.arch }} `
-TargetDir ${{ matrix.td }}\${{ matrix.target }}\release -OutDir ${{ matrix.td }}\msix
- name: Publish to Gitea generic registry
shell: pwsh
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
$PSNativeCommandUseErrorActionPreference = $false
$base = "https://$($env:REGISTRY)/api/packages/$($env:OWNER)/generic/$($env:PKG)"
# 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"
}
$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) {
# The generic registry makes a versioned path immutable and 409s a re-upload, so a tag
# re-run re-publishing the identical artifact must be tolerated as a no-op. (The channel
# alias below is delete-then-reuploaded and never 409s.) No curl -f, so we can read the
# status code instead of aborting on it.
$code = [int](curl.exe -sS -o NUL -w "%{http_code}" --user "enricobuehler:$($env:REGISTRY_TOKEN)" --upload-file "$f" "$url")
if ($LASTEXITCODE -ne 0) { throw "upload failed (curl exit $LASTEXITCODE): $url" }
if ($code -eq 409) { Write-Output "already published (409, immutable): $url"; return }
if ($code -lt 200 -or $code -ge 300) { throw "upload failed (HTTP $code): $url" }
Write-Output "published ($code): $url"
}
foreach ($f in $files) {
$name = Split-Path $f -Leaf
# 1) immutable, versioned path
Put $f "$base/$($env:MSIX_VERSION)/$name"
# 2) channel alias (delete-then-reupload; the generic registry 409s on an existing file)
$an = $aliasNames["$f"]
curl.exe -fsS -o NUL --user "enricobuehler:$($env:REGISTRY_TOKEN)" -X DELETE "$base/$alias/$an" 2>$null
Put $f "$base/$alias/$an"
}
# On a real release, also attach the MSIX (+ its .cer) to the unified Gitea Release. Both
# arch legs attach to the same release concurrently — the helper's create-or-fetch handles
# the race, and x64/arm64 filenames differ so the assets don't collide.
- name: Attach MSIX to the Gitea release (stable tags only)
if: startsWith(gitea.ref, 'refs/tags/v')
shell: pwsh
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
. scripts/ci/gitea-release.ps1
$rid = Ensure-GiteaRelease -Tag $env:GITHUB_REF_NAME -Name $env:GITHUB_REF_NAME -Prerelease 'auto'
foreach ($f in @($env:MSIX_PATH, $env:MSIX_CER_PATH)) {
if ($f -and (Test-Path $f)) { Upsert-GiteaAsset -ReleaseId $rid -File $f }
}
-169
View File
@@ -1,169 +0,0 @@
# Windows client CI — runs on a self-hosted windows-amd64 runner (host mode; the generic runner +
# toolchain come from unom/infra's windows-runner/; punktfunk's own extras - WDK, Inno Setup,
# the ARM64 rustup target - self-provision via the "Ensure Windows toolchain" step below, a fast
# no-op once already present, so any runner with that label works with no manual dispatch step
# first). Build + clippy + fmt + test BOTH client binaries: the WinUI 3 shell
# (windows-reactor + WASAPI + SDL3) and the punktfunk-session Vulkan client
# (pf-presenter/pf-client-core/pf-console-ui — every stream runs in it, spawned by the
# shell). ARM64 note: rust-skia publishes no aarch64-pc-windows-msvc prebuilt binaries, so the
# session builds --no-default-features there (no Skia console UI; streaming is unaffected) —
# flip when skia-binaries adds the target.
#
# NO FFmpeg here since M10 (design/client-native-decode.md §6): the client decodes with
# pf-vkdecode / pf-dxvadec / openh264+rav1d and links no libav* at all, so this workflow sets
# no FFMPEG_DIR, no PF_FFVK_VULKAN_INCLUDE and prepends nothing to PATH. The provisioning
# script still fetches the FFmpeg trees because the HOST keeps FFmpeg — windows-host.yml's
# `amf-qsv` leg link-imports them.
#
# Two architectures from ONE x64 runner: x86_64-pc-windows-msvc natively and
# aarch64-pc-windows-msvc by cross-compiling. The x64 MSVC toolset ships an ARM64 cross compiler
# (VC\Tools\MSVC\<ver>\bin\Hostx64\arm64\cl.exe) and aarch64-pc-windows-msvc is a tier-2 Rust
# target with host tools, so no ARM64 runner is needed — the cc/cmake crates pick the ARM64
# compiler from the target triple (SDL3 + libopus build-from-source cross-compile fine). The one
# thing the aarch64 build can't do is *run* on the x64 host, so fmt + test run only for x64.
#
# The MSVC/WinUI toolchain (cargo/rustup on ASCII paths, NASM, CMake, LLVM, CARGO_HOME,
# CMAKE_POLICY_VERSION_MINIMUM, …) is baked into the runner's daemon env. Per-checkout
# / per-arch vars are set in a step:
# - CARGO_TARGET_DIR=C:\t… the runner's host workdir is buried deep under
# C:\Windows\System32\config\systemprofile\.cache\act\<hash>\hostexecutor\,
# so the default target\ path blows past Windows' MAX_PATH (260) inside the
# CMake-from-source builds (audiopus_sys / SDL3) — MSBuild's tracker then
# can't create its .tlog (DirectoryNotFoundException -> MSB6003). A short
# root keeps every nested path well under the limit (per-arch so the two
# matrix legs don't share a target dir).
#
# Steps use `shell: pwsh` (PowerShell 7) deliberately: Windows PowerShell 5.1's
# `Out-File -Encoding utf8` prepends a UTF-8 BOM that corrupts the first GITHUB_ENV line (that
# var silently never gets set). pwsh writes no BOM.
# The runner's daemon wrapper puts C:\Program Files\PowerShell\7 on PATH so the job finds pwsh.
name: windows
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: [main]
paths:
- 'clients/windows/**'
- 'clients/session/**'
- 'crates/punktfunk-core/**'
- 'crates/pf-client-core/**'
- 'crates/pf-presenter/**'
- 'crates/pf-console-ui/**'
- 'crates/pf-bitstream/**'
- 'crates/pf-vkdecode/**'
- 'crates/pf-dxvadec/**'
- 'Cargo.lock'
- 'Cargo.toml'
- '.gitea/workflows/windows.yml'
pull_request:
paths:
- 'clients/windows/**'
- 'clients/session/**'
- 'crates/punktfunk-core/**'
- 'crates/pf-client-core/**'
- 'crates/pf-presenter/**'
- 'crates/pf-console-ui/**'
- 'crates/pf-bitstream/**'
- 'crates/pf-vkdecode/**'
- 'crates/pf-dxvadec/**'
- 'Cargo.lock'
- 'Cargo.toml'
- '.gitea/workflows/windows.yml'
workflow_dispatch:
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io, LAN-pinned via ci-core's
# unbound). Keys include compiler hash + target + flags, so cross-OS/arch entries can
# never collide; every Rust job on every host feeds and reads one warm cache.
env:
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: unom-ci-sccache
SCCACHE_ENDPOINT: https://storage.unom.io
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# sccache and incremental compilation are mutually exclusive; CI wants the shared
# cache, dev boxes keep incremental.
CARGO_INCREMENTAL: "0"
jobs:
# SECURITY: this job builds PULL-REQUEST code (attacker-controllable build.rs / cargo build) on the
# host-mode, persistent `windows-amd64` runner that the release-SIGNING jobs (windows-host.yml /
# windows-msix.yml, which decrypt MSIX_CERT_PFX_B64 + REGISTRY_TOKEN to disk) also run on. Untrusted
# PR code could therefore persist on that machine or harvest signing material a later job exposes.
# The DEFINITIVE fix is operational and lives outside this file: enable Gitea's "require approval to
# run workflows for PRs from outside collaborators/forks", and/or route PR CI to isolated ephemeral
# runners. The `if:` below is only a backstop — it skips fork PRs where Gitea reports the fork flag,
# and FAILS OPEN (still runs) for same-repo PRs and on Gitea versions that don't populate it, so it
# never blocks internal PR CI.
build:
runs-on: windows-amd64
if: >-
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.fork != true
timeout-minutes: 90
strategy:
fail-fast: false
matrix:
target: [x86_64-pc-windows-msvc, aarch64-pc-windows-msvc]
steps:
- uses: actions/checkout@v4
- name: Ensure Windows toolchain (WDK, Inno Setup, ARM64 target)
shell: pwsh
run: ./scripts/ci/ensure-windows-toolchain.ps1
- name: Configure + toolchain versions
shell: pwsh
run: |
# Per-arch short target root (dodges MAX_PATH; keeps the two legs from sharing target\).
$td = if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { 'C:\t-a64' } else { 'C:\t' }
"CARGO_TARGET_DIR=$td" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# No FFMPEG_DIR / PF_FFVK_VULKAN_INCLUDE / PATH prepend: the client links no libav*
# since M10 (see this file's header), so nothing here needs import libs or runtime DLLs.
# The HOST still does — windows-host.yml sets them for its amf-qsv leg.
rustup target add ${{ matrix.target }}
rustc --version
cargo --version
Write-Output "target ${{ matrix.target }} target-dir $td"
# Both client binaries. ARM64: no skia-binaries prebuilt for the target, so the session
# drops its `ui` feature there (pf-console-ui excluded; --no-default-features is a no-op
# for the shell, which has no features).
# punktfunk-cli is in every gate: windows-msix.yml ships its `punktfunk.exe` alias, so
# a CLI that only the release workflow compiles is a release-day surprise. Its tests
# RUN the binary (help contract), as the session's contract_smoke runs the session —
# the gate class that catches a compiling-but-wrong binary (the 0.22.0 clobber).
- name: Build
shell: pwsh
run: |
$sf = @(); if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { $sf = @('--no-default-features') }
cargo build -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli @sf --target ${{ matrix.target }}
- name: Clippy (-D warnings)
shell: pwsh
run: |
# Every crate in the `paths:` trigger above is named here: `cargo clippy -p X` BUILDS a
# dependency but only LINTS the packages it is given, so a decode crate that starts the
# run but is missing from this list would be gated by nothing.
$pkgs = @('-p','punktfunk-client-windows','-p','punktfunk-client-session','-p','punktfunk-cli','-p','pf-client-core','-p','pf-presenter','-p','pf-bitstream','-p','pf-vkdecode','-p','pf-dxvadec')
$sf = @()
if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { $sf = @('--no-default-features') } else { $pkgs += @('-p','pf-console-ui') }
cargo clippy @pkgs --all-targets @sf --target ${{ matrix.target }} -- -D warnings
- name: Rustfmt check
if: matrix.target == 'x86_64-pc-windows-msvc'
shell: pwsh
run: cargo fmt -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-dxvadec -- --check
- name: Test
if: matrix.target == 'x86_64-pc-windows-msvc'
shell: pwsh
run: cargo test -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-dxvadec --target ${{ matrix.target }}
+1345 -2
View File
File diff suppressed because it is too large Load Diff
Generated
+847 -1150
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -65,9 +65,9 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.27.0"
edition = "2021"
rust-version = "1.82"
version = "0.28.1"
edition = "2024"
rust-version = "1.85"
license = "MIT OR Apache-2.0"
authors = ["unom"]
repository = "https://git.unom.io/unom/punktfunk"
+1 -1
View File
@@ -100,7 +100,7 @@ installer (all-vendor: NVIDIA, AMD, Intel).
| Platform | Install | Guide |
|--------|---------|-------|
| **Ubuntu / Debian** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu / Debian](https://docs.punktfunk.unom.io/docs/ubuntu) · [packaging/debian](packaging/debian/README.md) |
| **Ubuntu 26.04+ / Debian 13+** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu](https://docs.punktfunk.unom.io/docs/ubuntu) · [Debian](https://docs.punktfunk.unom.io/docs/debian) · [packaging/debian](packaging/debian/README.md) |
| **Bazzite / Fedora Atomic** (systemd-sysext) | `curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh && sudo bash punktfunk-sysext.sh install` *(no layering, no reboot; rpm-ostree + bootc also supported)* | [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
| **Fedora** (dnf) | `sudo dnf install punktfunk` *(after adding the repo; the console comes with it)* | [Fedora](https://docs.punktfunk.unom.io/docs/fedora) · [packaging/rpm](packaging/rpm/README.md) |
| **Arch / CachyOS** (pacman) | `sudo pacman -Syu punktfunk-host` *(binary repo — always a full `-Syu`)* | [Arch Linux](https://docs.punktfunk.unom.io/docs/arch) · [packaging/arch](packaging/arch/README.md) |
+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.**
+1835 -1304
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -40,7 +40,6 @@ accepted = [
"CC0-1.0",
"Unlicense",
"WTFPL",
"OpenSSL",
]
# cbindgen is MPL-2.0 but it is a BUILD-ONLY codegen tool that never links into a shipped artifact
@@ -57,7 +56,8 @@ ignore-dev-dependencies = true
# accepted arm on its own (MIT/Apache-2.0 are globally accepted), so it needs no entry. (It is
# also UEFI-target-gated out of every shipped build.)
#
# ring's license is an AND of permissive terms including the OpenSSL license; accept the
# OpenSSL/ISC parts for this crate only, not globally.
[ring]
accepted = ["OpenSSL", "ISC"]
# There is deliberately NO per-crate entry here any more. `ring` used to need one (its licence is an
# AND that includes the OpenSSL licence, which was accepted for that crate alone), but the crypto
# backend moved to aws-lc-rs and the ureq 2 → 3 upgrade removed ring from every target we build.
# aws-lc-sys 0.44's SPDX is an AND of ISC / Apache-2.0 / MIT / BSD-3-Clause / MIT-0 — all globally
# accepted above — and carries no OpenSSL clause, so `OpenSSL` left the global list with ring.
+108 -12
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.27.0"
"version": "0.28.0"
},
"paths": {
"/api/v1/clients": {
@@ -45,6 +45,36 @@
}
}
}
},
"delete": {
"tags": [
"clients"
],
"summary": "Unpair every client",
"description": "The collection form of [`unpair_client`]: empties the pairing store in ONE persisted write,\ncarrying the same revocation guarantees across the whole set. A LIVE GameStream session is\nended (its owning certificate is necessarily one of those just removed), and the ENet control\nport (UDP 47999) closes, because no pairing is left to hold it open.\n\nIdempotent, and so a 200 rather than the single unpair's 204/404 pair: \"unpair everything\" is\nsatisfied by an already-empty store, and the operator still wants to know whether that meant\nthree devices or none.",
"operationId": "unpairAllClients",
"responses": {
"200": {
"description": "Every client unpaired (possibly none)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnpairAllResult"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/clients/{fingerprint}": {
@@ -997,7 +1027,7 @@
"library"
],
"summary": "List the game library",
"description": "Every installed-store title (Steam, read from the host's local files — no Steam API key)\nmerged with the user's custom entries, sorted by title. Artwork fields are URLs the client\nfetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the\nentries a given external provider owns; `?platform=` to one platform (case-insensitive —\ninstalled-store titles are `PC`, custom/provider entries carry whatever was authored).\n\n**The operator's own lane additionally sees the titles they have HIDDEN**, each carrying\n`hidden: true`; every other lane gets them filtered out upstream and cannot tell they exist. The\nconsole needs them to offer \"un-hide\", and it is the only surface that does.",
"description": "Every title this host knows about, sorted by title: the entries each installed library plugin\nhas synced (Steam, Lutris, Heroic, Epic, GOG, Xbox, Playnite, ROM managers, …) plus the user's\nown custom entries. Artwork fields are URLs the client fetches directly, except local files on\nthe host, which are rewritten to this API's own art proxy. `?provider=` narrows to the entries a\ngiven external provider owns; `?platform=` to one platform (case-insensitive — whatever the\nsource authored, conventionally `PC` for desktop stores).\n\n**The operator's own lane additionally sees the titles they have HIDDEN**, each carrying\n`hidden: true`; every other lane gets them filtered out upstream and cannot tell they exist. The\nconsole needs them to offer \"un-hide\", and it is the only surface that does.",
"operationId": "getLibrary",
"parameters": [
{
@@ -1052,7 +1082,7 @@
"library"
],
"summary": "Fetch one cover-art image for a library entry",
"description": "Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams\nthe image bytes. Any id stored in the host's catalog (manual entries, provider-synced entries,\nand a library plugin's claimed-store entries) serves its local art file. A Steam title falls back\nto the in-host scanner's resolver: the host's own local Steam cache first (exact — it's what the\nuser's Steam client already shows for it), the public Steam CDN's flat URL convention second\n(newer titles' CDN assets can live at a per-asset-hash path the host can't predict, in which case\nthis 404s and the client falls through to its next art candidate).",
"description": "Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams\nthe image bytes. Any id stored in the host's catalog (manual entries, provider-synced entries,\nand a library plugin's claimed-store entries) serves its local art file; anything else 404s and\nthe client falls through to its next art candidate.\n\nThe host fetches nothing here. Art a plugin published as an `http(s)` URL is fetched by the\nclient directly — this proxy exists for the *local* files a plugin finds on the host's own disk\n(a launcher's cover cache), which a client has no way to read.",
"operationId": "getLibraryArt",
"parameters": [
{
@@ -1380,7 +1410,7 @@
"library"
],
"summary": "Replace a provider's library entries (declarative reconcile)",
"description": "Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the\nprovider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each\nsurviving title's host id stable across reconciles, drops orphans, and never touches manual\nentries or other providers'. An empty array removes everything the provider owns. Emits\n`library.changed` with the provider as `source`.\n\n`?store=` additionally **claims** that store for the provider: its entries then surface with\ndeterministic `<store>:<external_id>` ids and the store's own badge, instead of opaque\n`custom:<id>` ones — which is what lets a library plugin reproduce the entries an in-host scanner\nused to produce, right down to the GameStream app ids and client-side art caches. One provider\nper store; a second claimant gets 409. While a claim is held the matching built-in scanner is\nsuppressed, so the two never double-list. The claim is released by `DELETE`, not by an empty\nreconcile (a store can legitimately have zero installed titles).",
"description": "Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the\nprovider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each\nsurviving title's host id stable across reconciles, drops orphans, and never touches manual\nentries or other providers'. An empty array removes everything the provider owns. Emits\n`library.changed` with the provider as `source`.\n\n`?store=` additionally **claims** that store for the provider: its entries then surface with\ndeterministic `<store>:<external_id>` ids and the store's own badge, instead of opaque\n`custom:<id>` ones — which is what let a library plugin reproduce the entries the in-host scanner\nused to produce, right down to the GameStream app ids and client-side art caches, and is why\nremoving those scanners changed nothing downstream. One provider per store; a second claimant\ngets 409. The claim is released by `DELETE`, not by an empty reconcile (a store can legitimately\nhave zero installed titles).",
"operationId": "reconcileProviderEntries",
"parameters": [
{
@@ -1538,8 +1568,8 @@
"tags": [
"library"
],
"summary": "List the library scanners",
"description": "The installed-store scanners this host supports — the list is platform-dependent (Steam\neverywhere; Lutris + Heroic on Linux; Epic, GOG, and Xbox/Game Pass on Windows), so the console\nrenders a toggle only for scanners that can do anything here. Scanners default to enabled;\ndisabling one hides its titles from every library surface from the next read. The user-curated\ncustom store is not a scanner and is always on.",
"summary": "List the library sources",
"description": "Every game source on this host with its enable state — one row per installed library plugin\n(Steam, Lutris, Heroic, Epic, GOG, Xbox, Playnite, ROM managers, …), so the list reflects what\nthe operator has actually installed rather than what this build happens to support. Sources\ndefault to enabled; disabling one hides its titles from every library surface from the next\nread. The user-curated custom store is not a source and is always on.\n\nOlder hosts (≤ v0.27.x) also listed the six scanners built into the host binary, with\n`origin: \"builtin\"`. Those are gone; every row now reports `origin: \"plugin\"`.",
"operationId": "listLibraryScanners",
"responses": {
"200": {
@@ -1573,8 +1603,8 @@
"tags": [
"library"
],
"summary": "Enable or disable a library scanner",
"description": "Persists the toggle and applies it from the next library read (no restart). Disabling a scanner\nhides its titles everywhere — the console grid, native clients, and the GameStream app list —\nand re-enabling brings them straight back (nothing is deleted; the scan just runs again). Emits\n`library.changed` with the scanner id as `source` when the state changed.",
"summary": "Enable or disable a library source",
"description": "Persists the toggle and applies it from the next library read (no restart). Disabling a source\nhides its titles everywhere — the console grid, native clients, and the GameStream app list —\nand re-enabling brings them straight back. Nothing is deleted: the plugin may keep reconciling\nwhile its source is off, and those entries simply aren't surfaced. Emits `library.changed` with\nthe source id as `source` when the state changed.",
"operationId": "setLibraryScanner",
"parameters": [
{
@@ -1767,6 +1797,56 @@
}
}
}
},
"delete": {
"tags": [
"native"
],
"summary": "Unpair every native client",
"description": "The collection form of [`unpair_native_client`]: empties the punktfunk/1 trust store in ONE\npersisted write (not a loop of them — a failure partway would leave a half-emptied store), and\nends every live native session the removed clients own.\n\nIdempotent, hence a 200 rather than the single unpair's 204/404: an already-empty store\nsatisfies the request, and the count still tells the operator what it meant.",
"operationId": "unpairAllNativeClients",
"responses": {
"200": {
"description": "Every native client unpaired (possibly none)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnpairAllResult"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"500": {
"description": "Could not persist the trust store",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"503": {
"description": "Native host not enabled",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/native/clients/{fingerprint}": {
@@ -5397,7 +5477,7 @@
"string",
"null"
],
"description": "The external provider owning this entry (custom-store entries synced by a provider\nplugin, RFC §8) — `None` for installed-store titles and manual custom entries. The\nconsole uses it for attribution; `GET /library?provider=` filters on it."
"description": "The external provider owning this entry (entries synced by a provider plugin, RFC §8) —\n`None` only for the manual entries the operator typed in. The console uses it for\nattribution; `GET /library?provider=` filters on it."
},
"role": {
"$ref": "#/components/schemas/GameRole",
@@ -7143,7 +7223,7 @@
},
"origin": {
"$ref": "#/components/schemas/SourceOrigin",
"description": "Where the source comes from: `builtin` (a scanner in this host build) or `plugin`."
"description": "Where the source comes from. Always `plugin` from this host build onward — see\n[`SourceOrigin`]."
},
"provider": {
"type": [
@@ -7163,7 +7243,7 @@
"properties": {
"enabled": {
"type": "boolean",
"description": "Whether the scanner should run on this host."
"description": "Whether this source should contribute titles on this host."
}
}
},
@@ -7687,6 +7767,22 @@
}
}
},
"UnpairAllResult": {
"type": "object",
"description": "What a bulk unpair removed. Shared by the two collection DELETEs (`/clients` and\n`/native/clients`) so the console sees one schema across both pairing planes.\n\nA count rather than 204: \"unpair everything\" is idempotent, so an empty store is a success, and\nthe operator still wants to be told whether that meant three devices or none.",
"required": [
"unpaired"
],
"properties": {
"unpaired": {
"type": "integer",
"format": "int32",
"description": "Clients removed from the trust store — 0 when nothing was paired.",
"example": 3,
"minimum": 0
}
}
},
"UpdateJobInfo": {
"type": "object",
"description": "A running apply job (or a spawned installer that hasn't resolved yet).",
@@ -7958,7 +8054,7 @@
},
{
"name": "library",
"description": "Game library: installed-store titles (Steam) plus user-curated custom entries"
"description": "Game library: the titles each installed library plugin syncs, plus user-curated custom entries"
},
{
"name": "stats",
+18
View File
@@ -48,6 +48,11 @@ RUN pacman -Syu --noconfirm --needed \
hwdata luajit seatd sdl2-compat vulkan-icd-loader \
xcb-util-errors xcb-util-wm xorg-xwayland \
meson glm wayland-protocols benchmark libxcursor \
# mold: link-phase accelerator (sccache cannot cache linking). makepkg links the release
# host, client, worker and tray on every arch.yml run. Wired via cargo-config-mold.toml
# below. It does NOT affect the gamescope companion leg — that is meson + its own linker,
# and its `-static-libstdc++` link is untouched.
mold \
&& pacman -Scc --noconfirm
# bun builds the punktfunk-web console + the punktfunk-scripting runner AND is vendored
@@ -64,3 +69,16 @@ ARG SCCACHE_VERSION=0.10.0
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
&& sccache --version
# CARGO_HOME is declared here only so this image agrees with what arch.yml already sets at job
# level (and so `cargo` finds the config below when the image is used by hand). The workflow still
# passes CARGO_HOME explicitly across the `sudo -u builder env …` boundary, which strips ambient
# env — that is why the C/C++ sccache wiring has to be re-exported there by name while THIS file,
# being a file, crosses the boundary for free.
ENV CARGO_HOME=/usr/local/cargo
RUN mkdir -p /usr/local/cargo && chmod -R a+w /usr/local/cargo
# Link x86_64 with mold — see cargo-config-mold.toml's header for the rustflags traps, and
# rust-ci.Dockerfile for why the `mold --version` assertion sits next to the COPY.
COPY cargo-config-mold.toml /usr/local/cargo/config.toml
RUN mold --version && test -r /usr/local/cargo/config.toml
+41
View File
@@ -0,0 +1,41 @@
# Installed as $CARGO_HOME/config.toml in every Linux CI builder image (ci/*.Dockerfile).
#
# WHAT: link the x86_64 Linux targets with mold instead of GNU ld. Linking is the one phase of a
# Rust build that sccache CANNOT cache — every job relinks punktfunk-host, punktfunk-client-linux,
# punktfunk-client-session, punktfunk-cli, pf-update and punktfunk-encode-worker from scratch on
# every run, and the packaging legs (deb/rpm/arch) do it for release binaries with full debug info.
# mold is the only lever that touches that phase.
#
# ⚠ THE TRAP THIS FILE HAS TO STAY CLEAR OF — read before editing, and before adding rustflags
# anywhere else in this repo:
#
# 1. A `RUSTFLAGS` ENVIRONMENT VARIABLE OVERRIDES CONFIG RUSTFLAGS ENTIRELY. It does not merge
# and it does not append. Any job that sets RUSTFLAGS silently loses mold here — it still
# builds, just with the default linker. Never "simplify" this file into a RUSTFLAGS export.
# (This trap used to be far worse: the workspace .cargo/config.toml carried aarch64
# `--cfg aes_armv8` / `--cfg polyval_armv8`, worth ~10x on the decrypt path, and an override
# dropped those too. The RustCrypto aes 0.9 / polyval 0.7 bump retired both cfgs — see the
# tombstone in .cargo/config.toml — so today only mold is at stake here.)
#
# 2. CONFIG FILES MERGE PER KEY, HIGHEST-PRECEDENCE FILE WINS — they do not concatenate. The
# workspace's .cargo/config.toml outranks this one ($CARGO_HOME is the LOWEST precedence).
# Today that is harmless because the workspace file defines NO rustflags at all and this one
# defines only `target.x86_64-unknown-linux-gnu.rustflags`. But the moment someone adds an
# x86_64 rustflags entry to the workspace .cargo/config.toml, IT WINS and mold silently stops
# being used here. If that ever happens, move the link-arg into that file instead of
# duplicating it.
#
# 3. aarch64 IS DELIBERATELY NOT WIRED. The cross image links with aarch64-linux-gnu-gcc against a
# multiarch sysroot (ci/rust-ci-arm64cross.Dockerfile); pointing that driver at mold is a
# separate thing to prove, and those legs are already the fast ones (~1.5 min of clippy, ~5 min
# for the arm64 .deb). Add it only with a measurement, and in a commit of its own.
#
# Requires GCC >= 12.1 (or clang) for `-fuse-ld=mold`; every base here ships far newer. mold itself
# is installed in the same Dockerfile layer that copies this file, so an image can never carry the
# flag without the linker — see the `mold --version` assertion there.
#
# NOTE this affects the HOST-targeted compiles of build scripts and proc macros too (they are
# x86_64-unknown-linux-gnu), which is exactly what we want: those link constantly and are pure
# overhead.
[target.x86_64-unknown-linux-gnu]
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
+11
View File
@@ -22,6 +22,12 @@ RUN dnf -y install \
rpm-build rpmdevtools systemd-rpm-macros git tar gzip nodejs unzip \
# build toolchain + bindgen
gcc gcc-c++ clang clang-devel cmake nasm pkgconf-pkg-config curl ca-certificates \
# mold: link-phase accelerator (sccache cannot cache linking). This image links the release
# host, client, worker and tray on every rpm.yml run, TWICE per push (f43 + f44). Wired via
# cargo-config-mold.toml below. Note the linker DRIVER is unchanged — still gcc, so Fedora's
# default `-Wl,--build-id` still reaches the link and rpmbuild's debuginfo extraction (which
# hard-requires a build-id) behaves exactly as before; mold implements --build-id natively.
mold \
# ffmpeg (NVENC), capture/audio/display link deps
ffmpeg-devel pipewire-devel wayland-devel libxkbcommon-devel opus-devel \
mesa-libGL-devel mesa-libgbm-devel \
@@ -76,3 +82,8 @@ ARG SCCACHE_VERSION=0.10.0
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
&& sccache --version
# Link x86_64 with mold — see cargo-config-mold.toml's header for the rustflags traps, and
# rust-ci.Dockerfile for why the `mold --version` assertion sits next to the COPY.
COPY cargo-config-mold.toml /usr/local/cargo/config.toml
RUN mold --version && test -r /usr/local/cargo/config.toml
+83
View File
@@ -0,0 +1,83 @@
# Builder for the `punktfunk-gamescope` .deb — Debian 13 (trixie).
#
# WHY THIS EXISTS, AND WHY IT IS NOT THE NOBLE IMAGE:
# The gamescope .deb was built in the host job's Ubuntu 24.04 (noble) image, and it has NEVER once
# succeeded there — v0.26.0 and v0.27.0 both shipped with no gamescope package while the release
# notes and docs-site said it was apt-installable. The failure is structural, not a flaky dep:
#
# wlroots| Dependency wayland-server found: NO found 1.22.0 but need: '>=1.23.1'
# subprojects/wlroots/meson.build:96:17: ERROR: Dependency 'wayland-server' is required but not found
#
# Our gamescope pin vendors wlroots 0.19.3, which floors wayland-server at 1.23.1. Noble ships
# 1.22.0 and will never ship more — so no amount of `apt-get install` in that image can fix it.
# Noble also has no `libxcb-errors-dev` at all and only libdisplay-info 0.1.1 (the tree wants 0.2).
#
# Debian 13 ships wayland 1.23.1 exactly, libxcb-errors 1.0.1 and libdisplay-info 0.2.0 — the
# oldest apt distro the tree actually builds on. Building HERE rather than on Ubuntu 26.04
# (wayland 1.24, libdisplay-info 0.3) is deliberate twice over: it keeps the glibc floor low, and
# it stays on the libdisplay-info 0.2 line the pin was developed against.
#
# WHAT THE RESULTING BINARY RUNS ON — verified by building it and reading the ELF:
# * glibc floor GLIBC_2.38 (the C++ runtime is linked statically by
# build-punktfunk-gamescope.sh, so libstdc++ never enters the NEEDED list)
# * NEEDED libwayland-server.so.0 / libwayland-client.so.0 — wlroots 0.19 calls symbols
# added in 1.23.1, so THAT, not glibc, is the real floor.
# ⇒ Debian 13 (1.23.1) and Ubuntu 26.04 (1.24.0) YES; Ubuntu 24.04 (1.22.0) NO — and 24.04
# could not run this binary however it was built, so nothing is lost by moving off noble.
#
# Rebuilt+pushed by .gitea/workflows/docker.yml (matrix: punktfunk-gamescope-trixie); consumed by
# the `build-publish-gamescope` job in .gitea/workflows/deb.yml. Bootstrap: like rust-ci-noble, the
# first deb.yml run after this image is added needs the image to already exist — seed it once by
# hand (docker build -f ci/gamescope-trixie.Dockerfile -t <registry>/punktfunk-gamescope-trixie:latest ci
# && docker push …) before that job can run.
FROM debian:trixie
ENV DEBIAN_FRONTEND=noninteractive
# nodejs is not optional: the Gitea runner executes the JS actions (checkout/cache) INSIDE this
# container, so an image without it fails before the first `run:` step ever starts.
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential pkg-config cmake meson ninja-build git curl ca-certificates nodejs \
# .deb assembly (dpkg-shlibdeps computes the runtime Depends from the built binary)
dpkg-dev \
# shader compilers gamescope's meson looks for
glslc glslang-tools \
# wayland + protocols. libwayland-dev 1.23.1 is the whole reason this image is Debian.
libwayland-dev wayland-protocols \
# gamescope's own dependency set. `apt-get build-dep gamescope` is useless here — Debian has
# no gamescope package to derive it from — so the tree's needs are named outright, exactly as
# the noble job had to. Kept as ONE transaction on purpose: in an image build a missing name
# SHOULD fail loudly at build time, unlike the workflow's per-package best-effort loop where a
# rename would have silently dropped a dep into a warning nobody reads.
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 \
libxcb-ewmh-dev libxcb-icccm4-dev libxcb-errors-dev libxcb-shape0-dev \
libpixman-1-dev libdisplay-info-dev libgbm-dev libegl-dev xwayland \
&& rm -rf /var/lib/apt/lists/*
# Assert the ONE version that decides whether this image can do its job, so a future Debian base
# bump that regressed it fails HERE (loudly, at image build) instead of in a deb.yml run whose
# gamescope failure has historically been a `::warning::` nobody saw.
RUN set -eux; \
have="$(pkg-config --modversion wayland-server)"; \
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"
+10
View File
@@ -26,6 +26,9 @@ ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
# toolchain + bindgen; nodejs runs the JS actions (checkout/cache); unzip for the rustup installer's deps
build-essential clang libclang-dev pkg-config cmake git curl ca-certificates nodejs unzip \
# mold: link-phase accelerator (sccache cannot cache linking). This image links the release
# host + encode worker on every deb.yml run. Wired via cargo-config-mold.toml below.
mold \
# .deb assembly: dpkg-shlibdeps/dpkg-deb; patchelf repoints the binary's rpath at the bundled FFmpeg
dpkg-dev patchelf \
# FFmpeg 8 build deps: nasm (asm), VAAPI (libva/libdrm) so the built libav* keep the AMD/Intel
@@ -99,3 +102,10 @@ ARG SCCACHE_VERSION=0.10.0
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
&& sccache --version
# Link x86_64 with mold — see cargo-config-mold.toml's header for the rustflags traps, and
# rust-ci.Dockerfile for why the `mold --version` assertion sits next to the COPY.
# ⚠ This does NOT touch the from-source FFmpeg built above: that is a plain ./configure && make in
# an earlier layer, linked by GNU ld exactly as before. Only cargo's links move to mold.
COPY cargo-config-mold.toml /usr/local/cargo/config.toml
RUN mold --version && test -r /usr/local/cargo/config.toml
+12
View File
@@ -13,6 +13,9 @@ ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
# toolchain + bindgen; nodejs runs the JS actions (checkout/cache); unzip is for the bun installer
build-essential clang libclang-dev pkg-config cmake git curl ca-certificates nodejs unzip \
# mold: the link-phase accelerator. Linking is the one thing sccache cannot cache, and this
# image relinks the whole workspace on every job. Wired via cargo-config-mold.toml below.
mold \
# ffmpeg-next 9, built against whatever libav* 26.04 ships (FFmpeg 8 / libavcodec 62 today).
# The crate major is a CEILING — ffmpeg-sys-next 9 spans libavcodec 56..63 — so this image does
# not need to move in lockstep with Arch's FFmpeg 9; it just links what the distro has.
@@ -61,3 +64,12 @@ ARG SCCACHE_VERSION=0.10.0
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
&& sccache --version
# Link x86_64 with mold (see the file's own header for the rustflags-precedence traps).
#
# The assertion is the point: an image carrying the flag but NOT the linker would fail every cargo
# invocation in every consuming job, which is a catastrophic way to find out that a base image
# renamed the package. `mold --version` fails the docker build instead, so nothing is pushed and
# `:latest` keeps pointing at the previous working image — consumers never see it.
COPY cargo-config-mold.toml /usr/local/cargo/config.toml
RUN mold --version && test -r /usr/local/cargo/config.toml
+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
File diff suppressed because it is too large Load Diff
@@ -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(
@@ -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
@@ -69,7 +69,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 +77,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 +100,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 +145,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 +207,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()
@@ -392,8 +402,8 @@ private fun ControllersBody(
// 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)
}
}
@@ -675,19 +685,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 +706,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 +721,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 +803,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,
@@ -526,10 +526,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 +555,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 +571,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
@@ -32,20 +32,12 @@ import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MicOff
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -57,7 +49,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
@@ -130,12 +121,12 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
// it, and survives the same recreate because the composition outlives the surface.
var micMuted by remember(handle) { mutableStateOf(false) }
// Whether a capture is actually RUNNING, not merely wanted — set from surfaceCreated on what
// nativeMicActive reports. A device that refused every AAudio input rung gets no mute control
// rather than one that lies about a mic being heard.
// nativeMicActive reports. A device that refused every AAudio input rung gets no mute chord and
// no chord line in the start banner, rather than an offer to mute a mic nobody is hearing.
var micRunning by remember(handle) { mutableStateOf(false) }
// Transient confirmation of a mic-chord toggle (null = nothing showing). Only the gamepad path
// needs it: the touch button confirms itself by changing under the finger, but a chord has no
// on-screen state of its own, and "did that register?" is exactly the doubt to answer.
// Transient confirmation of a mic-chord toggle (null = nothing showing). With no standing mic
// element on screen, this is mute's only feedback: a chord has no on-screen state of its own,
// and "did that register?" is exactly the doubt to answer.
var micHint by remember { mutableStateOf<String?>(null) }
LaunchedEffect(micHint) {
if (micHint != null) {
@@ -413,9 +404,9 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
// Show a "hold to quit" hint the moment the chord completes (the router debounces the actual
// exit); it clears when the buttons release early or the hold elapses. Runs on the main thread.
router.onExitArmed = { armed -> exitArming = armed }
// Select + Y toggles the mic — the couch reach for the on-screen mute button, which a
// gamepad/TV user has no pointer for. Ignored when no capture is running (there is nothing
// to mute, and claiming otherwise would be the lie the control exists to avoid).
// Select + Y toggles the mic — with no on-screen mute element, this chord is the whole of
// the control. Ignored when no capture is running (there is nothing to mute, and a hint
// saying "Microphone muted" over a mic nobody opened would be a lie).
// A captured Sony pad whose motion this session cannot carry. Fires once per pad, at the
// moment it is claimed, on the main thread.
router.onMotionUnreachable = { motionHint = true }
@@ -825,7 +816,7 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
?: (runCatching { context.display }.getOrNull()?.refreshRate ?: 0f)
.roundToInt(),
)
NativeBridge.nativeStartAudio(handle, lowLatencyMode)
NativeBridge.nativeStartAudio(handle, lowLatencyMode, isTv)
if (micWanted) {
val sessionId =
NativeBridge.nativeStartMic(handle, initialSettings.echoCancel)
@@ -981,19 +972,12 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
}
},
)
// Mic mute, LAST in the stack — the one in-stream control, so unlike the purely visual
// overlays above it has to sit on top of the gesture layer to receive its own taps (it
// costs the stream that small corner of touch area, which is why it exists only while a
// capture actually runs). On TV it is the indicator alone: the Select + Y chord is the
// control there, and a focusable button would fight the game for the D-pad.
if (micRunning && (micMuted || !isTv)) {
MicMuteControl(
muted = micMuted,
onToggle = if (isTv) null else ({ setMicMuted(!micMuted) }),
modifier = Modifier.align(Alignment.TopEnd).padding(12.dp),
)
}
// Chord confirmation (gamepad/TV) — the counterpart to the button changing under a finger.
// No standing mic element here: the in-stream mute control is deliberately absent until the
// on-screen overlay UI lands and can carry it as one of its controls. Mute itself is intact
// — the Select + Y chord toggles it, and the hint below is what confirms the toggle.
// Chord confirmation (gamepad/TV) — mute has no standing indicator, so this is the whole
// of its feedback: a toggle that showed nothing at all would be indistinguishable from one
// that never registered.
micHint?.let { MicChordHint(it, Modifier.align(Alignment.TopCenter).padding(top = 16.dp)) }
// Bottom, not top: this can coincide with a mic-chord confirmation or the exit cue, and a
// notice landing on top of one of those would cost the user both.
@@ -1030,47 +1014,8 @@ private fun releaseMicEffects(effects: MutableList<AudioEffect>) {
}
/**
* The in-stream mic control and its muted indicator, in one element: a dim mic glyph while the
* uplink is live, a red **Muted** badge while it isn't so the state that matters is the loud one,
* readable at couch distance and impossible to mistake for the stream's own picture.
*
* [onToggle] `null` makes it a pure indicator (the TV/gamepad surface, where the Select + Y chord
* is the control); non-null makes the badge itself the touch target. Rendering it at all is the
* caller's decision it means a capture is genuinely running.
*/
@Composable
private fun MicMuteControl(muted: Boolean, onToggle: (() -> Unit)?, modifier: Modifier = Modifier) {
val shape = RoundedCornerShape(10.dp)
Row(
modifier = modifier
.clip(shape)
.background(if (muted) Color(0xE0B3261E) else Color.Black.copy(alpha = 0.45f))
.then(if (onToggle != null) Modifier.clickable(onClick = onToggle) else Modifier)
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = if (muted) Icons.Filled.MicOff else Icons.Filled.Mic,
// Spoken state first, then the action — a talkback user needs to know they are muted
// before they need to know how to stop being muted.
contentDescription = if (muted) {
"Microphone muted. Activate to unmute."
} else {
"Microphone live. Activate to mute."
},
tint = Color.White,
modifier = Modifier.size(20.dp),
)
if (muted) {
Spacer(Modifier.width(6.dp))
Text("Muted", color = Color.White, fontSize = 14.sp)
}
}
}
/**
* Transient confirmation that the mic chord (Select + Y) registered. The badge above already says
* *muted*, but nothing on screen says *un*muted and "did that press do anything?" is the whole
* Transient confirmation that the mic chord (Select + Y) registered. Nothing else on screen says
* *muted* or *un*muted, so this pill carries both "did that press do anything?" is the whole
* doubt a chord with no button under the finger creates. Same pill vocabulary as the other
* in-stream cues; the caller clears it after a beat.
*/
@@ -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)
@@ -230,6 +230,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
@@ -302,12 +302,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 +477,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
@@ -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))
}
}
@@ -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
+23 -4
View File
@@ -15,22 +15,41 @@ crate-type = ["cdylib"]
[dependencies]
# The whole protocol/transport/FEC/crypto + the embeddable NativeClient connector. `quic` pulls
# the punktfunk/1 control plane (now ring-only — no aws-lc, see punktfunk-core/Cargo.toml).
# the punktfunk/1 control plane, whose TLS runs on aws-lc-rs (see punktfunk-core/Cargo.toml)
# aws-lc-sys cross-compiles for all three ABIs with the NDK clang cargo-ndk already exports.
punktfunk-core = { path = "../../../crates/punktfunk-core", features = ["quic"] }
jni = "0.21"
# 0.22, NOT 0.21 — and the version is load-bearing beyond currency: `rustls-platform-verifier`
# (via quinn-proto, for Android cert verification) already depends on jni 0.22, so pinning 0.21
# here compiled TWO jni copies into the one .so. Matching it collapses them and, with them, the
# whole windows-rs 0.42 generation jni 0.21 dragged in behind `cfg(windows)` (windows-sys 0.45 —
# the oldest crate in the tree — plus windows-targets 0.42.2 and its seven per-arch import libs)
# and jni 0.21's `cesu8`: 11 crates, for a dependency that never even builds on Android.
#
# NOTE for whoever next tries to retire thiserror 1.0 or the jni-sys 0.3/0.4 split: jni is no
# longer why they are here. Both now come solely from `vendor/ndk` 0.9.0 (thiserror 1.0.23,
# jni-sys 0.3) and the crates.io `ndk-sys` 0.6 (jni-sys 0.3). jni-sys 0.3.1 is itself a facade
# over 0.4.1, so the "split" cannot close until ndk + ndk-sys move — and ndk is vendored for one
# visibility patch, so bumping its deps would mean rewriting the vendor rather than a version edit.
jni = "0.22"
log = "0.4"
# LAN host discovery: browse the host's `_punktfunk._udp` mDNS advert — the SAME crate + service the
# Linux/Windows clients use (`crates/pf-client-core/src/discovery.rs`), replacing Android's per-OEM
# `NsdManager` system daemon with one tested browse path. Pure Rust (socket2/if-addrs/mio), so it
# cross-compiles to the Android targets AND builds on the host (the JNI seam links into
# `cargo build --workspace`). Kotlin keeps only the Wi-Fi `MulticastLock` + permission UX.
mdns-sd = "0.20"
mdns-sd = "0.21"
# Android-only deps. Gated so `cargo build --workspace` on the Linux/macOS dev boxes + CI still
# compiles this crate (as a host cdylib) — the Android-framework glue (logging, AMediaCodec + AAudio
# via `ndk`, the Opus codec) is only pulled in for the real `*-linux-android` targets.
[target.'cfg(target_os = "android")'.dependencies]
android_logger = "0.14"
# Default features only, DELIBERATELY: 0.15 added an opt-in `android-api-30` feature that routes
# level filtering through `__android_log_is_loggable_len` so logcat's system-wide/process-wide
# level overrides (`setprop log.tag.*`) are honoured. That symbol is API 30 and the feature
# HARD-LINKS it — on our minSdk-28 floor (Android 9/10) `System.loadLibrary` would fail outright,
# the same way ndk 0.9.0 hard-linking `AMediaCodec_setOnFrameRenderedCallback` broke every
# pre-Android-13 device (see the `ndk-sys` note below). Do not enable it while minSdk is 28.
android_logger = "0.15"
# Feature bridge, no code here: punktfunk-core logs through `tracing`, but this client only
# installs `android_logger` (a `log` backend). Core transport warnings (e.g. "UDP socket buffer
# capped well below target") reach logcat only via tracing's "log" feature, which forwards events
File diff suppressed because it is too large Load Diff
@@ -43,10 +43,12 @@ struct OutputReady {
/// internal looper thread) push the codec ones; the feeder thread pushes `Au`. Each carries only
/// owned/`Copy` data so the callback closures satisfy the `Send` bound and never touch the codec.
enum DecodeEvent {
/// A received access unit from the feeder, ready to queue into the decoder. The `bool` is the
/// feeder's [`NativeClient::note_frame_index`] verdict — `true` when this AU revealed a forward
/// frame-index gap, so the loop arms the freeze gate (the feeder already fired the RFI request).
Au(Frame, bool),
/// A received access unit from the feeder, ready to queue into the decoder. The `u32` is the
/// feeder's [`NativeClient::note_frame_index`] verdict — the forward frame-index gap's WIDTH
/// (0 = none), so the loop arms the freeze gate with the same signal and pre-credits the
/// reassembler's later `frames_dropped` climb for the loss (the feeder already fired the RFI
/// request).
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).
@@ -603,7 +605,11 @@ fn feeder_loop(
// AU's first piece (or a whole delivery), so the RFI gap detector keeps
// counting AUs.
let au_first = frame.part.is_none_or(|p| p.first);
let gap = au_first && client.note_frame_index(frame.frame_index);
let gap = if au_first {
client.note_frame_index(frame.frame_index)
} else {
0
};
// Park the receipt stamp (keyed by the pts the codec echoes) whenever the `decode`
// stage is consumed: the HUD, or the ABR decode signal (`measure_decode`). The
// HUD-only `received` point + host/network split stay gated on the overlay.
@@ -691,9 +697,12 @@ fn dispatch_event(
match ev {
DecodeEvent::Au(f, gap) => {
// A forward frame-index gap arms the freeze; park this AU's flags for the present side to
// fold `on_decoded` (keyed by the pts the codec will echo).
if gap {
gate.arm(Instant::now());
// fold `on_decoded` (keyed by the pts the codec will echo). Credited arm: the gap width
// pre-covers the reassembler's ~120 ms-later `frames_dropped` climb for the same loss,
// so a fast RFI anchor that heals in between isn't re-frozen by it (the double-arm
// race — see `ReanchorGate::arm_expecting_drops`).
if gap > 0 {
gate.arm_expecting_drops(Instant::now(), u64::from(gap));
}
// One entry per AU (parts share the pts): the completing delivery carries it.
if f.complete {
@@ -222,8 +222,13 @@ pub(super) fn run_sync(
// recovers with a cheap clean P-frame instead of a full IDR. The same forward gap
// arms the freeze gate so the decoder's concealment is held off the screen until the
// recovery re-anchors. The frames_dropped keyframe path below stays the backstop.
if client.note_frame_index(frame.frame_index) {
gate.arm(Instant::now());
// Credited arm: the gap width pre-covers the reassembler's ~120 ms-later
// `frames_dropped` climb for the same loss, so a fast RFI anchor that heals in
// between isn't re-frozen by it (the double-arm race — see
// `ReanchorGate::arm_expecting_drops`).
let gap = client.note_frame_index(frame.frame_index);
if gap > 0 {
gate.arm_expecting_drops(Instant::now(), u64::from(gap));
}
// Park this AU's re-anchor flags for the present side (keyed by the pts the codec
// echoes on the output buffer) — unconditional, unlike the HUD's `in_flight` map.
+34 -23
View File
@@ -16,9 +16,10 @@
//! wrong, and 1 Hz is plenty for a host picker.
use crate::session::jni_guard;
use jni::objects::JObject;
use jni::errors::LogErrorAndDefault;
use jni::objects::{JObject, JString};
use jni::sys::jlong;
use jni::JNIEnv;
use jni::EnvUnowned;
use mdns_sd::{ResolvedService, ServiceDaemon, ServiceEvent};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
@@ -31,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.
@@ -48,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 {
@@ -60,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),
@@ -69,6 +74,7 @@ impl Host {
clean(&self.pair),
clean(&self.mac),
clean(&self.os),
self.mgmt,
)
}
}
@@ -192,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),
})
}
@@ -200,9 +208,9 @@ fn resolve(info: &ResolvedService) -> Option<Host> {
/// hold the Wi-Fi `MulticastLock` for the browse lifetime.
///
/// [`nativeDiscoveryStop`]: Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStop
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStart(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
) -> jlong {
jni_guard(0, || match Discovery::start() {
@@ -212,15 +220,18 @@ 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).
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll<'local>(
env: JNIEnv<'local>,
mut env: EnvUnowned<'local>,
_this: JObject<'local>,
handle: jlong,
) -> jni::sys::jstring {
jni_guard(std::ptr::null_mut(), || {
) -> JString<'local> {
// `with_env` subsumes the `jni_guard` this used to carry: it catches panics at the boundary and
// `LogErrorAndDefault` logs then yields `JString::default()` — the null reference the old
// `std::ptr::null_mut()` default returned. Kotlin still sees a null String on failure.
env.with_env(|env| -> jni::errors::Result<JString<'local>> {
let out = if handle == 0 {
String::new()
} else {
@@ -229,11 +240,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPo
let d = unsafe { &*(handle as *const Discovery) };
d.snapshot()
};
match env.new_string(out) {
Ok(s) => s.into_raw(),
Err(_) => std::ptr::null_mut(),
}
env.new_string(out)
})
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeDiscoveryStop(handle)` — stop the browse, shut the daemon down and join its
@@ -245,9 +254,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPo
///
/// [`nativeDiscoveryStart`]: Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStart
/// [`nativeDiscoveryPoll`]: Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStop(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
) {
@@ -275,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");
@@ -287,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"
@@ -306,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");
+90 -80
View File
@@ -8,9 +8,10 @@
//! compile on the host build too (parity with the input shims in [`crate::session`]).
use crate::session::{jni_guard, SessionHandle};
use jni::errors::LogErrorAndDefault;
use jni::objects::{JByteBuffer, JObject};
use jni::sys::{jint, jlong};
use jni::JNIEnv;
use jni::EnvUnowned;
use punktfunk_core::quic::HidOutput;
use std::time::Duration;
@@ -60,9 +61,9 @@ const TAG_HID_RAW: u8 = 0x05;
/// closed (all packed values are positive, so `-1` stays unambiguous). Kotlin routes the command
/// back to the controller holding that wire `pad` index (multi-pad rumble). Run from a Kotlin
/// poll thread.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
) -> jlong {
@@ -99,96 +100,105 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
/// PlayerLeds → `[pad][0x02][bits]` (len 3)
/// Trigger → `[pad][0x03][which][effect…]` (len 3 + effect.len())
/// Returns the byte count written, or `-1` on timeout / session closed / buffer too small.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
env: JNIEnv,
mut env: EnvUnowned,
_this: JObject,
handle: jlong,
buf: JByteBuffer,
) -> jint {
// Runs on a Kotlin poll thread, so a panic here would abort the process; guard the boundary.
//
// Deliberately `with_env_no_catch` INSIDE `jni_guard`, not the usual `with_env`: every error
// policy resolves a failure to `T::default()`, and `jint::default()` is 0 — a *valid* byte
// count — whereas this method's contract says -1. Letting the panic travel out to `jni_guard`
// keeps the -1 sentinel exact. Every non-panic failure path below likewise returns `Ok(-1)`
// rather than `Err`, so the policy's default is unreachable by construction.
jni_guard(-1, || {
if handle == 0 {
return -1;
}
// SAFETY: live handle per the contract; next_hidout is &self on the Sync connector.
let h = unsafe { &*(handle as *const SessionHandle) };
let ev = match h.client.next_hidout(PULL_TIMEOUT) {
Ok(ev) => ev,
Err(_) => return -1, // timeout or closed — Kotlin loops
};
env.with_env_no_catch(|env| -> jni::errors::Result<jint> {
if handle == 0 {
return Ok(-1);
}
// SAFETY: live handle per the contract; next_hidout is &self on the Sync connector.
let h = unsafe { &*(handle as *const SessionHandle) };
let ev = match h.client.next_hidout(PULL_TIMEOUT) {
Ok(ev) => ev,
Err(_) => return Ok(-1), // timeout or closed — Kotlin loops
};
// The caller passes a direct ByteBuffer (allocateDirect) so we write its backing store directly.
let cap = match env.get_direct_buffer_capacity(&buf) {
Ok(c) => c,
Err(_) => return -1,
};
let ptr = match env.get_direct_buffer_address(&buf) {
Ok(p) if !p.is_null() => p,
_ => return -1,
};
// SAFETY: `ptr`/`cap` describe the direct ByteBuffer's backing store, valid for this call.
let out = unsafe { std::slice::from_raw_parts_mut(ptr, cap) };
// The caller passes a direct ByteBuffer (allocateDirect) so we write its backing store directly.
let cap = match env.get_direct_buffer_capacity(&buf) {
Ok(c) => c,
Err(_) => return Ok(-1),
};
let ptr = match env.get_direct_buffer_address(&buf) {
Ok(p) if !p.is_null() => p,
_ => return Ok(-1),
};
// SAFETY: `ptr`/`cap` describe the direct ByteBuffer's backing store, valid for this call.
let out = unsafe { std::slice::from_raw_parts_mut(ptr, cap) };
// out[0] = wire pad index; out[1] = kind tag; the rest is the per-kind payload.
let n = match ev {
HidOutput::Led { pad, r, g, b } => {
if cap < 5 {
return -1;
// out[0] = wire pad index; out[1] = kind tag; the rest is the per-kind payload.
let n = match ev {
HidOutput::Led { pad, r, g, b } => {
if cap < 5 {
return Ok(-1);
}
out[0] = pad;
out[1] = TAG_LED;
out[2] = r;
out[3] = g;
out[4] = b;
5
}
out[0] = pad;
out[1] = TAG_LED;
out[2] = r;
out[3] = g;
out[4] = b;
5
}
HidOutput::PlayerLeds { pad, bits } => {
if cap < 3 {
return -1;
HidOutput::PlayerLeds { pad, bits } => {
if cap < 3 {
return Ok(-1);
}
out[0] = pad;
out[1] = TAG_PLAYER_LEDS;
out[2] = bits;
3
}
out[0] = pad;
out[1] = TAG_PLAYER_LEDS;
out[2] = bits;
3
}
HidOutput::Trigger { pad, which, effect } => {
let n = 3 + effect.len();
if cap < n {
return -1; // the raw DS5 trigger block is ~11 bytes; Kotlin allocates 64
HidOutput::Trigger { pad, which, effect } => {
let n = 3 + effect.len();
if cap < n {
return Ok(-1); // the raw DS5 trigger block is ~11 bytes; Kotlin allocates 64
}
out[0] = pad;
out[1] = TAG_TRIGGER;
out[2] = which;
out[3..n].copy_from_slice(&effect);
n
}
out[0] = pad;
out[1] = TAG_TRIGGER;
out[2] = which;
out[3..n].copy_from_slice(&effect);
n
}
HidOutput::TrackpadHaptic { .. } => {
// Steam Controller trackpad-coil haptics — no Android equivalent; drop it (motor
// rumble already rides the universal 0xCA plane).
return -1;
}
HidOutput::HidRaw { pad, kind, data } => {
// As-is SC2 passthrough: the host's hidraw consumer (Steam) wrote this report to
// the virtual pad; Kotlin replays it verbatim on the physical controller.
// `[pad][0x05][kind][report…]` — kind 0 = output report, 1 = feature report.
let n = 3 + data.len();
if cap < n {
return -1; // reports are ≤ 64 bytes; Kotlin allocates 128
HidOutput::TrackpadHaptic { .. } => {
// Steam Controller trackpad-coil haptics — no Android equivalent; drop it (motor
// rumble already rides the universal 0xCA plane).
return Ok(-1);
}
out[0] = pad;
out[1] = TAG_HID_RAW;
out[2] = kind;
out[3..n].copy_from_slice(&data);
n
}
HidOutput::AudioCtl { .. } => {
// DS5 pad-audio routing/volumes — no Android replay path yet (the 0xD1 sample
// plane isn't rendered here either); drop it like TrackpadHaptic.
return -1;
}
};
n as jint
HidOutput::HidRaw { pad, kind, data } => {
// As-is SC2 passthrough: the host's hidraw consumer (Steam) wrote this report to
// the virtual pad; Kotlin replays it verbatim on the physical controller.
// `[pad][0x05][kind][report…]` — kind 0 = output report, 1 = feature report.
let n = 3 + data.len();
if cap < n {
return Ok(-1); // reports are ≤ 64 bytes; Kotlin allocates 128
}
out[0] = pad;
out[1] = TAG_HID_RAW;
out[2] = kind;
out[3..n].copy_from_slice(&data);
n
}
HidOutput::AudioCtl { .. } => {
// DS5 pad-audio routing/volumes — no Android replay path yet (the 0xD1 sample
// plane isn't rendered here either); drop it like TrackpadHaptic.
return Ok(-1);
}
};
Ok(n as jint)
})
.resolve::<LogErrorAndDefault>()
})
}
+11 -12
View File
@@ -21,9 +21,10 @@
//! surface, the per-plane pumps (video → AMediaCodec, audio ↔ AAudio, mic uplink), input, and
//! rumble/HID feedback ([`feedback`]). Mode renegotiation is still TODO (see [`session`]).
use jni::objects::JObject;
use jni::errors::LogErrorAndDefault;
use jni::objects::{JObject, JString};
use jni::sys::jint;
use jni::JNIEnv;
use jni::EnvUnowned;
#[cfg(target_os = "android")]
mod adpf;
@@ -54,7 +55,7 @@ mod probe;
/// on via quinn's defaults — forwards them as `log` records since no tracing subscriber is ever
/// installed. Android-only — there is no JVM (and no logcat) on the host build.
#[cfg(target_os = "android")]
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn JNI_OnLoad(
_vm: *mut jni::sys::JavaVM,
_reserved: *mut std::ffi::c_void,
@@ -74,22 +75,20 @@ pub extern "system" fn JNI_OnLoad(
/// `NativeBridge.abiVersion(): Int` — the core's C-ABI version. A non-error return is the
/// scaffold's proof that `System.loadLibrary` found the `.so`, the JNI symbol resolved, and the
/// linked `punktfunk-core` is the one we expect.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_abiVersion(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
) -> jint {
punktfunk_core::ABI_VERSION as jint
}
/// `NativeBridge.coreVersion(): String` — the crate version, proving JNI string marshaling works.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_coreVersion<'local>(
env: JNIEnv<'local>,
mut env: EnvUnowned<'local>,
_this: JObject<'local>,
) -> jni::sys::jstring {
match env.new_string(env!("CARGO_PKG_VERSION")) {
Ok(s) => s.into_raw(),
Err(_) => JObject::null().into_raw(),
}
) -> JString<'local> {
env.with_env(|env| env.new_string(env!("CARGO_PKG_VERSION")))
.resolve::<LogErrorAndDefault>()
}
+11 -14
View File
@@ -5,32 +5,29 @@
//! advertise on mDNS (reached over Tailscale / VPN / another subnet) — the display-side companion
//! to the dial-first connect fix.
use jni::errors::LogErrorAndDefault;
use jni::objects::{JObject, JString};
use jni::sys::{jboolean, jint};
use jni::JNIEnv;
use jni::EnvUnowned;
use punktfunk_core::client::NativeClient;
use std::time::Duration;
/// `NativeBridge.nativeProbe(host, port, timeoutMs): Boolean` — true if `host:port` completed a
/// QUIC handshake within `timeoutMs`. No pin/identity presented (trust-agnostic), mDNS-independent.
/// Blocking (builds its own runtime) — Kotlin runs it on `Dispatchers.IO`, never the main thread.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeProbe<'local>(
mut env: JNIEnv<'local>,
mut env: EnvUnowned<'local>,
_this: JObject<'local>,
host: JString<'local>,
port: jint,
timeout_ms: jint,
) -> jboolean {
let host: String = match env.get_string(&host) {
Ok(s) => s.into(),
Err(_) => return 0,
};
let port = port.clamp(0, u16::MAX as jint) as u16;
let timeout = Duration::from_millis(timeout_ms.max(0) as u64);
if NativeClient::probe(&host, port, timeout) {
1
} else {
0
}
env.with_env(|env| -> jni::errors::Result<bool> {
let host: String = host.try_to_string(env)?;
let port = port.clamp(0, u16::MAX as jint) as u16;
let timeout = Duration::from_millis(timeout_ms.max(0) as u64);
Ok(NativeClient::probe(&host, port, timeout))
})
.resolve::<LogErrorAndDefault>()
}
+82 -59
View File
@@ -15,9 +15,10 @@
use std::time::Duration;
use jni::errors::LogErrorAndDefault;
use jni::objects::{JObject, JString};
use jni::sys::{jboolean, jint, jlong, jstring};
use jni::JNIEnv;
use jni::sys::{jboolean, jint, jlong};
use jni::EnvUnowned;
use punktfunk_core::clipboard::ClipEventCore;
use punktfunk_core::error::PunktfunkError;
use punktfunk_core::quic::{ClipKind, CLIP_FILE_INDEX_NONE, HOST_CAP_CLIPBOARD};
@@ -40,37 +41,50 @@ fn client(handle: jlong) -> Option<&'static SessionHandle> {
}
/// `NativeBridge.nativeClipSupported(handle)` — the host advertised `HOST_CAP_CLIPBOARD`.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipSupported(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
) -> jboolean {
client(handle).map_or(0, |h| {
u8::from(h.client.host_caps() & HOST_CAP_CLIPBOARD != 0)
})
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.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipControl(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
enabled: jboolean,
) {
if let Some(h) = client(handle) {
let _ = h.client.clip_control(enabled != 0, 0);
let _ = h.client.clip_control(enabled, 0);
}
}
/// `NativeBridge.nativeClipOfferText(handle, seq)` — announce "the Android clipboard now holds
/// text" (format list only; bytes cross when the host fetches). `seq` is Kotlin's monotonic
/// counter, newest wins.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipOfferText(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
seq: jint,
@@ -88,9 +102,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipOfferTe
/// `NativeBridge.nativeClipFetchText(handle, seq)` — pull the text of the host's offer `seq`.
/// Returns the transfer id echoed on the matching `data:`/`error:` event, or 1.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipFetchText(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
seq: jint,
@@ -106,28 +120,34 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipFetchTe
/// `NativeBridge.nativeClipServeText(handle, reqId, text)` — answer a `fetch:` event with the
/// clipboard's current text (the host is pasting our offer).
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipServeText(
mut env: JNIEnv,
mut env: EnvUnowned,
_this: JObject,
handle: jlong,
req_id: jint,
text: JString,
) {
let Some(h) = client(handle) else { return };
let Ok(s) = env.get_string(&text) else {
let _ = h.client.clip_cancel(req_id as u32);
return;
};
let _ = h
.client
.clip_serve(req_id as u32, String::from(s).into_bytes(), true);
env.with_env(|env| -> jni::errors::Result<()> {
let Some(h) = client(handle) else {
return Ok(());
};
// An unreadable payload still has to answer the host's `fetch:` — leaving it unanswered
// stalls the paste — so cancel the transfer rather than propagating the error.
let Ok(s) = text.try_to_string(env) else {
let _ = h.client.clip_cancel(req_id as u32);
return Ok(());
};
let _ = h.client.clip_serve(req_id as u32, s.into_bytes(), true);
Ok(())
})
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeClipCancel(handle, id)` — abort a transfer (either direction).
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipCancel(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
id: jint,
@@ -144,39 +164,42 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipCancel(
/// Text payloads ride `data:<xfer_id>:<text>` decoded lossily — safe because the phase-0
/// clipboard task delivers a whole payload in ONE event (`last = true`), so a chunk boundary
/// can never split a UTF-8 sequence.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextClip(
env: JNIEnv,
_this: JObject,
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextClip<'local>(
mut env: EnvUnowned<'local>,
_this: JObject<'local>,
handle: jlong,
) -> jstring {
let Some(h) = client(handle) else {
return std::ptr::null_mut();
};
let msg = match h.client.next_clip(Duration::from_millis(250)) {
Ok(ClipEventCore::State { enabled, .. }) => format!("state:{}", u8::from(enabled)),
Ok(ClipEventCore::RemoteOffer { seq, kinds }) => {
let has_text = kinds.iter().any(|k| k.mime.starts_with("text/plain"));
format!("offer:{seq}:{}", u8::from(has_text))
}
Ok(ClipEventCore::FetchRequest { req_id, mime, .. }) => {
if mime.starts_with("text/plain") {
format!("fetch:{req_id}")
} else {
// We only ever offer text; cancel anything else rather than stall the host.
let _ = h.client.clip_cancel(req_id);
return std::ptr::null_mut();
) -> JString<'local> {
// `JString::default()` is the null reference the old `std::ptr::null_mut()` returned, so the
// "null on timeout" contract in the doc comment above is unchanged.
env.with_env(|env| -> jni::errors::Result<JString<'local>> {
let Some(h) = client(handle) else {
return Ok(JString::default());
};
let msg = match h.client.next_clip(Duration::from_millis(250)) {
Ok(ClipEventCore::State { enabled, .. }) => format!("state:{}", u8::from(enabled)),
Ok(ClipEventCore::RemoteOffer { seq, kinds }) => {
let has_text = kinds.iter().any(|k| k.mime.starts_with("text/plain"));
format!("offer:{seq}:{}", u8::from(has_text))
}
}
Ok(ClipEventCore::Data { xfer_id, bytes, .. }) => {
format!("data:{xfer_id}:{}", String::from_utf8_lossy(&bytes))
}
Ok(ClipEventCore::Cancelled { id }) => format!("cancel:{id}"),
Ok(ClipEventCore::Error { id, code }) => format!("error:{id}:{code}"),
Err(PunktfunkError::NoFrame) => return std::ptr::null_mut(),
Err(_) => "closed".into(),
};
env.new_string(msg)
.map(|s| s.into_raw())
.unwrap_or(std::ptr::null_mut())
Ok(ClipEventCore::FetchRequest { req_id, mime, .. }) => {
if mime.starts_with("text/plain") {
format!("fetch:{req_id}")
} else {
// We only ever offer text; cancel anything else rather than stall the host.
let _ = h.client.clip_cancel(req_id);
return Ok(JString::default());
}
}
Ok(ClipEventCore::Data { xfer_id, bytes, .. }) => {
format!("data:{xfer_id}:{}", String::from_utf8_lossy(&bytes))
}
Ok(ClipEventCore::Cancelled { id }) => format!("cancel:{id}"),
Ok(ClipEventCore::Error { id, code }) => format!("error:{id}:{code}"),
Err(PunktfunkError::NoFrame) => return Ok(JString::default()),
Err(_) => "closed".into(),
};
env.new_string(msg)
})
.resolve::<LogErrorAndDefault>()
}
+113 -105
View File
@@ -1,9 +1,10 @@
//! Connect lifecycle + the trust surface: identity mint, connect (TOFU / pinned), close,
//! host-fingerprint read, and the SPAKE2 PIN pairing ceremony.
use jni::errors::LogErrorAndDefault;
use jni::objects::{JObject, JString};
use jni::sys::{jboolean, jint, jlong};
use jni::JNIEnv;
use jni::EnvUnowned;
use punktfunk_core::client::NativeClient;
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
use std::sync::{Arc, Mutex};
@@ -36,26 +37,24 @@ fn note_error(e: &punktfunk_core::error::PunktfunkError) {
/// `NativeBridge.nativeTakeLastError(): String` — the machine token of the most recent failed
/// `nativeConnect`/`nativePair`, cleared on read (`""` when none). Call right after a `0`
/// handle / `""` fingerprint.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeTakeLastError<'local>(
env: JNIEnv<'local>,
mut env: EnvUnowned<'local>,
_this: JObject<'local>,
) -> jni::sys::jstring {
) -> JString<'local> {
let token = std::mem::take(&mut *lock_recover(&LAST_ERROR));
match env.new_string(token) {
Ok(s) => s.into_raw(),
Err(_) => JObject::null().into_raw(),
}
env.with_env(|env| env.new_string(token))
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeGenerateIdentity(): String` — mint a fresh persistent self-signed identity.
/// Returns `"<certPem>\n-----PUNKTFUNK-KEY-----\n<keyPem>"`, or `""` on failure (logged). Kotlin
/// persists it (Keystore-wrapped) and only calls this again when the store is genuinely empty.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeGenerateIdentity<'local>(
env: JNIEnv<'local>,
mut env: EnvUnowned<'local>,
_this: JObject<'local>,
) -> jni::sys::jstring {
) -> JString<'local> {
let out = match punktfunk_core::quic::endpoint::generate_identity() {
Ok((cert, key)) => format!("{cert}\n-----PUNKTFUNK-KEY-----\n{key}"),
Err(e) => {
@@ -63,10 +62,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeGenerateIde
String::new()
}
};
match env.new_string(out) {
Ok(s) => s.into_raw(),
Err(_) => JObject::null().into_raw(),
}
env.with_env(|env| env.new_string(out))
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeSetLowLatencyMode(enabled)` — apply the user's "Low-latency mode
@@ -74,13 +71,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeGenerateIde
/// the media sockets. Must be called BEFORE `nativeConnect` (the tag is applied at socket
/// creation); Kotlin's one connect choke point (`HostConnect.connectToHost`) does. The rest of the
/// toggle rides explicit per-session parameters (`nativeStartVideo` / `nativeStartAudio`).
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetLowLatencyMode(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
enabled: jboolean,
) {
punktfunk_core::transport::set_dscp_default(enabled != 0);
punktfunk_core::transport::set_dscp_default(enabled);
}
/// `debug.punktfunk.force_parts` = 1: arm slice-progressive parts delivery even when the
@@ -120,10 +117,10 @@ fn force_parts_sysprop() -> bool {
/// budget: the normal path passes a short value, the no-PIN "request access" path a long one (≥ the
/// host's approval-park window) so a slow operator approval lands on this same parked connection
/// rather than timing the client out first. Returns an opaque handle, or 0 on failure.
#[no_mangle]
#[unsafe(no_mangle)]
#[allow(clippy::too_many_arguments)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'local>(
mut env: JNIEnv<'local>,
mut env: EnvUnowned<'local>,
_this: JObject<'local>,
host: JString<'local>,
port: jint,
@@ -147,31 +144,44 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
device_name: JString<'local>,
pad_audio_ok: jboolean,
) -> jlong {
let host: String = match env.get_string(&host) {
Ok(s) => s.into(),
Err(_) => return 0,
// Every JNI string this method needs, read up front in the one `Env` scope jni 0.22 grants a
// native method; everything below is pure Rust over owned `String`s. `None` = the mandatory
// `host` could not be read, which is the old `Err(_) => return 0` arm.
type ConnectStrings = Option<(
String,
String,
String,
String,
Option<String>,
Option<String>,
)>;
let strings: ConnectStrings = env
.with_env(|env| -> jni::errors::Result<ConnectStrings> {
let Ok(host) = host.try_to_string(env) else {
return Ok(None);
};
let cert: String = cert_pem.try_to_string(env).unwrap_or_default();
let key: String = key_pem.try_to_string(env).unwrap_or_default();
let pin_hex: String = pin_hex.try_to_string(env).unwrap_or_default();
// A store-qualified library id (`steam:<appid>` / `custom:<id>`) to boot straight into a
// game; null / empty ⇒ None (a plain desktop connect). Rides the Hello as `launch`.
let launch: Option<String> = launch
.try_to_string(env)
.ok()
.filter(|s: &String| !s.is_empty());
// The host's approval-list / trust-store label for this device; null / blank ⇒ None (the
// host falls back to its fingerprint-derived "device abcd1234" placeholder).
let device_name: Option<String> = device_name
.try_to_string(env)
.ok()
.map(|s: String| s.trim().to_string())
.filter(|s| !s.is_empty());
Ok(Some((host, cert, key, pin_hex, launch, device_name)))
})
.resolve::<LogErrorAndDefault>();
let Some((host, cert, key, pin_hex, launch, device_name)) = strings else {
return 0;
};
let cert: String = env
.get_string(&cert_pem)
.map(Into::into)
.unwrap_or_default();
let key: String = env.get_string(&key_pem).map(Into::into).unwrap_or_default();
let pin_hex: String = env.get_string(&pin_hex).map(Into::into).unwrap_or_default();
// A store-qualified library id (`steam:<appid>` / `custom:<id>`) to boot straight into a game;
// null / empty ⇒ None (a plain desktop connect). Rides the Hello as `launch`.
let launch: Option<String> = env
.get_string(&launch)
.map(Into::into)
.ok()
.filter(|s: &String| !s.is_empty());
// The host's approval-list / trust-store label for this device; null / blank ⇒ None (the host
// falls back to its fingerprint-derived "device abcd1234" placeholder).
let device_name: Option<String> = env
.get_string(&device_name)
.map(Into::into)
.ok()
.map(|s: String| s.trim().to_string())
.filter(|s| !s.is_empty());
let identity: Option<(String, String)> = if cert.is_empty() || key.is_empty() {
None
@@ -184,16 +194,16 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
// the feature? (`adb shell setprop debug.punktfunk.force_parts 1` + stream restart; a codec
// that can't take parts errors recoverably and the reanchor gate + keyframe path recovers.)
let force_parts = force_parts_sysprop();
let frame_parts = frame_parts_ok != 0 || force_parts;
let frame_parts = frame_parts_ok || force_parts;
// The connect-time capability readout (`adb logcat -s pf.caps`): the P2 slice pipeline is
// inert client-side unless BOTH probes pass — this line is the one place that says which.
log::info!(
target: "pf.caps",
"decoder caps: multi_slice={} partial_frame={}{} hdr={} codec_bits={:#x}",
multi_slice_ok != 0,
frame_parts_ok != 0,
multi_slice_ok,
frame_parts_ok,
if force_parts { " (FORCED by sysprop)" } else { "" },
hdr_enabled != 0,
hdr_enabled,
video_codecs,
);
let pin: Option<[u8; 32]> = if pin_hex.is_empty() {
@@ -229,11 +239,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
// decoder this device would use (`VideoDecoders.multiSliceTolerant` — Amlogic wedges the
// whole device on multi-slice AUs, the 0.17.0 field regression) and only then may the
// host default to >1 slice per frame (its sub-frame readback / the P2 slice pipeline).
(if hdr_enabled != 0 {
(if hdr_enabled {
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR
} else {
0
}) | (if multi_slice_ok != 0 {
}) | (if multi_slice_ok {
punktfunk_core::quic::VIDEO_CAP_MULTI_SLICE
} else {
0
@@ -274,7 +284,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
// so declaring a pad's render caps later would have nothing to gate. Gated on the
// settings so a user with pad audio off does not make the host provision endpoints.
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK
| if pad_audio_ok != 0 {
| if pad_audio_ok {
punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO
} else {
0
@@ -322,9 +332,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
/// # Safety contract
/// `handle` must be `0` or a live handle from [`Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect`],
/// closed exactly once and not concurrently with other calls on the same handle (Kotlin owns this).
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClose(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
) {
@@ -344,9 +354,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClose(
/// # Safety contract
/// `handle` must be `0` or a live handle from [`Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect`],
/// not freed / closed concurrently with this call (Kotlin still owns it and closes it via `nativeClose`).
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDisconnectQuit(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
) {
@@ -363,12 +373,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDisconnectQ
/// `NativeBridge.nativeHostFingerprint(handle): String` — the SHA-256 (64-hex) of the cert the host
/// presented on this connection. Valid after a successful `nativeConnect`; Kotlin pins it on a TOFU
/// connect. `""` on a `0` handle.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostFingerprint<'local>(
env: JNIEnv<'local>,
mut env: EnvUnowned<'local>,
_this: JObject<'local>,
handle: jlong,
) -> jni::sys::jstring {
) -> JString<'local> {
let out = if handle == 0 {
String::new()
} else {
@@ -376,10 +386,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostFingerp
let h = unsafe { &*(handle as *const SessionHandle) };
hex32(&h.client.host_fingerprint)
};
match env.new_string(out) {
Ok(s) => s.into_raw(),
Err(_) => JObject::null().into_raw(),
}
env.with_env(|env| env.new_string(out))
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeSessionEnded(handle): Boolean` — has the underlying QUIC session ended?
@@ -388,19 +396,19 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostFingerp
/// Kotlin's stream watchdog polls this (~1 Hz) to leave a dead stream and return to the menu (where
/// the user can Wake-on-LAN the host) instead of stranding them on a frozen frame. `false` on a `0`
/// handle. Cheap (one atomic load); safe on the UI thread.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSessionEnded(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
) -> jboolean {
jni_guard(0, || {
jni_guard(false, || {
if handle == 0 {
return 0;
return false;
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
jboolean::from(h.client.is_session_ended())
h.client.is_session_ended()
})
}
@@ -413,9 +421,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSessionEnde
/// this existed the watchdog worded them identically ("the host may be asleep"), which is wrong for
/// every deliberate ending. `0` (NONE) on a `0` handle or before the session ends. Cheap (one
/// atomic load); safe on the UI thread.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeEndReason(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
) -> jint {
@@ -433,10 +441,10 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeEndReason(
/// ceremony, presenting our persistent identity. On success returns the host's verified fingerprint
/// (64-hex) to persist + pin; on any failure (wrong PIN / MITM / host reject / unreachable) returns
/// `""` (logged). Blocking — Kotlin calls it off the UI thread.
#[no_mangle]
#[unsafe(no_mangle)]
#[allow(clippy::too_many_arguments)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePair<'local>(
mut env: JNIEnv<'local>,
mut env: EnvUnowned<'local>,
_this: JObject<'local>,
host: JString<'local>,
port: jint,
@@ -444,40 +452,40 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePair<'local
key_pem: JString<'local>,
pin: JString<'local>,
name: JString<'local>,
) -> jni::sys::jstring {
let g = |e: &mut JNIEnv<'local>, j: &JString<'local>| -> String {
e.get_string(j).map(Into::into).unwrap_or_default()
};
let host = g(&mut env, &host);
let cert = g(&mut env, &cert_pem);
let key = g(&mut env, &key_pem);
let pin = g(&mut env, &pin);
let name = g(&mut env, &name);
) -> JString<'local> {
env.with_env(|env| -> jni::errors::Result<JString<'local>> {
let g = |e: &jni::Env<'local>, j: &JString<'local>| -> String {
j.try_to_string(e).unwrap_or_default()
};
let host = g(env, &host);
let cert = g(env, &cert_pem);
let key = g(env, &key_pem);
let pin = g(env, &pin);
let name = g(env, &name);
let out = if host.is_empty() || cert.is_empty() || key.is_empty() {
log::error!("nativePair: missing host/identity");
String::new()
} else {
match NativeClient::pair(
&host,
port as u16,
(&cert, &key), // borrowed identity
&pin,
&name,
Duration::from_secs(60),
) {
Ok(host_fp) => hex32(&host_fp),
Err(e) => {
// Crypto error == wrong PIN / MITM; anything else == transport/host reject.
// The token lets Kotlin say WHICH (`nativeTakeLastError`).
log::error!("nativePair to {host}:{port} failed: {e}");
note_error(&e);
String::new()
let out = if host.is_empty() || cert.is_empty() || key.is_empty() {
log::error!("nativePair: missing host/identity");
String::new()
} else {
match NativeClient::pair(
&host,
port as u16,
(&cert, &key), // borrowed identity
&pin,
&name,
Duration::from_secs(60),
) {
Ok(host_fp) => hex32(&host_fp),
Err(e) => {
// Crypto error == wrong PIN / MITM; anything else == transport/host reject.
// The token lets Kotlin say WHICH (`nativeTakeLastError`).
log::error!("nativePair to {host}:{port} failed: {e}");
note_error(&e);
String::new()
}
}
}
};
match env.new_string(out) {
Ok(s) => s.into_raw(),
Err(_) => JObject::null().into_raw(),
}
};
env.new_string(out)
})
.resolve::<LogErrorAndDefault>()
}
+145 -132
View File
@@ -6,9 +6,10 @@
//! conventions: buttons 1=left/2=middle/3=right/4=X1/5=X2; scroll axis 0=vertical/1=horizontal,
//! signed 120-unit delta, +=up/right; keys are Windows VK (mapped from KEYCODE_* on the Kotlin side).
use jni::errors::LogErrorAndDefault;
use jni::objects::{JByteBuffer, JFloatArray, JObject, JString};
use jni::sys::{jboolean, jint, jlong};
use jni::JNIEnv;
use jni::EnvUnowned;
use punktfunk_core::input::{InputEvent, InputKind};
use punktfunk_core::quic::{
PenSample, PenTool, RichInput, HID_REPORT_MAX, HOST_CAP_PEN, HOST_CAP_TEXT_INPUT,
@@ -35,9 +36,9 @@ fn send_event(handle: jlong, kind: InputKind, code: u32, x: i32, y: i32, flags:
}
/// `NativeBridge.nativeSendPointerMove(handle, dx, dy)` — relative mouse motion (screen +y down).
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointerMove(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
dx: jint,
@@ -51,9 +52,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointer
/// normalizing against the size packed into `flags` as `(w << 16) | h` and mapping into the output
/// region (it drops the event if that size is zero). This is the touch "direct pointing" path — the
/// cursor jumps to the finger — and matches the Apple client's absolute touch forwarding.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointerAbs(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
x: jint,
@@ -68,15 +69,15 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointer
/// `NativeBridge.nativeSendPointerButton(handle, button, down)` — one button transition.
/// `button`: GameStream id (1=left, 2=middle, 3=right, 4=X1, 5=X2). `down`: 1=press, 0=release.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointerButton(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
button: jint,
down: jboolean,
) {
let kind = if down != 0 {
let kind = if down {
InputKind::MouseButtonDown
} else {
InputKind::MouseButtonUp
@@ -86,9 +87,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointer
/// `NativeBridge.nativeSendScroll(handle, axis, delta)` — one scroll step. `axis`: 0=vertical,
/// 1=horizontal. `delta`: signed, WHEEL_DELTA(120)-scaled, +=up/right.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendScroll(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
axis: jint,
@@ -103,9 +104,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendScroll(
/// surface, whose size rides in `flags` so the host can rescale into the output (identical
/// packing to MouseMoveAbs). On up only the id matters. The host injects a real touch contact
/// (libei touchscreen / wlroots / SendInput).
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendTouch(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
id: jint,
@@ -128,9 +129,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendTouch(
/// `NativeBridge.nativeSendKey(handle, vk, down, mods)` — one key transition. `vk`: Windows
/// Virtual-Key code (0 = unmapped → dropped). `down`: 1=press, 0=release. `mods`: VK modifier
/// bitmask (0 for now — the host folds modifiers from the L/R modifier key events themselves).
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendKey(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
vk: jint,
@@ -140,7 +141,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendKey(
if vk == 0 {
return;
}
let kind = if down != 0 {
let kind = if down {
InputKind::KeyDown
} else {
InputKind::KeyUp
@@ -151,35 +152,35 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendKey(
/// `NativeBridge.nativeTextInputSupported(handle)` — whether the host advertised
/// `HOST_CAP_TEXT_INPUT` (its inject backend types committed text), so the Kotlin side can pick
/// the real IME `InputConnection` over the TYPE_NULL raw-key fallback. `0` handle → false.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeTextInputSupported(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
) -> jboolean {
if handle == 0 {
return 0;
return false;
}
// SAFETY: live handle per the nativeConnect/nativeClose contract; host_caps is &self.
let h = unsafe { &*(handle as *const SessionHandle) };
u8::from(h.client.host_caps() & HOST_CAP_TEXT_INPUT != 0)
h.client.host_caps() & HOST_CAP_TEXT_INPUT != 0
}
/// `NativeBridge.nativeHostSupportsPen(handle)` — the host advertised `HOST_CAP_PEN`, so the
/// Kotlin side splits stylus pointers out of the touch path onto the pen plane
/// (design/pen-tablet-input.md §7). `0` handle → false.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostSupportsPen(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
) -> jboolean {
if handle == 0 {
return 0;
return false;
}
// SAFETY: live handle per the nativeConnect/nativeClose contract; host_caps is &self.
let h = unsafe { &*(handle as *const SessionHandle) };
u8::from(h.client.host_caps() & HOST_CAP_PEN != 0)
h.client.host_caps() & HOST_CAP_PEN != 0
}
/// Floats per sample in the `nativeSendPen` flat array.
@@ -197,89 +198,97 @@ const PEN_JNI_MAX_SAMPLES: usize = PEN_BATCH_MAX * 8;
/// normalized 0..1; `distance`/`tilt_deg`/`azimuth_deg`/`roll_deg` < 0 = unknown. Call only
/// against a [`nativeHostSupportsPen`] host; the client heartbeats the last sample ≤100 ms
/// while in range (Kotlin side — see `StylusStream`).
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPen(
env: JNIEnv,
mut env: EnvUnowned,
_this: JObject,
handle: jlong,
samples: JFloatArray,
count: jint,
) {
if handle == 0 || count <= 0 {
return;
}
let count = (count as usize).min(PEN_JNI_MAX_SAMPLES);
let mut buf = [0f32; PEN_JNI_MAX_SAMPLES * PEN_JNI_STRIDE];
let flat = &mut buf[..count * PEN_JNI_STRIDE];
if env.get_float_array_region(&samples, 0, flat).is_err() {
return; // short array — a bridge bug, never worth a crash on the input path
}
// SAFETY: live handle per the nativeConnect/nativeClose contract; send_pen is &self.
let h = unsafe { &*(handle as *const SessionHandle) };
let mut batch = [PenSample::default(); PEN_BATCH_MAX];
for run in flat.chunks(PEN_BATCH_MAX * PEN_JNI_STRIDE) {
let n = run.len() / PEN_JNI_STRIDE;
for (slot, s) in batch.iter_mut().zip(run.chunks_exact(PEN_JNI_STRIDE)) {
if !s[2].is_finite() || !s[3].is_finite() {
return; // never forward a NaN coordinate
}
*slot = PenSample {
state: s[0] as u8,
tool: if s[1] as u8 == 1 {
PenTool::Eraser
} else {
PenTool::Pen
},
x: s[2].clamp(0.0, 1.0),
y: s[3].clamp(0.0, 1.0),
pressure: (s[4].clamp(0.0, 1.0) * 65535.0) as u16,
distance: if s[5] < 0.0 {
PEN_DISTANCE_UNKNOWN
} else {
(s[5].clamp(0.0, 1.0) * 65534.0) as u16
},
tilt_deg: if s[6] < 0.0 {
PEN_TILT_UNKNOWN
} else {
(s[6].clamp(0.0, 90.0)) as u8
},
azimuth_deg: if s[7] < 0.0 {
PEN_ANGLE_UNKNOWN
} else {
(s[7] as u16) % 360
},
roll_deg: if s[8] < 0.0 {
PEN_ANGLE_UNKNOWN
} else {
(s[8] as u16) % 360
},
dt_us: s[9].clamp(0.0, 65535.0) as u16,
};
env.with_env(|env| -> jni::errors::Result<()> {
if handle == 0 || count <= 0 {
return Ok(());
}
let _ = h.client.send_pen(&batch[..n]);
}
let count = (count as usize).min(PEN_JNI_MAX_SAMPLES);
let mut buf = [0f32; PEN_JNI_MAX_SAMPLES * PEN_JNI_STRIDE];
let flat = &mut buf[..count * PEN_JNI_STRIDE];
if samples.get_region(env, 0, flat).is_err() {
return Ok(()); // short array — a bridge bug, never worth a crash on the input path
}
// SAFETY: live handle per the nativeConnect/nativeClose contract; send_pen is &self.
let h = unsafe { &*(handle as *const SessionHandle) };
let mut batch = [PenSample::default(); PEN_BATCH_MAX];
for run in flat.chunks(PEN_BATCH_MAX * PEN_JNI_STRIDE) {
let n = run.len() / PEN_JNI_STRIDE;
for (slot, s) in batch.iter_mut().zip(run.chunks_exact(PEN_JNI_STRIDE)) {
if !s[2].is_finite() || !s[3].is_finite() {
return Ok(()); // never forward a NaN coordinate
}
*slot = PenSample {
state: s[0] as u8,
tool: if s[1] as u8 == 1 {
PenTool::Eraser
} else {
PenTool::Pen
},
x: s[2].clamp(0.0, 1.0),
y: s[3].clamp(0.0, 1.0),
pressure: (s[4].clamp(0.0, 1.0) * 65535.0) as u16,
distance: if s[5] < 0.0 {
PEN_DISTANCE_UNKNOWN
} else {
(s[5].clamp(0.0, 1.0) * 65534.0) as u16
},
tilt_deg: if s[6] < 0.0 {
PEN_TILT_UNKNOWN
} else {
(s[6].clamp(0.0, 90.0)) as u8
},
azimuth_deg: if s[7] < 0.0 {
PEN_ANGLE_UNKNOWN
} else {
(s[7] as u16) % 360
},
roll_deg: if s[8] < 0.0 {
PEN_ANGLE_UNKNOWN
} else {
(s[8] as u16) % 360
},
dt_us: s[9].clamp(0.0, 65535.0) as u16,
};
}
let _ = h.client.send_pen(&batch[..n]);
}
Ok(())
})
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeSendText(handle, text)` — committed IME text, one `TextInput` event per
/// Unicode scalar (`code` = the scalar; multi-char commits are consecutive events in order).
/// Control characters are skipped — Enter/Backspace/Tab ride the VK key path. Call only when
/// [`Java_io_unom_punktfunk_kit_NativeBridge_nativeTextInputSupported`] returned true.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendText(
mut env: JNIEnv,
mut env: EnvUnowned,
_this: JObject,
handle: jlong,
text: JString,
) {
if handle == 0 {
return;
}
let Ok(s) = env.get_string(&text) else {
return;
};
for ch in String::from(s).chars().filter(|c| !c.is_control()) {
send_event(handle, InputKind::TextInput, ch as u32, 0, 0, 0);
}
env.with_env(|env| -> jni::errors::Result<()> {
if handle == 0 {
return Ok(());
}
let Ok(s) = text.try_to_string(env) else {
return Ok(());
};
for ch in s.chars().filter(|c| !c.is_control()) {
send_event(handle, InputKind::TextInput, ch as u32, 0, 0, 0);
}
Ok(())
})
.resolve::<LogErrorAndDefault>()
}
// ---- Gamepad: Kotlin captures (KeyEvent/MotionEvent) → NativeClient::send_input ---------------
@@ -296,9 +305,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendText(
/// `NativeBridge.nativeSendGamepadButton(handle, bit, down, pad)` — one gamepad button transition on
/// wire pad index `pad`. `bit`: a `gamepad::BTN_*` bit (e.g. BTN_A = 0x1000). `down`: 1=press,
/// 0=release. `pad`: wire pad index 0..15 (rides `flags`).
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadButton(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
bit: jint,
@@ -309,7 +318,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
handle,
InputKind::GamepadButton,
bit as u32,
i32::from(down != 0),
i32::from(down),
0,
pad as u32,
);
@@ -318,9 +327,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
/// `NativeBridge.nativeSendGamepadAxis(handle, axisId, value, pad)` — one gamepad axis update on wire
/// pad index `pad`. `axisId`: a `gamepad::AXIS_*` id (LS_X=0..RT=5). `value`: stick i16
/// (32768..32767, +y=up) or trigger 0..255. `pad`: wire pad index 0..15 (rides `flags`).
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadAxis(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
axis_id: jint,
@@ -343,9 +352,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
/// index 0..15 (rides `flags`). Sent ONCE when a pad opens, BEFORE any of its input; the core re-sends
/// it a few times against datagram loss, and an older host ignores the unknown tag (that pad then uses
/// the session-default kind from the handshake — the pre-existing single-pad behaviour on pad 0).
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadArrival(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
pref: jint,
@@ -373,35 +382,35 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
///
/// A `0` handle answers `true` — "don't suppress" is the safe answer when we cannot tell, matching
/// the `Auto` rule inside the predicate itself.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadMotionReaches(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
declared_pref: jint,
) -> jboolean {
if handle == 0 {
return 1;
return true;
}
// SAFETY: live handle per the nativeConnect/nativeClose contract; both fields are plain Copy
// values read behind `&self`.
let h = unsafe { &*(handle as *const SessionHandle) };
let declared =
punktfunk_core::config::GamepadPref::from_u8(declared_pref.clamp(0, u8::MAX as jint) as u8);
u8::from(punktfunk_core::config::pad_motion_reaches(
punktfunk_core::config::pad_motion_reaches(
declared,
h.client.requested_gamepad,
h.client.resolved_gamepad,
))
)
}
/// `NativeBridge.nativeSendGamepadRemove(handle, pad)` — signal that wire pad index `pad` was
/// unplugged so the host tears its virtual device down. `pad` (rides `flags`) is the only field; the
/// core stamps the per-pad seq (in the snapshot seq space, so a reordered snapshot can't resurrect the
/// pad) and arms a re-send burst against datagram loss. An older host ignores the unknown tag.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadRemove(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
pad: jint,
@@ -415,38 +424,42 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
/// `len` bytes are the report, id byte first (`0x42`/`0x45`/`0x47` state, `0x43` battery, …);
/// `len` is clamped to the 64-byte wire body. Called from the capture thread at the controller's
/// own report rate (~250500 Hz) — the direct-buffer read avoids a JNI array copy per report.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadHidReport(
env: JNIEnv,
mut env: EnvUnowned,
_this: JObject,
handle: jlong,
pad: jint,
buf: JByteBuffer,
len: jint,
) {
if handle == 0 || len <= 0 {
return;
}
let cap = match env.get_direct_buffer_capacity(&buf) {
Ok(c) => c,
Err(_) => return,
};
let ptr = match env.get_direct_buffer_address(&buf) {
Ok(p) if !p.is_null() => p,
_ => return,
};
let n = (len as usize).min(cap).min(HID_REPORT_MAX);
let mut data = [0u8; HID_REPORT_MAX];
// SAFETY: `ptr`/`cap` describe the direct ByteBuffer's backing store, valid for this call;
// `n` is bounded by both the buffer capacity and the fixed wire body.
data[..n].copy_from_slice(unsafe { std::slice::from_raw_parts(ptr, n) });
// SAFETY: live handle per the nativeConnect/nativeClose contract; send_rich_input is &self.
let h = unsafe { &*(handle as *const SessionHandle) };
let _ = h.client.send_rich_input(RichInput::HidReport {
pad: (pad as u32 & 0xF) as u8,
len: n as u8,
data,
});
env.with_env(|env| -> jni::errors::Result<()> {
if handle == 0 || len <= 0 {
return Ok(());
}
let cap = match env.get_direct_buffer_capacity(&buf) {
Ok(c) => c,
Err(_) => return Ok(()),
};
let ptr = match env.get_direct_buffer_address(&buf) {
Ok(p) if !p.is_null() => p,
_ => return Ok(()),
};
let n = (len as usize).min(cap).min(HID_REPORT_MAX);
let mut data = [0u8; HID_REPORT_MAX];
// SAFETY: `ptr`/`cap` describe the direct ByteBuffer's backing store, valid for this call;
// `n` is bounded by both the buffer capacity and the fixed wire body.
data[..n].copy_from_slice(unsafe { std::slice::from_raw_parts(ptr, n) });
// SAFETY: live handle per the nativeConnect/nativeClose contract; send_rich_input is &self.
let h = unsafe { &*(handle as *const SessionHandle) };
let _ = h.client.send_rich_input(RichInput::HidReport {
pad: (pad as u32 & 0xF) as u8,
len: n as u8,
data,
});
Ok(())
})
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeSendPadTouch(handle, pad, finger, active, x, y)` — one touchpad contact
@@ -455,9 +468,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadHidR
/// SCREEN convention (+y down — the wire's fixed meaning); `active` 0 lifts the finger. The
/// host's DualSense-family backends scale onto the virtual pad's touch surface. On-change only —
/// the capture diffs, the host holds per-slot state.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadTouch(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
pad: jint,
@@ -474,7 +487,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadTouc
let _ = h.client.send_rich_input(RichInput::Touchpad {
pad: (pad as u32 & 0xF) as u8,
finger: (finger as u32 & 0x1) as u8,
active: active != 0,
active,
x: (x as i64).clamp(0, 65535) as u16,
y: (y as i64).clamp(0, 65535) as u16,
});
@@ -485,10 +498,10 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadTouc
/// raw signed-16 values in the pad's own units, passed straight into the host's virtual
/// DualSense report (the wire is a unit passthrough). Called from the capture thread at the
/// controller's report rate.
#[no_mangle]
#[unsafe(no_mangle)]
#[allow(clippy::too_many_arguments)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadMotion(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
pad: jint,
+141 -151
View File
@@ -1,12 +1,10 @@
//! Plane start/stop: video (HEVC decode → Surface), host→client audio, mic uplink — plus the
//! ~1 Hz decode-stats drain for the HUD.
use jni::objects::JObject;
// Used only by the android-gated `nativeStartVideo`; on the host build that fn is cfg'd out.
#[cfg(target_os = "android")]
use jni::objects::JString;
use jni::sys::{jboolean, jdoubleArray, jintArray, jlong, jsize, jstring};
use jni::JNIEnv;
use jni::errors::LogErrorAndDefault;
use jni::objects::{JDoubleArray, JIntArray, JObject, JString};
use jni::sys::{jboolean, jlong};
use jni::EnvUnowned;
use super::{jni_guard, lock_recover, SessionHandle};
@@ -19,9 +17,9 @@ use super::{jni_guard, lock_recover, SessionHandle};
/// presenter's intent (0 = lowest latency / 1 = smoothness; buffer 0 = auto, 1..=3 frames).
/// No-op if already started.
#[cfg(target_os = "android")]
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
mut env: JNIEnv,
mut env: EnvUnowned,
_this: JObject,
handle: jlong,
surface: JObject,
@@ -37,53 +35,58 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
if handle == 0 {
return;
}
// The decoder name Kotlin picked (empty string / read failure ⇒ None ⇒ default resolver).
let decoder = env
.get_string(&decoder_name)
.ok()
.map(String::from)
.filter(|s| !s.is_empty());
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
let mut guard = lock_recover(&h.video);
if guard.is_some() {
return; // already streaming
}
// SAFETY: `env`/`surface` are valid JNI pointers for this call. `as *mut _` bridges any
// jni-sys version skew between the `jni` and `ndk` crates (both are raw `*mut _` pointers).
let window = match unsafe {
ndk::native_window::NativeWindow::from_surface(
env.get_native_interface() as *mut _,
surface.as_raw() as *mut _,
)
} {
Some(w) => w,
None => {
log::error!("nativeStartVideo: no ANativeWindow from Surface");
return;
env.with_env(|env| -> jni::errors::Result<()> {
if handle == 0 {
return Ok(());
}
};
let shutdown = Arc::new(AtomicBool::new(false));
let client = h.client.clone();
let sd = shutdown.clone();
let st = h.stats.clone(); // session-lifetime stats (gate survives surface recreate)
let opts = crate::decode::DecodeOptions {
decoder_name: decoder,
ll_feature: ll_feature != 0,
low_latency_mode: low_latency_mode != 0,
is_tv: is_tv != 0,
present_priority,
smooth_buffer,
panel_hz: panel_fps,
};
let join = std::thread::Builder::new()
.name("pf-decode".into())
.spawn(move || crate::decode::run(client, window, sd, st, opts))
.ok();
*guard = Some(VideoThread { shutdown, join });
// The decoder name Kotlin picked (empty string / read failure ⇒ None ⇒ default resolver).
let decoder = decoder_name
.try_to_string(env)
.ok()
.filter(|s| !s.is_empty());
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
let mut guard = lock_recover(&h.video);
if guard.is_some() {
return Ok(()); // already streaming
}
// SAFETY: `env`/`surface` are valid JNI pointers for this call. `as *mut _` bridges any
// jni-sys version skew between the `jni` and `ndk` crates (both are raw `*mut _` pointers)
// — a real skew here, not a hypothetical one: `jni` is on jni-sys 0.4 while the vendored
// `ndk` is still on 0.3.
let window = match unsafe {
ndk::native_window::NativeWindow::from_surface(
env.get_raw() as *mut _,
surface.as_raw() as *mut _,
)
} {
Some(w) => w,
None => {
log::error!("nativeStartVideo: no ANativeWindow from Surface");
return Ok(());
}
};
let shutdown = Arc::new(AtomicBool::new(false));
let client = h.client.clone();
let sd = shutdown.clone();
let st = h.stats.clone(); // session-lifetime stats (gate survives surface recreate)
let opts = crate::decode::DecodeOptions {
decoder_name: decoder,
ll_feature,
low_latency_mode,
is_tv,
present_priority,
smooth_buffer,
panel_hz: panel_fps,
};
let join = std::thread::Builder::new()
.name("pf-decode".into())
.spawn(move || crate::decode::run(client, window, sd, st, opts))
.ok();
*guard = Some(VideoThread { shutdown, join });
Ok(())
})
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeVideoMime(handle): String` — the MediaCodec MIME for the codec the host
@@ -91,23 +94,21 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
/// decoders for it before calling [`Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo`].
/// Empty string on a `0` handle. Cheap; safe on the UI thread.
#[cfg(target_os = "android")]
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoMime<'local>(
env: JNIEnv<'local>,
mut env: EnvUnowned<'local>,
_this: JObject<'local>,
handle: jlong,
) -> jstring {
jni_guard(std::ptr::null_mut(), || {
) -> JString<'local> {
env.with_env(|env| -> jni::errors::Result<JString<'local>> {
if handle == 0 {
return std::ptr::null_mut();
return Ok(JString::default());
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
match env.new_string(crate::decode::codec_mime(h.client.codec)) {
Ok(s) => s.into_raw(),
Err(_) => std::ptr::null_mut(),
}
env.new_string(crate::decode::codec_mime(h.client.codec))
})
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeVideoCodecLabel(handle): String` — a short human label for the codec the
@@ -116,23 +117,21 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoMime<'
/// collapses PyroWave onto `video/hevc` and can't name it. Empty string on a `0` handle. Cheap;
/// safe on the UI thread. Android-gated (reads `crate::decode`), matching `nativeVideoMime`.
#[cfg(target_os = "android")]
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoCodecLabel<'local>(
env: JNIEnv<'local>,
mut env: EnvUnowned<'local>,
_this: JObject<'local>,
handle: jlong,
) -> jstring {
jni_guard(std::ptr::null_mut(), || {
) -> JString<'local> {
env.with_env(|env| -> jni::errors::Result<JString<'local>> {
if handle == 0 {
return std::ptr::null_mut();
return Ok(JString::default());
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
match env.new_string(crate::decode::codec_label(h.client.codec)) {
Ok(s) => s.into_raw(),
Err(_) => std::ptr::null_mut(),
}
env.new_string(crate::decode::codec_label(h.client.codec))
})
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeVideoDecoderLabel(handle): String` — the resolved decoder identity for the
@@ -140,30 +139,28 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoCodecL
/// One-shot (the decoder is fixed for the session); poll once after the HUD appears. Not
/// android-gated — pure `jni` + a lock, so it links on the host build too (Kotlin only calls it on
/// device).
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoDecoderLabel<'local>(
env: JNIEnv<'local>,
mut env: EnvUnowned<'local>,
_this: JObject<'local>,
handle: jlong,
) -> jstring {
jni_guard(std::ptr::null_mut(), || {
) -> JString<'local> {
env.with_env(|env| -> jni::errors::Result<JString<'local>> {
if handle == 0 {
return std::ptr::null_mut();
return Ok(JString::default());
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
match env.new_string(h.stats.decoder_label()) {
Ok(s) => s.into_raw(),
Err(_) => std::ptr::null_mut(),
}
env.new_string(h.stats.decoder_label())
})
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeStopVideo(handle)` — stop + join the decode thread (without closing the
/// session). No-op on `0`.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
) {
@@ -210,20 +207,20 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
/// Poll ~1 Hz from the UI; each call
/// resets the measurement window. Not android-gated — pure `jni` + connector reads, so it links on
/// the host build too (Kotlin only ever calls it on device).
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
env: JNIEnv,
_this: JObject,
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats<'local>(
mut env: EnvUnowned<'local>,
_this: JObject<'local>,
handle: jlong,
) -> jdoubleArray {
jni_guard(std::ptr::null_mut(), || {
) -> JDoubleArray<'local> {
env.with_env(|env| -> jni::errors::Result<JDoubleArray<'local>> {
if handle == 0 {
return std::ptr::null_mut();
return Ok(JDoubleArray::default());
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
if lock_recover(&h.video).is_none() {
return std::ptr::null_mut(); // not streaming → no stats
return Ok(JDoubleArray::default()); // not streaming → no stats
}
let snap = h
.stats
@@ -294,15 +291,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
h.client.audio_buffer_ms() as f64,
h.client.audio_av_offset_ms() as f64,
];
let arr = match env.new_double_array(buf.len() as jsize) {
Ok(a) => a,
Err(_) => return std::ptr::null_mut(),
};
if env.set_double_array_region(&arr, 0, &buf).is_err() {
return std::ptr::null_mut();
}
arr.into_raw()
let arr = env.new_double_array(buf.len())?;
arr.set_region(env, 0, &buf)?;
Ok(arr)
})
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeVideoSize(handle): IntArray?` — the negotiated video mode as
@@ -312,15 +305,15 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
/// trailing `refreshHz` was appended later — old readers index only 0/1 and never see it. `null`
/// on a `0` handle. Not android-gated — pure `jni` + a connector read, so it links on the host
/// build too. Cheap; safe on the UI thread.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSize(
env: JNIEnv,
_this: JObject,
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSize<'local>(
mut env: EnvUnowned<'local>,
_this: JObject<'local>,
handle: jlong,
) -> jintArray {
jni_guard(std::ptr::null_mut(), || {
) -> JIntArray<'local> {
env.with_env(|env| -> jni::errors::Result<JIntArray<'local>> {
if handle == 0 {
return std::ptr::null_mut();
return Ok(JIntArray::default());
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
@@ -330,15 +323,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSize(
mode.height as i32,
mode.refresh_hz as i32,
];
let arr = match env.new_int_array(buf.len() as jsize) {
Ok(a) => a,
Err(_) => return std::ptr::null_mut(),
};
if env.set_int_array_region(&arr, 0, &buf).is_err() {
return std::ptr::null_mut();
}
arr.into_raw()
let arr = env.new_int_array(buf.len())?;
arr.set_region(env, 0, &buf)?;
Ok(arr)
})
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeSetVideoStatsEnabled(handle, enabled)` — gate per-frame stats sampling on the
@@ -346,9 +335,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSize(
/// Enabling resets the measurement window so a later show never reports stale data. Sticky for the
/// session (survives video stop/start across surface recreation). No-op on `0`. Not android-gated —
/// pure `jni` + an atomic store, so it links on the host build too.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetVideoStatsEnabled(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
enabled: jboolean,
@@ -360,7 +349,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetVideoSta
// The current cumulative counters seed the window baselines, so the first snapshot's
// `lost`/`FEC` cover only time the HUD was actually up.
h.stats.set_enabled(
enabled != 0,
enabled,
h.client.frames_dropped(),
h.client.fec_recovered_shards(),
);
@@ -368,17 +357,18 @@ 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")]
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
low_latency_mode: jboolean,
is_tv: jboolean,
) {
if handle == 0 {
return;
@@ -389,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 != 0) {
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)"),
}
@@ -398,9 +388,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
/// `NativeBridge.nativeStopAudio(handle)` — stop + join the audio thread and close AAudio (without
/// closing the session). No-op on `0`.
#[cfg(target_os = "android")]
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopAudio(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
) {
@@ -422,9 +412,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopAudio(
/// the running capture's id. Caller MUST hold RECORD_AUDIO; a failure (e.g. no permission) leaves
/// the rest of the session streaming.
#[cfg(target_os = "android")]
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartMic(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
echo_cancel: jboolean,
@@ -440,7 +430,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartMic(
}
// The capture SHARES the session's mute flag, so one started while muted stays muted (and
// sends nothing) from its very first frame — see `SessionHandle::mic_muted`.
match crate::mic::MicCapture::start(h.client.clone(), echo_cancel != 0, h.mic_muted.clone()) {
match crate::mic::MicCapture::start(h.client.clone(), echo_cancel, h.mic_muted.clone()) {
Some(m) => {
let session_id = m.session_id();
*guard = Some(m);
@@ -457,9 +447,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartMic(
/// stream (without closing the session). No-op on `0`. Leaves the session's mute state alone: a
/// surface recreate stops and restarts the mic, and a user who muted must stay muted through it.
#[cfg(target_os = "android")]
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
) {
@@ -484,10 +474,10 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
/// start. A kernel that refuses the interface claim is NOT reported here — the renderer discovers
/// that on its own thread and degrades to tier C, because some OEM kernels refuse and there is no
/// app-side fix worth blocking a session on.
#[no_mangle]
#[unsafe(no_mangle)]
#[cfg(target_os = "android")]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAudio(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
pad: jni::sys::jint,
@@ -495,9 +485,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAud
haptics: jboolean,
speaker: jboolean,
) -> jboolean {
jni_guard(0, || {
jni_guard(false, || {
if handle == 0 || fd < 0 || !(0..16).contains(&pad) {
return 0;
return false;
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
@@ -512,14 +502,14 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAud
std::sync::Arc::clone(&h.client),
pad as u8,
fd,
haptics != 0,
speaker != 0,
haptics,
speaker,
) {
Some(p) => {
*lock_recover(&h.pad_audio) = Some(p);
1
true
}
None => 0,
None => false,
}
})
}
@@ -530,10 +520,10 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAud
/// The check a standalone harness cannot make: it owns its descriptor by construction, so it can
/// never reveal that the client handed the renderer a descriptor something else was already
/// driving. Returns sample frames written, or negative on failure (see `pad_audio::SelfTest`).
#[no_mangle]
#[unsafe(no_mangle)]
#[cfg(target_os = "android")]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadAudioSelfTest(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
fd: jni::sys::jint,
seconds: jni::sys::jint,
@@ -553,10 +543,10 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadAudioSel
///
/// Returns only once the render thread is joined, which is the point: Kotlin may close the
/// `UsbDeviceConnection` as soon as this returns and not before.
#[no_mangle]
#[unsafe(no_mangle)]
#[cfg(target_os = "android")]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopPadAudio(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
pad: jni::sys::jint,
@@ -594,9 +584,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopPadAudi
/// One honest consequence of keeping the stream open: the platform's own recording indicator stays
/// lit while muted, because the mic really is still open. What stops is the encode and the send —
/// no captured audio leaves the process.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetMicMuted(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
muted: jboolean,
@@ -606,7 +596,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetMicMuted
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
h.mic_muted
.store(muted != 0, std::sync::atomic::Ordering::Relaxed);
.store(muted, std::sync::atomic::Ordering::Relaxed);
}
})
}
@@ -617,18 +607,18 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetMicMuted
/// refused every AAudio input rung (or a missing RECORD_AUDIO grant) shows no control instead of a
/// lie about a mic that is being heard. `false` on a `0` handle. Cheap (one uncontended lock).
#[cfg(target_os = "android")]
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeMicActive(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
) -> jboolean {
jni_guard(0, || {
jni_guard(false, || {
if handle == 0 {
return 0;
return false;
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
jboolean::from(lock_recover(&h.mic).is_some())
lock_recover(&h.mic).is_some()
})
}
+21 -23
View File
@@ -10,9 +10,10 @@
//! coroutine on the main thread the way it polls the stats HUD.
use super::{jni_guard, SessionHandle};
use jni::objects::JObject;
use jni::sys::{jboolean, jdoubleArray, jint, jlong};
use jni::JNIEnv;
use jni::errors::LogErrorAndDefault;
use jni::objects::{JDoubleArray, JObject};
use jni::sys::{jboolean, jint, jlong};
use jni::EnvUnowned;
/// The `DoubleArray` [`Java_io_unom_punktfunk_kit_NativeBridge_nativeProbeResult`] returns. Kept in
/// one place because Kotlin indexes it positionally; see the Kotlin doc for the field order.
@@ -23,27 +24,27 @@ const PROBE_RESULT_LEN: usize = 6;
/// **briefly pausing video**. Non-blocking: poll
/// [`Java_io_unom_punktfunk_kit_NativeBridge_nativeProbeResult`] until its `done` element is 1.
/// Starting a probe resets any prior measurement. `false` on a `0` handle or a closed session.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSpeedTest(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
target_kbps: jint,
duration_ms: jint,
) -> jboolean {
jni_guard(0, || {
jni_guard(false, || {
if handle == 0 {
return 0;
return false;
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
let target = target_kbps.clamp(0, i32::MAX) as u32;
let duration = duration_ms.clamp(0, i32::MAX) as u32;
match h.client.request_probe(target, duration) {
Ok(()) => 1,
Ok(()) => true,
Err(e) => {
log::warn!("speed test: could not ask the host to probe: {e:?}");
0
false
}
}
})
@@ -54,15 +55,17 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSpeedTest(
///
/// Layout (doubles so one array carries both the counts and the percentages):
/// `[done, throughputKbps, lossPct, hostDropPct, elapsedMs, recvBytes]`.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeProbeResult<'local>(
env: JNIEnv<'local>,
mut env: EnvUnowned<'local>,
_this: JObject<'local>,
handle: jlong,
) -> jdoubleArray {
jni_guard(JObject::null().into_raw(), || {
) -> JDoubleArray<'local> {
// `JDoubleArray::default()` is the null reference the old `JObject::null().into_raw()` returned,
// so Kotlin still reads `null` on every failure path.
env.with_env(|env| -> jni::errors::Result<JDoubleArray<'local>> {
if handle == 0 {
return JObject::null().into_raw();
return Ok(JDoubleArray::default());
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
@@ -75,14 +78,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeProbeResult
f64::from(r.elapsed_ms),
r.recv_bytes as f64,
];
match env.new_double_array(PROBE_RESULT_LEN as i32) {
Ok(arr) => {
if env.set_double_array_region(&arr, 0, &values).is_err() {
return JObject::null().into_raw();
}
arr.into_raw()
}
Err(_) => JObject::null().into_raw(),
}
let arr = env.new_double_array(PROBE_RESULT_LEN)?;
arr.set_region(env, 0, &values)?;
Ok(arr)
})
.resolve::<LogErrorAndDefault>()
}
+20 -23
View File
@@ -3,38 +3,35 @@
//! host has no ARP entry, so the broadcast the core sends is what wakes it, and Kotlin calls this
//! just before connecting to an offline saved host.
use jni::errors::LogErrorAndDefault;
use jni::objects::{JObject, JString};
use jni::JNIEnv;
use jni::EnvUnowned;
/// `NativeBridge.nativeWakeOnLan(macsCsv: String, lastIp: String): Boolean` — send a Wake-on-LAN
/// magic packet. `macsCsv` is comma-separated MACs (`aa:bb:..,cc:dd:..`, learned from the host's
/// mDNS `mac` TXT while it was online); `lastIp` is the host's last-known IPv4 (or empty).
/// Returns true if at least one datagram went out.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeWakeOnLan<'local>(
mut env: JNIEnv<'local>,
mut env: EnvUnowned<'local>,
_this: JObject<'local>,
macs_csv: JString<'local>,
last_ip: JString<'local>,
) -> jni::sys::jboolean {
let macs_csv: String = match env.get_string(&macs_csv) {
Ok(s) => s.into(),
Err(_) => return 0,
};
let last_ip: String = env
.get_string(&last_ip)
.map(Into::<String>::into)
.unwrap_or_default();
let macs: Vec<[u8; 6]> = macs_csv
.split(',')
.filter_map(|s| punktfunk_core::wol::parse_mac(s.trim()))
.collect();
if macs.is_empty() {
return 0;
}
let ip = last_ip.trim().parse::<std::net::Ipv4Addr>().ok();
match punktfunk_core::wol::send_magic_packet(&macs, ip) {
Ok(()) => 1,
Err(_) => 0,
}
env.with_env(|env| -> jni::errors::Result<bool> {
let macs_csv: String = macs_csv.try_to_string(env)?;
// Unlike `macs_csv`, an unreadable `lastIp` is not fatal: the core falls back to the
// subnet broadcast when it has no address, so keep the old lenient default.
let last_ip: String = last_ip.try_to_string(env).unwrap_or_default();
let macs: Vec<[u8; 6]> = macs_csv
.split(',')
.filter_map(|s| punktfunk_core::wol::parse_mac(s.trim()))
.collect();
if macs.is_empty() {
return Ok(false);
}
let ip = last_ip.trim().parse::<std::net::Ipv4Addr>().ok();
Ok(punktfunk_core::wol::send_magic_packet(&macs, ip).is_ok())
})
.resolve::<LogErrorAndDefault>()
}
@@ -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:
@@ -453,6 +447,10 @@ struct ContentView: View {
LibraryView(store: store, target: shelf, onLaunch: { launchTitle(shelf, $0) })
}
.frame(minWidth: 940, minHeight: 620)
// The stack draws the title, and it sits outside LibraryView's own ink see the tvOS
// cover. Gated, because this sheet is BOTH modes' library on macOS and the touch
// grid's title belongs to the system background.
.gamepadPaletteInk(gamepadUIActive)
}
#else
// iOS: the cover is the TOUCH UI's presentation only. In gamepad mode the library is one
@@ -822,6 +820,7 @@ struct ContentView: View {
onPaired: handlePaired, waker: waker,
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered,
launchTitle: launchTitle,
wakeOnly: { wakeOnly($0) },
promptActive: consolePromptShowing)
} else {
HomeView(
@@ -841,6 +840,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
@@ -851,12 +851,29 @@ struct ContentView: View {
.fullScreenCover(item: $pairingTarget) { host in
PairSheet(host: host) { fingerprint in handlePaired(host, fingerprint: fingerprint) }
.onExitCommand { pairingTarget = nil }
// A tvOS cover draws NO background of its own, and this one is attached
// outside the launcher's `gamepadPaletteInk` so the pairing screen used
// to render the system's dark chrome directly over the launcher showing
// through it, which under a pale palette is white text on a bright field
// (the PIN prompt was all but invisible). Give it the console's own field
// and the palette's ink, like every other screen the launcher opens. Only
// this branch: `HomeView`'s route to the same sheet is the TOUCH UI, which
// sits on the system background and has no palette.
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background { GamepadFormBackground() }
.gamepadPaletteInk()
}
.fullScreenCover(item: $libraryTarget) { shelf in
NavigationStack {
LibraryView(store: store, target: shelf, onLaunch: { launchTitle(shelf, $0) })
}
.onExitCommand { libraryTarget = nil }
// On the STACK, not just inside LibraryView: the navigation title is drawn by
// the stack, which wraps that view from outside its own `gamepadPaletteInk`
// so the shelf's name stayed white over a pale field while the content below
// it had already gone dark. Unconditional here because this cover only exists
// in the launcher's branch, where the console UI is by definition drawing.
.gamepadPaletteInk()
}
#endif
} else {
@@ -973,9 +990,15 @@ struct ContentView: View {
model?.disconnect() // the captured-state D combo
},
onFrame: { [meter = model.meter, latency = model.latency,
split = model.latencySplit, queue = model.clientQueue,
offset = conn.clockOffsetNs] au in
split = model.latencySplit, queue = model.clientQueue] au in
meter.note(byteCount: au.data.count)
// Read the offset PER AU (an atomic load), never in the capture list: a
// capture-list `offset =` froze the connect-time estimate for the whole
// session, and on a host whose wall clock steps (VM + NTP) that frozen
// value shifted hostnet/e2e by ~15 ms between sessions while the meter's
// impossible-sample guard hid the damage (field 2026-08-13). See
// `PunktfunkConnection.clockOffsetNs`.
let offset = conn.clockOffsetNs
latency.record(ptsNs: au.ptsNs, offsetNs: offset)
// The same receipt, keyed by pts, awaiting its 0xCF host timing (the
// host/network split drained by the 1 s stats tick). receivedNs is
@@ -1048,31 +1071,15 @@ 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)
@@ -1139,23 +1146,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
@@ -1273,6 +1267,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).
@@ -1331,6 +1328,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)
@@ -12,7 +12,10 @@ import SwiftUI
#if os(iOS) || os(macOS) || os(tvOS)
struct GamepadAddHostView: View {
@Environment(\.gamepadInk) private var ink
/// 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(\.dismiss) private var dismiss
@@ -25,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.
@@ -57,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)
@@ -98,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.
@@ -202,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),
]
}
@@ -261,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)
}
@@ -430,7 +430,12 @@ private struct HintCellStyle: ButtonStyle {
/// can't inflate the caller's layout past the safe area (see the layout note in GamepadHomeView's
/// header). Honors Reduce Motion by freezing the field at a fixed phase.
struct GamepadScreenBackground: View {
@Environment(\.gamepadInk) private var ink
/// Resolved from `paletteID` below rather than `\.gamepadInk`: this is mounted as a screen's
/// `.background { }`, which the screen attaches BEFORE its own `gamepadPaletteInk()`, so the
/// environment here is the screen's parent's the dark default under a cover or a sheet. It
/// only feeds a pale palette's scrim, so the symptom was subtle: the field bleached toward
/// white instead of settling onto its own ink. (see `GamepadInk.stored`)
private var ink: GamepadInk { .stored(paletteID) }
/// How far toward the form screens' quiet the field sits: 0 = the launcher's full aurora,
/// 1 = calm, fractional mid-chase. Continuous (not a Bool) so the in-place shell can CHASE
/// it during a push/pop the console does the same with its `bg_mix` and every
@@ -64,7 +64,10 @@ private struct HomeTile: Identifiable {
}
struct GamepadHomeView: View {
@Environment(\.gamepadInk) private var ink
/// 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) }
/// Published by ContentView at the app ROOT, so this reads its own window's tier this screen
/// applies `gamepadPaletteInk` itself and so sits above its own copy of the environment.
@Environment(\.gamepadMetrics) private var metrics
@@ -91,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
@@ -119,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
@@ -201,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
}
@@ -261,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) }
@@ -277,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,
@@ -414,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)
@@ -469,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 }))
@@ -543,6 +591,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 })
@@ -657,6 +759,12 @@ private struct GamepadHostTile: View {
private var monogramBadge: some View {
let shape = RoundedRectangle(cornerRadius: Self.badgeCorner, style: .continuous)
// What the glyph is drawn ON: a filled badge IS the accent, so its mark takes `onAccent`
// the colour picked by the accent's own luminance exactly as the settings screen's
// selected tab pill does. It used to take `fg`, which is chosen against the FIELD, and the
// two disagree at both ends of the set: a pale palette put near-black on a deep accent, and
// Graphite (accent luma 0.80) put white on a light grey.
let glyph = tile.filled ? ink.onAccent : ink.accent
return ZStack {
shape.fill(tile.filled
? AnyShapeStyle(LinearGradient(
@@ -664,7 +772,7 @@ private struct GamepadHostTile: View {
startPoint: .top, endPoint: .bottom))
: AnyShapeStyle(ink.accent(0.16)))
if tile.isConnecting {
ProgressView().tint(ink.fg)
ProgressView().tint(glyph)
} else if let icon = tile.icon {
Image(systemName: icon)
.font(.system(size: Self.iconFont, weight: .semibold))
@@ -676,12 +784,12 @@ private struct GamepadHostTile: View {
.resizable()
.scaledToFit()
.frame(width: Self.monogramFont, height: Self.monogramFont)
.foregroundStyle(tile.filled ? ink.fg : ink.accent)
.foregroundStyle(glyph)
.accessibilityLabel(tile.osChain ?? "")
} else {
Text(monogram(tile.title))
.font(.geistFixed(Self.monogramFont, .bold))
.foregroundStyle(tile.filled ? ink.fg : ink.accent)
.foregroundStyle(glyph)
}
}
.frame(width: Self.badgeSide, height: Self.badgeSide)
@@ -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
@@ -67,9 +67,32 @@ struct GamepadInk: Equatable, Sendable {
/// The shipped dark look what a preview or a test composition gets.
static let dark = GamepadInk.of(GamepadPalette.named("violet"))
/// The ink for a stored `ui_palette` id, resolved WITHOUT the environment.
///
/// For the screens that publish their own ink with `gamepadPaletteInk()`. A view's
/// `@Environment` resolves against its PARENT the modifier a screen applies to its own body
/// covers its descendants, never the body's own `ink.` references so such a screen reads
/// whatever was published ABOVE it. Nested inside another gamepad screen (the iOS shell's
/// layers) that happens to be right; presented as a cover or a sheet (tvOS, macOS) there is
/// nothing above it and it gets the bare dark default. That is precisely how a pale palette
/// came out with a WHITE title, white row labels and a violet focus wash on an Apple TV, while
/// the child views in the same screen the hint bar, the host tiles, the glass were
/// correctly dark-on-pale.
///
/// Declare it beside an `@AppStorage(DefaultsKey.uiPalette)`, which is what re-renders the
/// screen when the setting changes (`GamepadInkModifier` reads the same key).
static func stored(_ paletteID: String) -> GamepadInk {
.of(GamepadPalette.named(paletteID))
}
/// 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 {
@@ -10,7 +10,10 @@ import SwiftUI
#if os(iOS)
struct GamepadLibraryScreen: View {
@Environment(\.gamepadInk) private var ink
/// 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) }
@ObservedObject var store: HostStore
let target: LibraryTarget
let onLaunch: (String) -> Void
@@ -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
}
}
@@ -19,9 +19,12 @@ import SwiftUI
import GameController
struct LibraryCoverflowView: View {
@Environment(\.gamepadInk) private var ink
/// 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) }
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
@@ -94,6 +94,9 @@ struct LibraryView: View {
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled,
mode: gamepadUIMode)
}
/// True when the iOS shell already draws one persistent field behind its layers mounting a
/// second would double the mesh (the same rule the coverflow and the settings screen follow).
@Environment(\.gamepadHostedInShell) private var hostedInShell
#endif
var body: some View {
@@ -150,12 +153,13 @@ struct LibraryView: View {
@ViewBuilder private var content: some View {
if loading && games.isEmpty {
ProgressView("Loading library…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
consoleField(
ProgressView("Loading library…")
.frame(maxWidth: .infinity, maxHeight: .infinity))
} else if let errorText, games.isEmpty {
errorState(errorText)
consoleField(errorState(errorText))
} else if games.isEmpty {
emptyState
consoleField(emptyState)
} else {
if gamepadUIActive {
LibraryCoverflowView(
@@ -168,6 +172,24 @@ struct LibraryView: View {
}
}
/// The console field behind the three states that are NOT the coverflow loading, error,
/// empty. The coverflow mounts its own backdrop; these mounted nothing, so wherever this view
/// is a COVER over the launcher (tvOS, macOS) they drew straight onto it: the spinner and its
/// label sat on the launcher's own aurora with the host tiles still showing through. The same
/// field as the coverflow's (not the calmed form one), so nothing shifts under the content when
/// the titles land and the coverflow takes over.
///
/// Only in gamepad mode: the plain grid's states belong on the system background, as before.
@ViewBuilder private func consoleField(_ view: some View) -> some View {
#if os(iOS) || os(macOS) || os(tvOS)
view.background {
if gamepadUIActive, !hostedInShell { GamepadScreenBackground() }
}
#else
view
#endif
}
private var grid: some View {
// Design D4: launcher entries get their own section above the titles, never interleaved.
// Both headers appear only when both groups exist, so a library without launcher entries
@@ -387,7 +409,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
@@ -20,6 +20,18 @@ import SwiftUI
/// instrument: any visible overlay forces the metal layer through the compositor, which costs a
/// refresh period on the vsync-latched platforms this is how to measure with it off.
private let statsLog = Logger(subsystem: "io.unom.punktfunk", category: "stats")
/// Mirror the 1 Hz vitals line to STDOUT as well as the unified log.
///
/// Exists for **tvOS, where the unified log is unreachable**: `log stream --device` is gone from
/// modern macOS, `log collect --device-name` needs root and then fails "Device not configured"
/// (an Apple TV has no USB to fall back to), and libimobiledevice pairs against a different
/// database than Xcode. Stdout, however, IS bridged `xcrun devicectl device process launch
/// --console -e '{"PUNKTFUNK_STATS_STDOUT":"1"}' io.unom.punktfunk` streams these lines straight
/// to the Mac. That is the only way to read a session's numbers with the **stats overlay OFF**,
/// which matters because the overlay is itself a composited layer over the Metal one i.e. a
/// plausible cause of the very present-floor inflation the overlay is used to measure.
/// Env-gated: no cost, and no stdout noise, unless someone is deliberately measuring.
private let statsToStdout = ProcessInfo.processInfo.environment["PUNKTFUNK_STATS_STDOUT"] == "1"
/// Pump-thread-side frame counters; a 1 Hz main-actor timer drains them into @Published
/// values. NSLock instead of an actor the writer is the (non-async) pump thread.
@@ -137,6 +149,25 @@ final class SessionModel: ObservableObject {
/// and under stage-1.
@Published var osFloorP50Ms = 0.0
@Published var osFloorValid = false
/// The deadline link's `preferredFrameLatency` ASK beside its property READBACK (see
/// `PresentLinkInfo` it exists because tvOS has no reachable log). The readback is NOT
/// a grant: it is a plain float property, so it echoes whatever was stored unless the
/// system clamps the setter. readback ask a visible clamp (the one signal the API can
/// give); readback == ask proves nothing `osFloorP50Ms` (the measured vend lead) is the
/// truth-teller (field 2026-08-13: readback 1.00 beside a 32.5 ms floor).
@Published var linkLatencyAskFrames: Float = 0
@Published var linkLatencyFrames: Float = 0
@Published var linkRangeMinHz: Float = 0
@Published var linkRangeMaxHz: Float = 0
@Published var linkDrawables = 0
@Published var linkInfoValid = false
/// Impossible samples the HOST-ANCHORED meters (host+network, end-to-end) refused this
/// second (`LatencyMeter.drainTrimmed`). Nonzero means the clock offset is lying and every
/// host-anchored p50/p95 this window is a TRUNCATED distribution the HUD marks the window
/// suspect instead of letting a trimmed tail pose as a healthy small number (the field
/// "e2e 03 ms" reading, 2026-08-13). Client-local stages can't go negative, so they carry
/// no such term.
@Published var skewTrimPerS = 0
/// The AUDIO plane's latency, from the playback ring (`SessionAudio.Stats`): how much decoded
/// audio is queued ahead of the speaker, and where that PUTS it relative to the picture
/// (positive = audio behind). `audioValid` is false until playback runs.
@@ -683,6 +714,10 @@ final class SessionModel: ObservableObject {
displayValid = false
clientQueueValid = false
osFloorValid = false
linkInfoValid = false
// Drop the previous session's grant too the shared box outlives the session, and a new
// link may never come up (a non-deadline rung has none at all).
PresentLinkInfo.shared.clear()
audioValid = false
lostFrames = 0
lostPct = 0
@@ -904,6 +939,10 @@ final class SessionModel: ObservableObject {
} else {
self.endToEndValid = false
}
// Drained even when the stats drains came back empty with a badly wrong offset
// an entire window is refused and only this counter still tells the story.
self.skewTrimPerS =
self.latency.drainTrimmed() + self.endToEnd.drainTrimmed()
if let d = self.decodeStage.drain() {
self.decodeP50Ms = d.p50Ms
self.decodeValid = true
@@ -923,6 +962,18 @@ final class SessionModel: ObservableObject {
} else {
self.osFloorValid = false
}
// The display link's latency ask + property readback (deadline rung only) a
// LEVEL, not a window, so it is read rather than drained.
if let l = PresentLinkInfo.shared.snapshot() {
self.linkLatencyAskFrames = l.ask
self.linkLatencyFrames = l.latency
self.linkRangeMinHz = l.rangeMin
self.linkRangeMaxHz = l.rangeMax
self.linkDrawables = l.drawables
self.linkInfoValid = true
} else {
self.linkInfoValid = false
}
if let q = self.clientQueue.drain() {
self.clientQueueP50Ms = q.p50Ms
self.clientQueueValid = true
@@ -951,6 +1002,14 @@ final class SessionModel: ObservableObject {
// Swift Int is 64-bit %lld, NOT %d (which is a 32-bit C int); macOS 26's
// strict String(format:) validator rejects the %d/Int mismatch and drops
// the whole line (a cascade error that also mis-blames the float args).
//
// Every invalid-field fallback below MUST be a typed `-1.0` (or a
// `Double(...)`-wrapped value), never a bare `-1`: in this variadic
// `CVarArg` context the ternary does NOT unify to Double the untyped
// literal goes in as Int, and `%f` then reads Int64(-1)'s all-ones bit
// pattern, which IS a quiet NaN. Field 2026-08-13 (tvOS, stage-1, the
// first session ever to have invalid fields while frames flowed): every
// fallback printed `nan`. Latent since the line was added.
format: "fps=%lld presents=%lld e2e_p50=%.1f e2e_p95=%.1f hostnet_p50=%.1f "
+ "decode_p50=%.1f display_p50=%.1f lost=%lld "
+ "floor_p50=%.1f display_adj=%.1f e2e_adj=%.1f queue_p50=%.1f "
@@ -958,22 +1017,35 @@ final class SessionModel: ObservableObject {
// In the log as well as on the HUD because the overlay is only up when
// someone thought to turn it on, and the reports that need these
// numbers arrive after the fact.
+ "audio_buffer=%lld audio_av_offset=%lld",
+ "audio_buffer=%lld audio_av_offset=%lld "
// The deadline link's latency ask + property readback (both -1 on
// non-deadline rungs) appended so the PUNKTFUNK_FRAME_LATENCY
// ladder is readable over the stdout channel with the HUD off,
// which is the only honest way to run it on a tvOS device.
+ "link_ask=%.2f link_readback=%.2f "
// Impossible samples the host-anchored meters refused this window:
// nonzero the clock offset is lying and e2e/hostnet above are
// truncated distributions disregard their p50/p95.
+ "skew_trim=%lld",
frames,
displayWindow?.count ?? 0,
self.endToEndValid ? self.endToEndP50Ms : -1,
self.endToEndValid ? self.endToEndP95Ms : -1,
self.hostNetworkValid ? self.hostNetworkP50Ms : -1,
self.decodeValid ? self.decodeP50Ms : -1,
self.displayValid ? self.displayP50Ms : -1,
self.endToEndValid ? self.endToEndP50Ms : -1.0,
self.endToEndValid ? self.endToEndP95Ms : -1.0,
self.hostNetworkValid ? self.hostNetworkP50Ms : -1.0,
self.decodeValid ? self.decodeP50Ms : -1.0,
self.displayValid ? self.displayP50Ms : -1.0,
lost,
self.osFloorValid ? self.osFloorP50Ms : -1,
self.displayValid ? self.displayAdjP50Ms : -1,
self.endToEndValid ? self.endToEndAdjP50Ms : -1,
self.clientQueueValid ? self.clientQueueP50Ms : -1,
self.osFloorValid ? self.osFloorP50Ms : -1.0,
self.displayValid ? self.displayAdjP50Ms : -1.0,
self.endToEndValid ? self.endToEndAdjP50Ms : -1.0,
self.clientQueueValid ? self.clientQueueP50Ms : -1.0,
self.audioValid ? self.audioBufferMs : -1,
self.audioValid ? self.audioAvOffsetMs : 0)
self.audioValid ? self.audioAvOffsetMs : 0,
self.linkInfoValid ? Double(self.linkLatencyAskFrames) : -1.0,
self.linkInfoValid ? Double(self.linkLatencyFrames) : -1.0,
self.skewTrimPerS)
statsLog.info("\(line, privacy: .public)")
if statsToStdout { print("pf.stats \(line)") }
}
}
}
@@ -128,6 +128,29 @@ struct StreamHUDView: View {
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.tertiary)
}
// The deadline link's frame-latency ASK beside its property READBACK. The
// readback is NOT a grant the property echoes whatever we stored (field
// 2026-08-13: 1.00 beside a 32.5 ms `os present` floor). The line earns its
// place because a readback that DIFFERS from the ask is the one clamp signal
// the API can give, and on tvOS the screen is the only place to read either
// (no log is reachable on an Apple TV; see PresentLinkInfo).
if model.linkInfoValid {
Text("link latency ask \(model.linkLatencyAskFrames, specifier: "%.2f") readback \(model.linkLatencyFrames, specifier: "%.2f") · range \(model.linkRangeMinHz, specifier: "%.0f")-\(model.linkRangeMaxHz, specifier: "%.0f") Hz · drawables \(model.linkDrawables)")
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.tertiary)
}
// The clock-offset tripwire: host-anchored meters refused samples as
// impossible ( 0 after offset correction) this second. When this shows,
// e2e and host+network above are TRUNCATED distributions a wrong skew
// offset shifted them and the impossible half was trimmed so their
// p50/p95 flatter the stream (the field "e2e 03 ms" reading). Orange on
// purpose: every other stat here stays legible-quiet, but a number that
// has stopped meaning anything must not.
if model.skewTrimPerS > 0 {
Text("clock offset suspect — \(model.skewTrimPerS)/s impossible samples trimmed; e2e & host+network unreliable")
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.orange)
}
// Client-queue wait (reassembly receipt decode pull, ABI v9 split): ~0 on
// a healthy stream and hidden as noise; shown from 2 ms a persistent value
// is a client-side standing backlog that pre-split builds displayed as
@@ -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,14 +42,22 @@ 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 {
@Environment(\.gamepadInk) private var ink
/// Resolved from `paletteID` below, NOT from `\.gamepadInk` this screen publishes that value
/// itself and so sits above its own copy (see `GamepadInk.stored`). Reading the environment
/// here is what left the title, the tab pills and every row label white-on-pale on tvOS.
private var ink: GamepadInk { .stored(paletteID) }
@Environment(\.gamepadMetrics) private var metrics
@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).
@@ -61,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
@@ -132,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(
@@ -157,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))
@@ -326,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`).
@@ -383,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()
}
@@ -390,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.
@@ -502,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
@@ -527,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).

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