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
169 changed files with 8810 additions and 1053 deletions
+2 -2
View File
@@ -6,7 +6,7 @@
# android.yml would mean an `if:` on all ten of its build steps.
#
# What it is for:
# * promote a tested build up a track (alpha -> production)
# * promote a tested build up a track (beta -> production)
# * roll production back by re-pointing it at an older versionCode (to_track=production,
# version_code=<the good one>, from_track blank)
# * halt a rollout (status=halted)
@@ -36,7 +36,7 @@ on:
from_track:
description: 'track to verify it is on, then clear (blank = touch nothing else)'
required: false
default: 'alpha'
default: 'beta'
notes_tag:
description: "tag whose docs/releases/whatsnew/<tag>.txt to attach, e.g. v0.23.0 (blank = none)"
required: false
+44 -13
View File
@@ -36,8 +36,13 @@ on:
- '.gitea/workflows/android.yml'
# Single project version: a `vX.Y.Z` tag is THE release (publishes to Play `production` at
# 100% + attaches the .aab/.apk to the unified Gitea Release). A main push is canary
# (Play `internal`). Production access was granted 2026-08-01; before that a tag could only
# reach `alpha` and someone had to promote it by hand in the Console.
# (Play `beta` = open testing: public opt-in, no tester list — but unlike the previous
# `internal` target, every canary now passes Google review before testers see it, so a
# canary lands in hours/days, not minutes). The same canary versionCode is also assigned
# to `alpha` (closed testing) in the same Play edit, so the pre-production-access closed
# testers keep receiving builds without re-opting-in. Production access was granted
# 2026-08-01; before that a tag could only reach `alpha` and someone had to promote it
# by hand in the Console.
tags: ['v*']
pull_request:
paths:
@@ -51,7 +56,21 @@ on:
- 'rust-toolchain.toml'
- 'scripts/ci/**'
- '.gitea/workflows/android.yml'
# Manual runs are BUILD-ONLY by default. The escape hatch below exists because a push run can
# go missing entirely: merge two PRs seconds apart and Gitea attributes the window's runs to the
# newer head, so the older merge sha gets no run at all — its android change then sits on main
# having never been built, let alone published (2026-08-14: `1e5dca4c`, PR #235, lost its run to
# `b5cace3a` 12 s later). Re-running the PR run does NOT recover it: a re-run replays the original
# `pull_request` event, so every gate below stays false. Only a dispatch with publish=true can
# ship that commit without inventing a filler push.
workflow_dispatch:
inputs:
publish:
# String, not a boolean: matches apple.yml's `testflight` input, which is the form proven
# to evaluate correctly on this Gitea. Compared as `inputs.publish == 'true'` below.
description: "Also publish this build (registry + Google Play). main -> beta+alpha, vX.Y.Z tag -> production at 100%. Default false: a stray click must not reach testers."
required: false
default: "false"
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io, LAN-pinned via ci-core's
# unbound). The NDK clang targets get their own key universes automatically (keys embed
@@ -94,8 +113,9 @@ jobs:
# store listing. Failing here also means a missing file cannot leave a half-published
# release: nothing is built, nothing is attached to the Gitea release, nothing reaches Play.
#
# Canary is exempt on purpose: it has no curated notes, and Play reusing text for internal
# testers costs nothing.
# Canary is exempt on purpose: it has no curated notes. Open-testing users therefore see
# the previous release's text on a canary — cosmetic, and cheaper than gating every main
# push on a notes file.
- name: Play release notes gate (tags only)
if: startsWith(github.ref, 'refs/tags/v')
run: |
@@ -222,15 +242,18 @@ jobs:
# Single source of the version name + the Play track for the release steps below. versionCode
# stays github.run_number (monotonic across both tracks; Play rejects a regressed code).
- name: Version + channel
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
if: >-
(github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish == 'true'))
&& (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
run: |
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of the latest stable tag)
case "$GITHUB_REF" in
refs/tags/v*) VN="${GITHUB_REF_NAME#v}"; TRACK="production" ;;
*) VN="${PF_BASE}-ci${GITHUB_RUN_NUMBER}"; TRACK="internal" ;;
refs/tags/v*) VN="${GITHUB_REF_NAME#v}"; TRACK="production"; ALSO="" ;;
*) VN="${PF_BASE}-ci${GITHUB_RUN_NUMBER}"; TRACK="beta"; ALSO="alpha" ;;
esac
echo "VERSION_NAME=$VN" >> "$GITHUB_ENV"
echo "PLAY_TRACK=$TRACK" >> "$GITHUB_ENV"
echo "PLAY_ALSO_TRACK=$ALSO" >> "$GITHUB_ENV"
# Play's own "What's new" (500-char cap, its own file — the vX.Y.Z.md body is ~34 KB).
# On a tag the gate step above already proved this exists, so the else branch is only
# ever the canary path. See docs/releases/README.md.
@@ -240,10 +263,12 @@ jobs:
else
echo "no Play release notes at $NOTES (canary — Play keeps the previous text)"
fi
echo "android version $VN -> Play track '$TRACK'"
echo "android version $VN -> Play track '$TRACK'${ALSO:+ (+ '$ALSO')}"
- name: Build Release (signed AAB + universal APK)
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
if: >-
(github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish == 'true'))
&& (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
working-directory: clients/android
env:
VERSION_CODE: ${{ github.run_number }} # VERSION_NAME comes from the Version+channel step (GITHUB_ENV)
@@ -278,7 +303,9 @@ jobs:
# main = canary store + `canary/` sideload alias; a `vX.Y.Z` tag = `latest/` alias + attached
# to the unified Gitea Release.
- name: Publish to generic registry + attach to Gitea release
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
if: >-
(github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish == 'true'))
&& (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
env:
REGISTRY: git.unom.io
OWNER: unom
@@ -312,7 +339,8 @@ jobs:
# Direct Publishing-API upload instead of r0adkll/upload-google-play — that action hides the
# real API error behind "Unknown error occurred."; this prints it. stdlib + openssl only (no
# pip), reuses SERVICE_ACCOUNT_JSON (raw JSON or base64), auto-handles changesNotSentForReview.
# Track: canary main -> `internal`; a vX.Y.Z release -> `production` at 100% (`completed`).
# Track: canary main -> `beta` (open testing) + the same versionCode on `alpha` (closed
# testing) in the same Play edit; a vX.Y.Z release -> `production` at 100% (`completed`).
#
# A tag therefore ships to real users with no further click. Two things keep that honest:
# the tag is only pushed once every platform is green, and Play reviews each production
@@ -320,13 +348,16 @@ jobs:
# `--status inProgress --user-fraction 0.2`; to undo a bad one, halt or roll back from the
# Console (or `android-promote.yml`, which can re-point production at an older versionCode).
- name: Upload to Google Play
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
if: >-
(github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish == 'true'))
&& (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
env:
SERVICE_ACCOUNT_JSON: ${{ secrets.SERVICE_ACCOUNT_JSON }}
run: |
echo "uploading to Play track '$PLAY_TRACK'"
echo "uploading to Play track '$PLAY_TRACK'${PLAY_ALSO_TRACK:+ (+ '$PLAY_ALSO_TRACK')}"
set -- --package io.unom.punktfunk \
--aab clients/android/app/build/outputs/bundle/release/app-release.aab \
--track "$PLAY_TRACK" --status completed
if [ -n "${PLAY_ALSO_TRACK:-}" ]; then set -- "$@" --also-track "$PLAY_ALSO_TRACK"; fi
if [ -n "${PLAY_NOTES:-}" ]; then set -- "$@" --release-notes-file "$PLAY_NOTES"; fi
python3 clients/android/ci/play-upload.py "$@"
+21 -10
View File
@@ -676,20 +676,23 @@ jobs:
# Skipped on PRs (cost); runs on main pushes + manual dispatch. Needs the build/test job green
# first, and is a separate job so a capture hiccup can never red the core signal.
#
# Scope = the two REQUIRED iOS sizes (iPhone 6.9" + iPad 13"), captured on the Simulator
# (`simctl io screenshot`, no Screen Recording grant needed). macOS and tvOS are deliberately
# NOT in CI: the self-hosted runner is headless (no window-server session), so the mac window
# capture can't run there; tvOS needs the Tier-3 build-std slice. Generate those two locally on
# a GUI Mac with `clients/apple/tools/screenshots.sh macos tvos`.
# Scope = the two REQUIRED iOS sizes (iPhone 6.9" + iPad 13") + Apple TV (1920×1080), captured
# on the Simulator (`simctl io screenshot`, no Screen Recording grant needed). The tvOS slice is
# Tier-3 (nightly -Zbuild-std, same as the distribute job — slow cold, cached on the self-hosted
# runner). The tvOS scene list is explicit: the gamepad-console scenes are iOS/macOS-only, and an
# unknown scene name falls back to a NORMAL app launch — the capture would silently be of the
# real empty app. macOS stays deliberately NOT in CI: the runner is headless (no window-server
# session), so the mac window capture can't run there — generate it locally on a GUI Mac with
# `clients/apple/tools/screenshots.sh macos`.
screenshots:
needs: swift
if: gitea.event_name != 'pull_request'
runs-on: macos-arm64
timeout-minutes: 75
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- name: Rust toolchain + iOS Simulator targets
- name: Rust toolchain + iOS Simulator targets (+ nightly for the tvOS slices)
run: |
if ! command -v rustup >/dev/null && [ ! -x "$HOME/.cargo/bin/rustup" ]; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
@@ -699,6 +702,10 @@ jobs:
dirname "$RUSTUP" >> "$GITHUB_PATH"
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin \
aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
# tvOS targets are tier-3 (no prebuilt std) — build-xcframework.sh compiles them with
# nightly + -Zbuild-std, so ensure nightly + rust-src are present (see the swift job).
"$RUSTUP" toolchain install nightly --profile minimal
"$RUSTUP" component add rust-src --toolchain nightly
# Shared compile cache. The script handles the macOS side (user-prefix install +
# GITHUB_PATH, bsdtar globbing) — see scripts/ci/ensure-sccache.sh.
@@ -735,10 +742,10 @@ jobs:
-mtime +7 -exec rm -rf {} + 2>/dev/null || true
fi
- name: Build PunktfunkCore.xcframework (mac + iOS slices)
run: BUILD_IOS=1 bash scripts/build-xcframework.sh
- name: Build PunktfunkCore.xcframework (mac + iOS + tvOS slices)
run: BUILD_IOS=1 BUILD_TVOS=1 bash scripts/build-xcframework.sh
- name: Capture screenshots (iPhone 6.9" + iPad 13"; auto-creates the Simulators)
- name: Capture screenshots (iPhone 6.9" + iPad 13" + Apple TV; auto-creates the Simulators)
working-directory: clients/apple
env:
SETTLE: "8" # Simulators settle slower than a local run
@@ -746,6 +753,10 @@ jobs:
# Independent invocations: one platform failing skips it, not the other.
bash tools/screenshots.sh ios || echo "::warning::iOS (iPhone 6.9\") screenshots skipped"
bash tools/screenshots.sh ipad || echo "::warning::iPad 13\" screenshots skipped"
# tvOS shoots only the scenes that exist there — the 0609 gamepad-console scenes are
# compiled out on tvOS (native focus engine), and an unknown name = a normal app launch.
SCENES="01-stream 02-hosts 11-library 05-settings 03-pair" \
bash tools/screenshots.sh tvos || echo "::warning::Apple TV screenshots skipped"
echo "Produced:"; ls -la screenshots || true
- name: Shut the Simulators down (leaked booted sims once piled up 846 deep)
+7 -5
View File
@@ -9,11 +9,13 @@
# login gate, session sealing, mgmt bearer token), sdk (@punktfunk/host),
# plugin-kit (@punktfunk/plugin-kit).
# * pnpm audit → clients/decky (the Steam Deck plugin).
# * docs-site → scanned NON-blocking (continue-on-error): known transitive advisories ride in
# via the CMS/UI chain (@unom/ui → payload → dompurify/monaco) and the nitropack
# build chain (node-tar, brace-expansion); clearing them needs coordinated bumps
# verified against the LIVE site (the docs don't build standalone) — tracked in
# punktfunk-planning design/cra-readiness.md. Flip to blocking once clean.
# * docs-site → scanned NON-blocking (continue-on-error). 2026-08-14: docs-site's own deps
# are current (fumadocs/tanstack/react bumped; build + tsc + serve verified),
# but every remaining advisory is pinned INSIDE @unom/ui 0.9.2's dependency
# tree (@payloadcms/* → fast-uri/image-size/sharp, next 16.x, sass→immutable) —
# nothing bumpable from this lockfile, and overrides would fork what the CMS
# actually ships. The fix belongs in the @unom/ui package repo; flip this to
# blocking after a ui release with a clean payload chain lands here.
# * cargo-about → license-allowlist gate over the host + driver workspaces (about.toml `accepted`);
# fails if any crate carries a license outside the allowlist — the regression
# guard about.toml always promised. (The Android Gradle tree has no lockfile, so
+31 -9
View File
@@ -70,12 +70,18 @@
# latest stable tag via scripts/ci/pf-version.ps1, run number climbs monotonically).
# Both arches share the version; artifacts are arch-suffixed (..._x64.msix / ..._arm64.msix).
#
# Signing (clients/windows/packaging/pack-msix.ps1): if the MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD
# Actions secrets are set (a real or shared code-signing .pfx whose subject DN == Publisher), the
# package is signed with them. Otherwise an ephemeral self-signed cert is generated and its public
# .cer is published next to the .msix (users import it to Trusted People before install).
# Signing (clients/windows/packaging/pack-msix.ps1), first match wins:
# 1. Azure Artifact Signing — what this workflow always takes, since the AZURE_CODESIGNING_*
# endpoint/account/profile are literals below and only the AZURE_TENANT_ID / AZURE_CLIENT_ID /
# AZURE_CLIENT_SECRET secrets are needed. Publicly trusted, so NO .cer is emitted or published
# and users import nothing. NOTE the Publisher DN is the Azure profile's verified subject, and
# MSIX identity is name + publisher: moving to it changed the package identity, so installs
# predating it need an uninstall, not an upgrade.
# 2. MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD — the older self-signed .pfx, kept as a fallback.
# 3. an ephemeral self-signed cert. Modes 2 and 3 DO emit a .cer next to the .msix, which users
# would have to import into Trusted People before Windows will install the package.
#
# That fallback is for canary/CI ONLY. On a v* tag the pack script FAILS CLOSED — a missing secret
# Modes 2 and 3 are for canary/CI ONLY. On a v* tag the pack script FAILS CLOSED — a missing secret
# aborts the build instead of quietly shipping a release signed by a per-build throwaway cert that
# no one can pin. Nothing to opt into here: the script reads GITHUB_REF itself.
name: windows-client
@@ -257,6 +263,19 @@ jobs:
if: github.event_name != 'pull_request'
shell: pwsh
env:
# Azure Artifact Signing (formerly Trusted Signing) — takes precedence over MSIX_CERT_*
# when all three are set. Not secret: an account/profile name and a regional endpoint,
# inert without the credentials below. The profile's verified subject is also the MSIX
# manifest Publisher; pack-msix.ps1 reads the signature back and fails on a mismatch.
AZURE_CODESIGNING_ENDPOINT: https://neu.codesigning.azure.net/
AZURE_CODESIGNING_ACCOUNT: unomsigning
AZURE_CODESIGNING_PROFILE: unom-io
# Service principal 'punktfunk-ci-signing', holding ONLY the Artifact Signing Certificate
# Profile Signer role, scoped to the unom-io profile — it can sign and nothing else.
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
# Legacy self-signed path, kept as the fallback for builds without Azure access.
MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }}
MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }}
run: |
@@ -275,10 +294,13 @@ jobs:
# stable release -> `latest/` alias; canary main build -> `canary/` alias.
$alias = if ($env:GITHUB_REF -like 'refs/tags/v*') { 'latest' } else { 'canary' }
# version-less, arch-suffixed alias names so each channel keeps one predictable URL.
$aliasNames = @{
"$($env:MSIX_PATH)" = "$($env:PKG)_${{ matrix.arch }}.msix"
"$($env:MSIX_CER_PATH)" = "$($env:PKG)_${{ matrix.arch }}.cer"
}
# Under Azure signing there is no .cer, so MSIX_CER_PATH is unset. The quotes below are
# load-bearing: "$($env:UNSET)" interpolates to an empty string (a legal key), whereas a
# BARE $env:UNSET is $null and a null key is a hard error in a hash literal — which is
# exactly how windows-host.yml's publish step broke. Added explicitly rather than relying
# on that accident, so removing the quotes can't silently reintroduce it.
$aliasNames = @{ "$($env:MSIX_PATH)" = "$($env:PKG)_${{ matrix.arch }}.msix" }
if ($env:MSIX_CER_PATH) { $aliasNames[$env:MSIX_CER_PATH] = "$($env:PKG)_${{ matrix.arch }}.cer" }
$files = @($env:MSIX_PATH, $env:MSIX_CER_PATH) | Where-Object { $_ -and (Test-Path $_) }
if (-not $files) { throw "pack produced no artifacts to publish" }
function Put($f, $url) {
+30 -4
View File
@@ -20,12 +20,18 @@
# main push / dispatch -> <next-minor>.<run_number> (canary; `canary/` alias; base one minor
# ahead of the latest stable tag via scripts/ci/pf-version.ps1, run climbs).
#
# Signing reuses the client's MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD secrets (CN=unom). Without them
# an ephemeral self-signed cert is generated and its public .cer published next to the installer
# (import once to LocalMachine\TrustedPublisher). That fallback is for canary/CI ONLY — on a v* tag
# Signing goes through Azure Artifact Signing (account `unomsigning`, profile `unom-io`) — a publicly
# trusted CA, so there is no .cer for users to import and no SmartScreen "unknown publisher" prompt.
# It falls back to the old MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD self-signed cert, and then to an
# ephemeral one, for builds without Azure access. Those fallbacks are for canary/CI ONLY — on a v* tag
# the pack script FAILS CLOSED rather than ship a release signed by a per-build throwaway cert.
# See packaging/windows/pack-host-installer.ps1.
#
# The bundled DRIVERS are NOT signed by Azure — they keep their own DRIVER_CERT_* cert and are still
# trusted by planting that cert in the machine Root store at install time. Independent by design:
# Windows checks the installer's signature via SmartScreen/UAC and driver catalogs via PnP, and never
# requires a common signer. See packaging/windows/README.md for why that root-plant is still there.
#
# GPU backends: the host builds with --features nvenc,amf-qsv,qsv = all three vendors in one installer.
# - NVENC (NVIDIA, direct SDK): nothing needed at build time — the entry points are resolved at
# RUNTIME from the driver's nvEncodeAPI64.dll (a link-time import would kill the binary on
@@ -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
+458 -1
View File
@@ -12,7 +12,82 @@ with the version table of the release you are moving to, then read **Breaking ch
---
## v0.28.1 — in development
## v0.28.1
60 commits since v0.28.0.
A patch release in the strict sense: **nothing on the wire, in the C ABI, in the driver protocol or
in the plugin contract moves.** Every host, client, driver and plugin built against v0.28.0 keeps
working against v0.28.1 and vice versa, in both directions and with no re-pairing.
### Versions
| | v0.28.0 | v0.28.1 | Notes |
|---|---|---|---|
| Wire protocol | 2 | **2** | unchanged |
| C ABI | 19 | **19** | unchanged — `include/punktfunk_core.h` is byte-identical to the v0.28.0 tag |
| Rust edition | 2024 | **2024** | unchanged |
| MSRV (`rust-version`) | 1.85 | **1.85** | unchanged |
| Workspace crate dirs | 27 | **27** | unchanged |
| Virtual-display driver protocol | 6 | **6** | unchanged (minimum accepted still 3) |
| Windows virtual-gamepad channel | 3 | **3** | unchanged |
| Plugin index schema | 1 | **1** | unchanged |
| `api/openapi.json` | 0.27.0 | **0.28.0** | the management API **did** change (two collection deletes, below); the file carries the stamp it was regenerated under, not `0.28.1` |
| gamescope patch level (`+pfhdrN`) | 6 | **7** | 8 patches → 9 (the linger crash); no new capability |
| `@punktfunk/host` (SDK) | 0.1.4 | **0.1.4** | unchanged |
| `@punktfunk/plugin-kit` | 0.4.1 | **0.4.1** | unchanged |
**The `api/openapi.json` stamp is not a per-release counter** and should not be read as one. The
drift test (`openapi_document_is_complete_and_checked_in`) normalizes `info.version` on both sides,
so only the *surface* is gated and a version bump alone never invalidates the snapshot. The table
row says what the file actually says. Regenerating it needs a Linux or Windows host build —
`punktfunk-host` does not compile on macOS.
### ⚠ Breaking changes
**None.** No wire change, no C ABI change, no driver-protocol change, no plugin-contract change.
Three things are worth an embedder's or packager's attention anyway, none of which break a build:
- **The Rust crate gained one public constant.** `punktfunk_core::client::FLUSH_COOLDOWN` was
`pub(crate)`; the host now compares against it rather than against a copy of the number (see the
keyframe-cadence fix below). Addition only.
- **`NativeBridge.nativeStartAudio` takes a third argument** on Android — `isTv`. Detail in the
Android section; this is a JNI signature change, so an out-of-tree caller must pass it.
- **Every Linux packaging channel now ships a second gamescope artifact**, the Vulkan WSI layer,
and a package that carries the compositor without it is *fatal* rather than degraded. If you
repackage `punktfunk-gamescope` downstream, read the gamescope section before rebuilding.
### The management API gains two collection deletes — "unpair all"
Clearing a host's trust store meant one row-level delete per device, each with its own
confirmation. Two new endpoints, one per pairing plane:
```
DELETE /api/v1/clients -> {"unpaired": N}
DELETE /api/v1/native/clients -> {"unpaired": N}
```
They are **not** a loop over the per-fingerprint deletes. Each empties its store in ONE persisted
write, because N deletes would rewrite and atomically rename the store N times and a failure
partway leaves a half-emptied store with nothing saying which half. The two planes are separate
endpoints because they own separate trust stores with separate persistence and separate revocation
duties.
Being collection deletes, they carry the single delete's revocation guarantees across the whole
set: a live session owned by any removed certificate is ended, and on the GameStream side the ENet
control port (UDP 47999) closes, because no pairing is left to hold it open.
**200 with a count, not the single delete's 204/404.** "Unpair everything" is idempotent — an
already-empty store satisfies it — and the count still distinguishes three devices from none.
**Both are admin-token only.** The route-classification gates match on (method, path), so the
roster's plugin-readable `GET` does not carry over to emptying it; both new routes have explicit
rows in the table, like every other pairing-administration route. The native endpoint answers
**503** on a host built without that plane, which is why the console calls only the planes that
actually have a row.
`UnpairAllResult` is the one new schema. `api/openapi.json` is regenerated;
`docs-site/public/openapi.json` is re-synced from it (see **Documentation** at the end).
### The pad-audio "Wireless Controller" speaker hides while no client pad is attached
@@ -66,6 +141,38 @@ Punktfunk only takes it for the duration of a stream. If you *want* apps to reco
while idle, select "Punktfunk Microphone" manually; the host no longer re-asserts it (idle
re-assertion used to stomp a manual choice within one mic-pump reopen).
### The NixOS module started a second host in root's systemd, which stole the ports from the real one
Found on the first real deployment of `packaging/nix/nixos-module.nix` (NixOS 26.05, punktfunk
0.28.0-nix). The host crash-looped forever on one line:
```
ERROR punktfunk_host: start RTSP server: bind RTSP 48010: Address already in use (os error 98)
```
`systemd.user.*` has no per-user form in NixOS: it installs units into **every** user's systemd
manager. `host.autoStart` then adds them to `default.target` — for every user, including **root**,
whose `user@0.service` springs into existence the moment anybody so much as SSHes in as root. Root's
copy of the host won the race for the fixed ports, and the desktop user's copy could never bind.
The failure is nastier than it sounds because every *other* listener binds first and logs success —
the version banner, mDNS on 47989, the GameStream warning all print normally — so the log reads like
a conflict with some unrelated program. A second copy of *itself*, running as root, is the last
thing anyone looks for. `host.users` did not help: that option only granted `input`/`punktfunk`
group membership and never scoped the units.
Fixed by rendering `ConditionUser=` on all four user units (`punktfunk-host`, `punktfunk-web`,
`punktfunk-web-init`, `punktfunk-scripting`) from `host.users`. Each entry is written `|user` — the
pipe makes it a *triggering* condition, which systemd ORs; plain repeated `ConditionUser=` lines are
ANDed and would have matched nobody. With `host.users` empty the units fall back to
`ConditionUser=!@system`, which still keeps root out while leaving a normal login free to run the
host by hand, as the module header documents.
`packaging/nix/module-check.nix` gained three assertions covering both branches and the fact that
`punktfunk-web-init` keeps its pre-existing (non-triggering) `ConditionPathExists` alongside the new
condition. They run in the `eval` leg of `nix.yml`, and were verified to fail against the unfixed
module before being committed.
### The Steam plugin synced nothing on Windows: its art is in Program Files, the art roots were not
Field report — the plugin installed, the grid stayed empty, and the only clue was one host warn per
@@ -110,6 +217,39 @@ silence would be the wrong answer.
expect art, the cue is the host log's `dropped local art the proxy may not serve` line, and the knob
is `PUNKTFUNK_LIBRARY_ART_ROOTS` (which **replaces** the defaults — list every root you need).
### Hyprland/Sway — the wlr-family backends asserted a cursor mode instead of negotiating it
🛑 **Every cursor-forward session on current Hyprland died at `select_sources`** — "pipeline build
failed" and a black client, with `unavailable cursor mode 4` in the portal log.
Hyprland and wlroots both hardcoded portal `CursorMode::Metadata` whenever the session had
negotiated the cursor channel, and never asked the backend what it supports. That is **not** a soft
failure: xdg-desktop-portal's **frontend** validates the requested mode against the backend's
`AvailableCursorModes` and fails the call with `"Unavailable cursor mode %x"` before the backend
ever sees it.
**Measured on glass 2026-08-14, and worse than the report suggested.** Against a live Hyprland
0.56.2 with xdg-desktop-portal-hyprland 1.4.1 and xdg-desktop-portal 1.22.1 — all current —
`AvailableCursorModes` reads **3** (`Hidden|Embedded`) on both the backend impl interface and the
frontend. **xdph does not offer the metadata cursor at all**, so this broke every cursor-forward
session on current Hyprland, not merely on old installs, and **updating the portal would not have
helped.** xdpw is the same from the other end: its `screencast.c` refuses `METADATA` outright.
`pf-capture`'s own portal path has always negotiated (`choose_cursor_mode`); this restates that
ladder in `pf-vdisplay`, which may not depend on `pf-capture`. The downgrade is graceful rather than
merely survivable: with the portal on `Embedded` no `SPA_META_Cursor` arrives, so the host feeds the
cursor channel nothing and a cursor-forward client draws nothing of its own — **one pointer, not
two.**
**`PUNKTFUNK_PORTAL_CURSOR_MODE=auto|hidden|embedded|metadata`** pins the preference for a backend
that advertises a mode it implements badly, which negotiation cannot detect. It is a preference
only: a pin runs the same ladder, so no value can re-create the refused request.
⚠ The module is declared **unconditionally**, so its ladder tests run on every CI leg rather than
only the one that compiles `mod hyprland` — including a Linux-only test pinning our bit values
against ashpd's enum (ashpd answers 4 for `Metadata`, the number in the report), verified
non-vacuous by planting a wrong discriminant.
### Android — the audio plane trusted AAudio, and a TV box that opened a stream it never played was silent for the session
🛑 **Reported from the field: no audio at all on an NVIDIA Shield Android TV, stereo, with the same
@@ -160,6 +300,84 @@ existing `debug.punktfunk.no_av_sync`: `debug.punktfunk.audio_sharing` (`exclusi
old give-up-on-disconnect behaviour). A stream that stops taking samples after it started now says
so at `error` level instead of looking exactly like an app with no sound.
### gamescope — we ship our own Vulkan WSI layer, so a game can reach an HDR10 swapchain (⚠ packager-visible)
🛑 **On essentially every box running a distro gamescope, no game could render HDR at all** — and
nothing said so.
A game nested under gamescope gets an HDR10 swapchain from the FROG WSI layer and from nothing
else: gamescope advertises no runtime colour-management protocol a Mesa/NVIDIA WSI could negotiate
through. That layer speaks `gamescope_swapchain` to the compositor, and when the two disagree the
compositor rejects the client's `swapchain_feedback` and **every Vulkan client dies on a black
screen** with sound and input intact and no error anywhere.
We shipped our own compositor and *not* a layer, on the recorded grounds that the layer is
"version-independent of the compositor binary". It is not — `wsi_layer_matches_our_gamescope()`
exists precisely because it is not — so the host was left guessing from version triples, and that
guess is wrong in both directions. A distro at the same upstream tag that patched the protocol
compares EQUAL and keeps a layer that will black-screen every game; a distro at a different tag
with a byte-identical protocol compares unequal and loses HDR for nothing. **Since we pin a rev,
the second case is the normal one.**
We now build the layer from the same tree at the same rev as the compositor and ship it, so the two
cannot drift and the guess stops being load-bearing. It installs under **our own** name
(`VK_LAYER_PUNKTFUNK_gamescope_wsi`), at our own path, with our own enable/disable variables, so it
coexists with the distro's rather than colliding — the Vulkan loader keys implicit layers on that
name — and the host switches the two independently within one session.
`WsiPlan` resolves three states once per launch (the fallback spawns `--version` probes):
| state | condition | action |
|---|---|---|
| `Ours` | our layer is installed | enable ours, force the distro's off — **both halves, or it is a bug** |
| `DistroKept` | no layer of ours, distro's looks compatible | touch nothing |
| `DistroDisabled` | no layer of ours, distro's untrusted | v0.28.0's behaviour |
That last arm is the fail-safe: a host newer than its gamescope package behaves exactly as it did,
rather than enabling a layer that is not there.
**What packagers must know.** The layer manifest carries an **absolute** `library_path` baked in
at build time, so every channel installs the `.so` at exactly that path: literal
`/usr/lib/punktfunk`**not** `%{_libdir}` (which is `/usr/lib64` on Fedora) and not a Debian
multiarch triplet. Nothing links it by soname (the loader `dlopen`s it by that path), so multilib
has no claim. rpm and nix read the path back **out of the manifest** and fail if it names a file the
package does not install, because a manifest pointing at nothing is the silent shape of this bug.
A missing layer is **fatal in every channel**, not best-effort: a package carrying the compositor
without it looks completely healthy and then silently denies every game an HDR10 swapchain.
The packaging scripts now take `--stage` (the DESTDIR the gamescope build script wrote) instead of
a path to one binary, and CI caches the whole staged tree; the `gs-cache` key already hashes
`packaging/gamescope/**`, so stale caches in the old single-file shape cannot be restored into the
new layout. The manifest rewrite lives in `packaging/gamescope/rewrite-wsi-layer-manifest.py`
rather than a heredoc, because the FHS builds and the Nix store both need it and must rename the
layer identically. **NixOS has no `/usr`**, so the layer lives inside the gamescope derivation and
the host's path is overridable with **`PUNKTFUNK_GAMESCOPE_WSI_LAYER_DIR`**, which the module sets
— the same posture as `PUNKTFUNK_GAMESCOPE_BIN`.
### gamescope — HDR sessions anchored SDR white a stop bright, and never said game HDR was unreachable
🛑 **Field report: Steam's Big Picture UI glaring and over-saturated while HDR game content looked
washed out, on the same stream.** Those are one error.
gamescope maps everything that is not an HDR game — the desktop, the Steam overlay, an SDR title —
into the session's PQ container at `--hdr-sdr-content-nits`, and we passed that flag **only** when
an operator had set `PUNKTFUNK_GAMESCOPE_SDR_NITS`. Unset, gamescope used its own default of
**400**, while every first-party client anchors diffuse white at **203** (BT.2408 reference white;
the Apple presenter hands exactly that to `CAEDRMetadata.hdr10`'s `opticalOutputScale`). The two
ends sat nearly a stop apart, so the UI landed above SDR white and the client's tone-mapper worked
from a reference point the host had never used, flattening the content around it.
**The flag is now always passed, defaulting to 203.** `PUNKTFUNK_GAMESCOPE_SDR_NITS` still
overrides it for anyone who wants a brighter or dimmer desktop — it is the anchor, not a taste
knob. ⭐ Because it is an env var, a field A/B needs **no rebuild**.
Separately, and visible in the same log: the two HDR decisions in a gamescope session were made
independently. `hdr_args()` never consulted `wsi_layer_matches_our_gamescope()`, so when the layer
check fired the session launched **advertising HDR while having made an HDR10 swapchain
unreachable for every game in it** — a title told to render HDR rendered it into an SDR swapchain
and looked washed out, with nothing anywhere saying why. It now warns. The behaviour of the check
itself is deliberately unchanged; the section above is the real fix.
### punktfunk-gamescope `+pfhdr7` — a lingered session no longer dies of its own capture teardown
🛑 **On client disconnect the host keeps the headless gamescope alive so a reconnect resumes the
@@ -179,6 +397,200 @@ four coredumps on 4K60 HDR + composited cursor, zero after; disconnect/reconnect
lingered session. Banner `+pfhdr6``+pfhdr7` (no new capability — but "reconnect lost my game"
triage must be able to read a box's exposure off its banner, the same rule as `+pfhdr5`/`6`).
### Apple — the stats overlay lied three ways, and every host-anchored number with it
🛑 **Two sessions minutes apart on the same wire read `hostnet_p50` 1721 ms, then a physically
impossible 4.4 ms** — host-side encode alone is ~4.7. Three independent defects, all of which
corrupt any measurement taken against a host clock:
- **A frozen clock-offset.** The client consumed the **connect-time** skew offset and cached it —
in a `Stage2Pipeline` field, in a `StreamPump` `let`, and in a `ContentView` closure **capture
list** feeding the hostnet meter and the host/network splitter. The core keeps a *live* estimate
(`punktfunk_connection_clock_offset_now_ns`, ABI v10, re-synced every 60 s and on suspected
wall-clock steps) whose own doc says the connect-time value "silently corrupts every
capture-clock comparison" after an NTP step — **and a VM host steps.**
`PunktfunkConnection.clockOffsetNs` is now the live read (an atomic load behind the FFI), read at
use: per record, per AU, per enqueue. The Swift audio plane's AvSync observation takes the same
live value.
- **Silently trimmed impossible samples.** `LatencyMeter`'s guard (≤ 0 after offset correction)
dropped samples without counting them, so a wrong offset did not invalidate a window — it trimmed
the impossible half of the shifted distribution and presented the surviving tail as a plausible
small number. That is the origin of the historical "0 ms network / 0 ms e2e" readings. Refusals
are now counted and drained **separately from `Stats`** — deliberately, because a fully-poisoned
window drains to `nil` and a count inside `Stats` would vanish with it. The HUD shows an orange
**`clock offset suspect`** line and the stats line grew **`skew_trim=N`**; nonzero means
disregard `e2e`/`hostnet` for that window.
- **`-1` fallbacks printing as `NaN`.** In a `CVarArg` context `cond ? someDouble : -1` does **not**
unify to `Double` — the literal goes in as `Int`, and `%f` reads `Int64(-1)`'s all-ones bit
pattern, which is a quiet NaN. Latent since the 1 Hz stats line existed. All fallbacks are now
typed `-1.0`.
**Any client-side e2e or hostnet figure recorded before this release is suspect** and worth
re-measuring rather than trusted as a baseline.
Two new levers ship with the tvOS present-floor investigation, both env-only:
**`PUNKTFUNK_FRAME_LATENCY`** (float 0…4, default 1) makes the `preferredFrameLatency` ask
adjustable, so an on-device ladder can establish whether the property does anything on tvOS — the
previous "immovable two-refresh floor" verdict rested on a **readback** of a plain read-write
float, which is not a grant. **`PUNKTFUNK_PRESENTER=stage1` now resolves on Release builds** (the
persisted picker stays DEBUG-gated; an env var takes a `devicectl`/Xcode launch to exist, so it is
never a leftover). Stage-1 presents on the hardware video plane rather than through the GPU
compositor — the one rung that can dodge the two-refresh regime — and the field A/B that concluded
otherwise had silently run stage-4, because the gate keyed on build config.
### Apple — two colour faults: an SDR stream shipped untagged, and it forced the TV into HDR10
- **The SDR layer was never tagged.** `configure(hdr:)` guards on `hdr != hdrActive` and
`hdrActive` starts `false`, so a session that is SDR from its first frame matched the initial
state, fell through the guard, and `configureColor` never ran once — the layer kept `make()`'s
bare configuration, which assigns no colour space. An untagged `CAMetalLayer` gets no colour
matching: a BT.709 stream is drawn in the display's native space. Mild oversaturation on a P3 Mac
or iPad; on a tvOS display composited for HDR it also lifts the black floor. ⚠ It also made
`PUNKTFUNK_SDR_COLORSPACE` **dead code on exactly the sessions it exists to fix**, so a field A/B
of that knob would have shown no change.
- **An SDR stream drove an HDR-capable TV into PQ output.** `applyDisplayCriteriaIfNeeded` builds a
synthetic format description hardcoding BT.2020 primaries, ST.2084 and the BT.2020 matrix, then
hands it to `AVDisplayManager` — and its guard checked only that no criteria had been set and that
the user's HDR *setting* was on, never that **the stream** was HDR. That setting defaults to true.
The Apple TV switches HDMI to limited range in its HDR modes, so a set configured for full range
renders code 16 as grey rather than black. Now gated on `connection.isHDR` as well; layout re-runs
it, so a session that flips to HDR mid-stream still picks the mode up.
### Apple — the macOS device-change recovery could answer itself forever (mic on)
**Streaming from a Mac with the microphone enabled cut audio AND input on a ~2.5 s metronome
while video ran untouched** (field, 2026-08-14: a Mac Studio whose default input is a 6-channel
device). The chain: the voice-processing engine cannot start on that mic, every rebuild re-tried
it, and the failed attempt's HAL churn (VPIO builds and tears down an aggregate device) stopped
the healthy fallback engines — which posted the `AVAudioEngineConfigurationChange` that scheduled
the next rebuild. Each ~1.9 s rebuild runs on the main thread, where macOS input capture and
sending live, so input froze on the same beat — and since audio, input and mic share the QUIC
datagram plane while video rides its own socket, the wire signature read as a network fault and
the host's METRONOMIC heuristic pointed at the display stack. Three defenses, layered because no
single one covers every feedback shape:
- **A voice-processing start failure latches per input device** (`CombinedTopologyGate`): a
rebuild goes straight to the split topology instead of re-running a failure that is a property
of the device. A different default input earns exactly one fresh attempt.
- **A configuration change posted by an engine that is RUNNING is the rebuild's own echo, and is
ignored**: an engine stops itself before posting, so a live poster was already restarted.
- **Rebuilds that chain anyway back off exponentially** (`RebuildBackoff`: 0.5 s floor doubling
to a 30 s cap, reset by 10 s of quiet) — an unforeseen loop costs one blip per half-minute
instead of a metronome, and the chaining itself logs a WARN that names the condition.
iOS/tvOS behaviour is untouched (routes are session-managed there; nothing is latched). Until a
client carries this, the field workaround is turning the client microphone off.
**And the engines no longer start on the main thread at all.** An engine start can block on the
audio server for seconds (~1.9 s per attempt in the field case) and macOS captures and sends the
stream's input from the main thread — so even a single legitimate device switch froze input for
the length of the rebuild, loop or no loop. All engine build/start/teardown now runs on a
per-session serial `engineQueue`; the main queue keeps only the trigger bookkeeping (debounce,
backoff, retry ladder), which is cheap by construction. ⚠ Embedder-visible edge:
`SessionAudio.start()` is now asynchronous on macOS too (it always was on iOS/tvOS) — playback is
live shortly after the call, not on return, and `stats` is safe from any thread.
### Apple gamepad UI — a host menu, and About becomes a page
**UP on a saved tile opens Wake / Copy link / Edit… / Forget pairing / Remove.** The desktop and
Android consoles have had this for a while; this is the Apple port, so the three consoles are
learned once. Wiring UP takes the whole vertical axis away from scrolling (down goes inert) — a
horizontal carousel has no vertical travel to spend, and one meaning per direction is what makes
the gesture learnable. **Remove arms on the first press and fires on the second**, disarming if
focus wanders off the row: the touch grid gets a system confirmation dialog, and a thumbstick from
across a room deserves at least as much. Edit reuses `GamepadAddHostView` seeded from the record and
writes a **copy** back through `HostStore.update`, so the fingerprint, MACs, pins and binding the
form never shows survive a rename; it **replaces** the menu rather than stacking on it, keeping the
shell's "depth ≤ 1 by construction" true. A pinned profile card offers only Unpin — it is a
shortcut, not a second host.
**The start-of-stream shortcut banner is retired.** Telling someone the controls for six seconds,
over the stream they just connected to, answers the question at the one moment nobody is asking it
— and it put a composited overlay above the stream to do it. The words are now a catalogue rendered
in an About page you can open, which is also its own section rather than the last row of Interface.
Its remaining fixes: the identity card became a version line under the rows, a zero-radius clip is
still a clip (it cropped the TV's wide icon), and the card ignored the row column.
**Apple console screens read the ink they publish.** A SwiftUI screen cannot read the environment
value it publishes in the same view — so a pale palette stayed white-on-white on Apple TV. Fixed
across every console screen.
### Console UI — Skia sized its function table to the loader, not to what we promised
🛑 **On a Steam Deck the console home died on update**, and in a stream the same failure quietly
cost the stats OSD and capture HUD.
The skia-safe 0.87 → 0.99 move swapped `BackendContext::new` for `new_builder(…, None)` and
recorded the `None` as "byte-for-byte what the removed constructor did". True of the **value**,
false of the **behaviour**: `None` leaves Skia's `fMaxAPIVersion` at its `0` sentinel, and the newer
Skia acts on that sentinel by falling back to **`vkEnumerateInstanceVersion()` — the loader's
ceiling, not ours.** The presenter declares 1.3; a current Mesa answers 1.4 (1.4.321 on SteamOS
3.7, host and inside the flatpak sandbox alike). Skia then validates a 1.4 function table against an
instance that only promised 1.3, `vkGetDeviceProcAddr` returns null for the entry points in
between, and `make_vulkan` hands back `None`. At 0.87 the sentinel was inert because that Skia knew
nothing of Vulkan 1.4 — **which is why this surfaced the moment v0.28.0 landed.**
`run.rs` makes an overlay that cannot init fatal for `--browse`, so the Decky panel's button and the
gamepad-UI library shortcut both failed to open. The presenter now publishes
`SharedDevice::api_version``min(what we declared, what the loader reports)` — and
`SkiaOverlay::init` passes it instead of `None`. ⚠ `pf-presenter`'s `vk` module is
`cfg(any(linux, windows))`, so this was never Deck-specific.
### pf-vkdecode — AV1's "maximum parameters" level is not a level above the ceiling
🛑 **Every AV1 session demoted to D3D11VA** with `stream level (seq_level_idx 31) above the device's
maxLevel (AV1 Std level 23)` — on hardware decoding the stream trivially on the rung it fell
through to.
`seq_level_idx` is a 5-bit field: Annex A defines 0…23 (levels 2.0…7.3), reserves 24…30, and makes
**31 the "maximum parameters" level — the spec's own way of saying the bitstream is not constrained
to a level.** `StdVideoAV1Level` stops at 7.3 = 23, so 31 has no Std code point and the index-coded
comparison that holds across 0…23 says nothing: `31 > 23` is true even of a device that decodes
everything AV1 can name, which is what makes it useless as a capability test. We write no AV1 level
on any host encode path, so whichever sentinel the vendor's encoder defaults to is what the client
must accept. This is the AV1 half of the same defect fixed for H.264/H.265 in v0.28.0, which was
left alone on the premise that no over-declaration had been seen in the field — the reporter's log
from that same day already showed otherwise.
### Client stats — the stage line is a partition again
A field reader added up `host 5.4 · net 0.3 · decode 6.6 · display 1.4` against `e2e 8.1` and asked
why the parts did not sum. Fair question: they sum **without** `decode`.
The stages *are* a per-frame partition of e2e — pts →(host+net)→ received →(decode)→ decoded
→(display)→ displayed — for as long as the `decoded` stamp is a **completion** stamp. On the
synchronous rungs it is. On the **native-Vulkan** rung `receive_frame` returns at *submission*
(~0.1 ms) and the stamp is taken there, so `display` is measured from submit and the GPU decode
happens **inside** it. `host+net` and `display` already tile e2e; the `decode` figure (received →
fence-complete) re-counts the GPU work `display` contains — two figures with one overlap, printed
as though they tiled.
On that rung `decode` now leaves the stage line and gets its own, carrying the two caveats a reader
needs: it is **one sample per window** there, not the p50 every other figure on that line is, and it
is already inside `display`, so adding it double-counts. The synchronous rungs are untouched.
**Deliberately not changed:** the one-sample-per-window design. A per-frame fence wait serialises
the decode pipeline (an APU's 19 ms decode capping a 5120×1440 stream at ~51 fps) and polling
quantises every sample up by a frame interval. The reporting was the defect, not the sampling.
### Host — two warnings that named the wrong subsystem
Both fired in the same 2026-08-13 field log, and both sent an investigation somewhere innocent:
- **"Client keyframe recoveries are METRONOMIC — a periodic host/display disturbance … is the
likely cause"**, at `period_s=2.0`, naming three host subsystems. **2.0 s is the *client's*
`FLUSH_COOLDOWN`.** The receive-backlog guard sheds a standing queue with a flush plus a keyframe
request, rate-limited to one per cooldown, so a client that cannot sustain the stream asks for a
keyframe at exactly that spacing for as long as it stays behind. **Perfect periodicity is the
signature of a fixed software cooldown, not of a physical disturbance.** The host now compares
against `punktfunk_core::client::FLUSH_COOLDOWN` itself rather than a copy of the number, so the
two cannot drift.
- **"The audio encode thread could not keep up — captured audio was DROPPED"**, worst case
`dropped_chunks=11251`. Not one sample anybody wanted was lost. PipeWire negotiated a 128-frame
quantum, so the plane produces 48000/128 = 375 chunks/s and a 30 s window holds exactly 11250 —
a 100 % drop rate at `peak_db=-120.0`, digital silence. Every one of the ten warnings straddled a
**session boundary**, and `dropped_chunks/375` matches the seconds with *no live session* in that
window to within a fraction of a second. The warning no longer fires for idle seconds.
### NixOS — the plugin runner was installed, running, and reported missing
🛑 **On NixOS every plugin *package* op failed with "the plugin runner isn't installed", on a box
@@ -217,6 +629,51 @@ NixOS ships only `sh` in `/bin`, so `gamelease`'s hand-off test and `pyrowave_re
handshake-rung test failed there for reasons unrelated to the code under test. Both now resolve a
real binary rather than assuming an FHS path.
### Documentation
**`docs-site/public/openapi.json` was stale again, and by the same mechanism as last release.**
v0.28.0 fixed it once (it was five releases behind at `0.21.0`); the scanner-removal regen then
updated `api/openapi.json` alone and it drifted a second time inside that same cycle. It has now
drifted a third time, across the unpair-all endpoints — the docs-site copy was still stamped
`0.27.0` and missing both collection deletes. Re-synced; the two files are byte-identical again.
⚠ **The copy is a documented manual step (`cp api/openapi.json docs-site/public/openapi.json`,
CONTRIBUTING.md) and nothing in CI enforces it.** Three drifts in two release cycles is the
argument for gating it; until something does, **treat the copy as part of regenerating, not as a
follow-up.**
### Linux — the data-plane threads finally get the priority they ask for (⚠ packager-visible)
**On every Linux host to date, `pf_frame::thread_qos`'s per-thread renice was a silent no-op**
it needs CAP_SYS_NICE or a raised RLIMIT_NICE, no packaging channel granted either, and the host
binary can never carry a file capability (KWin identification, the 0.26.0-1 incident). So the
capture/encode and send threads ran at nice 0, and a CPU-saturating burst on the host — a fresh
game launch's shader-compile storm is the canonical one — descheduled them at will. A 2026-08-14
field log showed the result end to end: 5 ms audio datagrams leaving late enough to stutter, the
client's delay signal rising, and ABR cutting a gigabit-Ethernet session to its 5 Mbps floor with
zero packet loss — while the box carried 708 Mbps cleanly minutes later, once the storm passed.
**The renice now falls back to RealtimeKit** (`MakeThreadHighPriorityWithPID`, one blocking
system-bus call per boosted thread) — the same unprivileged broker PipeWire clients use, present
on effectively every desktop install. No capability enters the host's permitted set, so KWin
identification is untouched. Boxes with neither rtkit nor the new limit keep today's best-effort
no-op, one debug line per thread.
**The audio plane is boosted at all for the first time.** The 5 ms Opus capture→encode→send loop,
the PipeWire capture mainloop thread (its `process` callbacks run there — PipeWire's own
`module-rt` only covers data loops we don't use), and the pad-audio streamer now take the same
boost the video threads always asked for. The audio loop is `critical`: a scheduling stall there
is directly audible where a late video frame is one presentation slip.
**Packagers: a new `user@.service.d` drop-in.** rpm/deb/Arch (and the Bazzite sysext, via the
RPM) now ship `packaging/linux/50-punktfunk-nice.conf`
`/usr/lib/systemd/system/user@.service.d/50-punktfunk-nice.conf` (`LimitNICE=-15`), so the direct
`setpriority()` also works where rtkit isn't running. It raises a session *limit*, from the next
login — nothing is reprioritized by itself. The NixOS module instead sets
`security.rtkit.enable = lib.mkDefault true` (rtkit is not a given there). It remains true that
**no channel may ever grant the host binary a file capability** — this change is the sanctioned
route to the same end.
---
## v0.28.0
Generated
+37 -36
View File
@@ -1090,7 +1090,7 @@ dependencies = [
[[package]]
name = "cursor-probe"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"anyhow",
"pf-capture",
@@ -1222,7 +1222,7 @@ dependencies = [
[[package]]
name = "display-disturb"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"pf-win-display",
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
@@ -2343,7 +2343,7 @@ dependencies = [
[[package]]
name = "latency-probe"
version = "0.28.0"
version = "0.28.1"
[[package]]
name = "lazy_static"
@@ -2446,7 +2446,7 @@ dependencies = [
[[package]]
name = "libvpl-sys"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"bindgen",
"cmake",
@@ -2475,7 +2475,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"punktfunk-core",
]
@@ -2967,7 +2967,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pf-bitstream"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"cros-codecs",
"tracing",
@@ -2975,7 +2975,7 @@ dependencies = [
[[package]]
name = "pf-capture"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"anyhow",
"ashpd",
@@ -2996,7 +2996,7 @@ dependencies = [
[[package]]
name = "pf-client-core"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"anyhow",
"ash",
@@ -3031,7 +3031,7 @@ dependencies = [
[[package]]
name = "pf-clipboard"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"anyhow",
"ashpd",
@@ -3049,7 +3049,7 @@ dependencies = [
[[package]]
name = "pf-console-ui"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"anyhow",
"ash",
@@ -3071,7 +3071,7 @@ dependencies = [
[[package]]
name = "pf-dxvadec"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"cros-codecs",
"pf-bitstream",
@@ -3081,7 +3081,7 @@ dependencies = [
[[package]]
name = "pf-encode"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"anyhow",
"ash",
@@ -3107,7 +3107,7 @@ dependencies = [
[[package]]
name = "pf-frame"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"anyhow",
"libc",
@@ -3115,11 +3115,12 @@ dependencies = [
"punktfunk-core",
"tracing",
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
"zbus",
]
[[package]]
name = "pf-gpu"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"anyhow",
"pf-host-config",
@@ -3133,11 +3134,11 @@ dependencies = [
[[package]]
name = "pf-host-config"
version = "0.28.0"
version = "0.28.1"
[[package]]
name = "pf-inject"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"anyhow",
"ashpd",
@@ -3166,14 +3167,14 @@ dependencies = [
[[package]]
name = "pf-paths"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"tracing",
]
[[package]]
name = "pf-presenter"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"anyhow",
"ash",
@@ -3188,7 +3189,7 @@ dependencies = [
[[package]]
name = "pf-update"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"serde",
"serde_json",
@@ -3196,7 +3197,7 @@ dependencies = [
[[package]]
name = "pf-update-check"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"anyhow",
"aws-lc-rs",
@@ -3208,7 +3209,7 @@ dependencies = [
[[package]]
name = "pf-vaadec"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"cros-codecs",
"pf-bitstream",
@@ -3217,7 +3218,7 @@ dependencies = [
[[package]]
name = "pf-vdisplay"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"anyhow",
"ashpd",
@@ -3250,7 +3251,7 @@ dependencies = [
[[package]]
name = "pf-vkdecode"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"ash",
"cros-codecs",
@@ -3261,7 +3262,7 @@ dependencies = [
[[package]]
name = "pf-win-display"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"pf-paths",
"punktfunk-core",
@@ -3272,7 +3273,7 @@ dependencies = [
[[package]]
name = "pf-zerocopy"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"anyhow",
"ash",
@@ -3484,7 +3485,7 @@ dependencies = [
[[package]]
name = "punktfunk-cli"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"pf-client-core",
"punktfunk-core",
@@ -3494,7 +3495,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"android_logger",
"jni",
@@ -3512,7 +3513,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-linux"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"anyhow",
"async-channel",
@@ -3529,7 +3530,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-session"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"pf-client-core",
"pf-console-ui",
@@ -3543,7 +3544,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-windows"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"async-channel",
"mdns-sd",
@@ -3561,7 +3562,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"aes-gcm",
"cbindgen",
@@ -3593,7 +3594,7 @@ dependencies = [
[[package]]
name = "punktfunk-encode-worker"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"pf-encode",
"tracing",
@@ -3602,7 +3603,7 @@ dependencies = [
[[package]]
name = "punktfunk-host"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"aes",
"aes-gcm",
@@ -3672,7 +3673,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3686,7 +3687,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"anyhow",
"ksni",
@@ -3709,7 +3710,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "pyrowave-sys"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"bindgen",
"cmake",
+1 -1
View File
@@ -65,7 +65,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.28.0"
version = "0.28.1"
edition = "2024"
rust-version = "1.85"
license = "MIT OR Apache-2.0"
+5 -1
View File
@@ -5,13 +5,17 @@ machine, so we take security reports seriously and appreciate responsible disclo
## Supported versions
Punktfunk ships on two tracks — **stable** (a `vX.Y.Z` tag; the current line is **0.22.x**) and
Punktfunk ships on two tracks — **stable** (a `vX.Y.Z` tag) and
**canary** (built from `main`). Fixes ship as a new release on those tracks; in practice
we don't backport to older minor versions, so the supported versions are the latest stable release
and the current canary build. If you're on an older build, please check that the issue still
reproduces on the latest stable before reporting it. See
[Release Channels](https://docs.punktfunk.unom.io/docs/channels).
Security fixes are **free of charge**, ship **without undue delay**, and are **separated from
feature updates where feasible**: on the stable track they arrive as patch releases (`vX.Y.Z+1`)
that carry the fix rather than waiting on the next feature release.
## Reporting a vulnerability
**Please report security issues privately by email to security@punktfunk.com.**
+15
View File
@@ -51,6 +51,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libxdamage-dev libxcomposite-dev libxrender-dev libxext-dev libxxf86vm-dev \
libxtst-dev libx11-dev libxres-dev libxmu-dev libxcursor-dev libxi-dev \
libxfixes-dev libxkbcommon-dev libxkbcommon-x11-dev libcap-dev libdrm-dev \
# x11-xcb is needed by the VULKAN WSI LAYER (layer/meson.build), not by the compositor — so it
# was not missed until v0.28.1 started building the layer beside the binary. Debian is the only
# channel that needs it named: Arch's libx11 and Fedora's libX11-devel both carry x11-xcb.pc
# themselves, while Debian splits it into its own -dev package.
libx11-xcb-dev \
libinput-dev libudev-dev libpipewire-0.3-dev libseat-dev libsdl2-dev \
libluajit-5.1-dev libavif-dev libdecor-0-dev hwdata libglm-dev libbenchmark-dev \
libvulkan-dev libxcb1-dev libxcb-composite0-dev libxcb-xfixes0-dev libxcb-res0-dev \
@@ -66,3 +71,13 @@ RUN set -eux; \
pkg-config --atleast-version=1.23.1 wayland-server \
|| { echo "wayland-server $have < 1.23.1 — the vendored wlroots will not configure" >&2; exit 1; }; \
echo "wayland-server $have — OK"
# The layer's own floor, asserted for the same reason: a missing x11-xcb does not fail the
# COMPOSITOR build, it fails `layer/meson.build` — and the layer is the only route to an HDR10
# swapchain for a nested game, so losing it silently ships a package that looks healthy and denies
# every game HDR. This is exactly how v0.28.1's deb leg broke, one release after the layer was
# added; assert it here so the next dep the layer grows fails at image build, not mid-release.
RUN set -eux; \
pkg-config --exists x11-xcb \
|| { echo "x11-xcb absent — the Vulkan WSI layer will not configure (need libx11-xcb-dev)" >&2; exit 1; }; \
echo "x11-xcb $(pkg-config --modversion x11-xcb) — OK"
+2 -1
View File
@@ -22,7 +22,8 @@ Google TV, budget Amlogic boxes) that otherwise reject a 64-bit-only build as "n
## Get it
Published to **Google Play (Internal Testing)** — join the beta via the
Published to **Google Play (Open Testing)** — join via the
[public opt-in link](https://play.google.com/apps/testing/io.unom.punktfunk) or the
[Discord](https://discord.gg/kaPNvzMuGU). Per-device setup and pairing:
**[docs.punktfunk.unom.io/docs/install-client](https://docs.punktfunk.unom.io/docs/install-client)**.
+4
View File
@@ -142,6 +142,10 @@ dependencies {
// job runs `:app:testDebugUnitTest -PskipRustBuild` (see kit/build.gradle.kts). ---
testImplementation(composeBom)
testImplementation("androidx.compose.ui:ui-test-junit4")
// Deterministic cover art for the library scene: FakeImageLoaderEngine answers the coverflow's
// AsyncImage synchronously with generated posters — no network, no async race under the frozen
// animation clock.
testImplementation("io.coil-kt:coil-test:2.7.0")
debugImplementation("androidx.compose.ui:ui-test-manifest") // the ComponentActivity test host
testImplementation("junit:junit:4.13.2")
// Real `org.json` for the shared-vectors test: the `org.json` inside `android.jar` is a stub
@@ -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
@@ -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
@@ -477,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
+18 -9
View File
@@ -32,7 +32,7 @@ const PROTO: &str = "punktfunk/1";
/// Field separator inside one serialized record (ASCII Unit Separator — never in a field value).
const FIELD_SEP: char = '\u{1f}';
/// One resolved host, serialized to Kotlin as `key␟name␟addr␟port␟fp␟pair␟mac␟os`
/// One resolved host, serialized to Kotlin as `key␟name␟addr␟port␟fp␟pair␟mac␟os␟mgmt`
/// (`␟` = [`FIELD_SEP`]). Records are newline-joined in a poll snapshot; [`Host::encode`] strips
/// the framing bytes from every field so no value can break it. New fields append (the Kotlin
/// parser tolerates both arities), never reorder.
@@ -49,6 +49,10 @@ struct Host {
/// OS-identity chain from the mDNS `os` TXT (`linux/fedora/bazzite`, ...), for the host
/// card's OS icon. Empty if absent (older host).
os: String,
/// Management-API port from the mDNS `mgmt` TXT — where the game library is served, distinct
/// from `port` (the native QUIC plane). `0` if absent. Kotlin persists it on the host record so
/// a host that moved off 47990 keeps its library once mDNS is no longer reachable.
mgmt: u16,
}
impl Host {
@@ -61,7 +65,7 @@ impl Host {
s.replace(['\n', '\r', FIELD_SEP], "")
}
format!(
"{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}",
"{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}",
clean(&self.key),
clean(&self.name),
clean(&self.addr),
@@ -70,6 +74,7 @@ impl Host {
clean(&self.pair),
clean(&self.mac),
clean(&self.os),
self.mgmt,
)
}
}
@@ -193,6 +198,8 @@ fn resolve(info: &ResolvedService) -> Option<Host> {
pair: val("pair"),
mac: val("mac"),
os: val("os"),
// 0 = the host didn't advertise one (older host); Kotlin then falls back to 47990.
mgmt: val("mgmt").parse().unwrap_or(0),
})
}
@@ -213,7 +220,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoverySt
}
/// `NativeBridge.nativeDiscoveryPoll(handle): String` — the current resolved-host snapshot,
/// newline-joined records of `key␟name␟addr␟port␟fp␟pair␟mac␟os` (`␟` = U+001F). Empty string = no hosts /
/// newline-joined records of `key␟name␟addr␟port␟fp␟pair␟mac␟os␟mgmt` (`␟` = U+001F). Empty string = no hosts /
/// `0` handle. Poll ~1 Hz from the UI thread (cheap: a mutex lock + string build).
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll<'local>(
@@ -277,10 +284,11 @@ mod tests {
pair: "required".into(),
mac: "aa:bb:cc:dd:ee:ff".into(),
os: "linux/fedora/bazzite".into(),
mgmt: 47991,
};
let encoded = h.encode();
let fields: Vec<&str> = encoded.split(FIELD_SEP).collect();
assert_eq!(fields.len(), 8);
assert_eq!(fields.len(), 9);
assert_eq!(fields[0], "host-123");
assert_eq!(fields[1], "home-worker-2");
assert_eq!(fields[2], "192.168.1.70");
@@ -289,6 +297,9 @@ mod tests {
assert_eq!(fields[5], "required");
assert_eq!(fields[6], "aa:bb:cc:dd:ee:ff");
assert_eq!(fields[7], "linux/fedora/bazzite");
// A NON-default port on purpose: the whole point of carrying this field is the host that
// moved off 47990, so a test pinned to the default would pass against a hardcoded value.
assert_eq!(fields[8], "47991");
assert!(
!encoded.contains('\n'),
"a record must never contain the record separator"
@@ -308,13 +319,11 @@ mod tests {
pair: "required\n".into(),
mac: "aa:bb\u{1f}cc".into(),
os: "linux\u{1f}evil/arch".into(),
// A numeric field cannot smuggle a separator — it is formatted from a u16, not cleaned.
mgmt: 47991,
};
let encoded = h.encode();
assert_eq!(
encoded.matches(FIELD_SEP).count(),
7,
"exactly eight fields"
);
assert_eq!(encoded.matches(FIELD_SEP).count(), 8, "exactly nine fields");
assert!(!encoded.contains('\n') && !encoded.contains('\r'));
let fields: Vec<&str> = encoded.split(FIELD_SEP).collect();
assert_eq!(fields[0], "kinjected");
@@ -50,6 +50,21 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipSupport
client(handle).is_some_and(|h| h.client.host_caps() & HOST_CAP_CLIPBOARD != 0)
}
/// `NativeBridge.nativeHostMgmtPort(handle)` — the management-API port the host reported in this
/// session's `Welcome`, or `0` if it advertised none (older host / no management API).
///
/// Kotlin persists this on the host record, which is what lets the library screen reach a host that
/// moved its mgmt port off 47990 WITHOUT ever having seen an mDNS advert — the VPN / routed-subnet
/// / added-by-address cases, where the `mgmt` TXT the discovery path relies on never arrives.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostMgmtPort(
_env: EnvUnowned,
_this: JObject,
handle: jlong,
) -> jint {
client(handle).map_or(0, |h| jint::from(h.client.mgmt_port()))
}
/// `NativeBridge.nativeClipControl(handle, enabled)` — session-level opt-in/out. Nothing
/// clipboard-related happens on either side until an `enabled: true` crosses.
#[unsafe(no_mangle)]
@@ -354,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:
@@ -1262,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).
@@ -1320,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)
@@ -24,7 +24,7 @@ struct LibraryCoverflowView: View {
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
private var ink: GamepadInk { .stored(paletteID) }
let games: [GameEntry]
let artLoader: LibraryArtLoader?
let artLoader: (any LibraryArtSource)?
var onLaunch: ((String) -> Void)?
/// Button B (back) dismisses the library screen. No touch equivalent needed here (the toolbar
/// Close button already covers that); this is what makes gamepad-only exit possible.
@@ -74,7 +74,7 @@ struct LibraryView: View {
@State private var errorText: String?
/// Cover-art loader (the same paired identity + host pinning as the list fetch, reused across
/// every poster in the grid). Built alongside `games` in `load()`; dropped on disappear.
@State private var artLoader: LibraryArtLoader?
@State private var artLoader: (any LibraryArtSource)?
#if os(iOS) || os(macOS)
/// The plain grid's hardware-keyboard cursor (a game id), and the grid width the column count
/// is derived from. nil until the first arrow press, so a touch user never sees a selection
@@ -409,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)
@@ -311,6 +359,61 @@ private struct ShotConnect: View {
}
}
}
// 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
@@ -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) {
@@ -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
@@ -162,6 +162,17 @@ final class HostStore: ObservableObject {
hosts[i].osChain = chain
}
/// Learn/refresh this host's management-API port from its live advert same contract as
/// `updateMacs`. Until this existed, `StoredHost.mgmtPort` was declared and read but never
/// written, so `effectiveMgmtPort` always answered 47990 and a host that had moved its mgmt
/// port simply had no working library here.
func updateMgmtPort(_ hostID: UUID, port: UInt16?) {
guard let port, port > 0,
let i = hosts.firstIndex(where: { $0.id == hostID }),
hosts[i].mgmtPort != port else { return }
hosts[i].mgmtPort = port
}
/// Bind this host to a settings profile, or to "Default settings" (nil) the ONLY way the
/// default changes. A one-off "Connect with " deliberately never lands here (§5.2:
/// predictable, not sticky).
@@ -48,11 +48,8 @@ struct GamepadPairView: View {
@StateObject private var ceremony = PairCeremony()
@State private var pin = ""
#if os(macOS)
@State private var clientName = Host.current().localizedName ?? "Mac"
#else
@State private var clientName = UIDevice.current.name
#endif
// Same source the connect path knocks with see the note in `PairSheet`.
@State private var clientName = DeviceName.current
@State private var focusID: String?
/// The field row the keyboard tray is editing; nil the row list owns the controller.
@State private var editing: String?
@@ -49,7 +49,7 @@ final class PairCeremony: ObservableObject {
let identity = try ClientIdentityStore.shared.loadForPairing()
return try PunktfunkKit.pair(
host: address, port: port, identity: identity,
pin: pin, name: name.isEmpty ? "Mac" : name)
pin: pin, name: name.isEmpty ? DeviceName.current : name)
}
await MainActor.run {
guard !token.cancelled else { return } // screen dismissed mid-ceremony
@@ -21,11 +21,9 @@ struct PairSheet: View {
let onPaired: (Data) -> Void
@State private var pin = ""
#if os(macOS)
@State private var clientName = Host.current().localizedName ?? "Mac"
#else
@State private var clientName = UIDevice.current.name
#endif
// Same source the connect path knocks with (`DeviceName.current`), so a device the operator
// approves from the console's pending list and one that pairs by PIN land under one name.
@State private var clientName = DeviceName.current
@StateObject private var ceremony = PairCeremony()
private var busy: Bool { ceremony.busy }
@@ -32,8 +32,11 @@ final class AudioDeviceWatcher {
/// posts one last change as it is torn down, and other AVAudioEngines in the process are not
/// ours to restart.
private let isOurs: (AnyObject?) -> Bool
/// Delivered on the main queue.
private let onChange: (Reason) -> Void
/// Delivered on the main queue. The second argument is the engine that posted the change
/// (`.engineConfiguration` only; nil for the HAL listener) the owner needs the OBJECT, not
/// just the reason, because an engine that is RUNNING when the notification lands is one the
/// owner already restarted: acting on that echo is how a rebuild loop starts.
private let onChange: (Reason, AnyObject?) -> Void
private let lock = NSLock()
private var configObserver: NSObjectProtocol?
@@ -41,7 +44,7 @@ final class AudioDeviceWatcher {
private var defaultOutputListener: AudioObjectPropertyListenerBlock?
#endif
init(isOurs: @escaping (AnyObject?) -> Bool, onChange: @escaping (Reason) -> Void) {
init(isOurs: @escaping (AnyObject?) -> Bool, onChange: @escaping (Reason, AnyObject?) -> Void) {
self.isOurs = isOurs
self.onChange = onChange
}
@@ -63,7 +66,7 @@ final class AudioDeviceWatcher {
let posted = note.object as AnyObject?
DispatchQueue.main.async {
guard let self, self.isOurs(posted) else { return }
self.onChange(.engineConfiguration)
self.onChange(.engineConfiguration, posted)
}
}
lock.lock()
@@ -77,7 +80,8 @@ final class AudioDeviceWatcher {
// (the voice-processing engine, which is the DEFAULT macOS configuration and which no Mac
// here can even initialize). The HAL is told either way.
let block: AudioObjectPropertyListenerBlock = { [weak self] _, _ in
self?.onChange(.defaultOutputDevice) // on the main queue registered against it below
// On the main queue registered against it below. No engine posted this, so nil.
self?.onChange(.defaultOutputDevice, nil)
}
var address = Self.defaultOutputAddress()
let status = AudioObjectAddPropertyListenerBlock(
@@ -42,7 +42,10 @@ public enum AudioDevices {
return channelCount(id, scope: kAudioObjectPropertyScopeInput)
}
private static func defaultInputDevice() -> AudioDeviceID? {
/// The device the system is currently capturing from the key `SessionAudio`'s
/// voice-processing gate latches a start failure against (the failure is a property of the
/// input device, so a new device earns a fresh attempt).
static func defaultInputDevice() -> AudioDeviceID? {
systemDevice(kAudioHardwarePropertyDefaultInputDevice)
}
@@ -0,0 +1,89 @@
// The two policy decisions of the device-change recovery, extracted where a unit test can reach
// them. Both exist because of one field incident (2026-08-14, Mac Studio): the voice-processing
// engine could not start on a 6-channel input device, every rebuild re-tried it, and the failed
// attempt's HAL churn (VPIO builds and tears down an aggregate device) re-stopped the fallback
// engines which posted the configuration change that scheduled the next rebuild. A ~2.5 s
// metronome of audio gaps, forever, with each rebuild also stalling the main thread (where macOS
// input capture lives), so the stream's INPUT cut out on the same beat. The session-side wiring
// lives in `SessionAudio`; the decisions live here because the loop shipped precisely because
// they could not be tested without a mic and a session.
#if os(macOS)
import CoreAudio
#endif
import Foundation
#if os(macOS)
/// Should a rebuild try the combined (voice-processing) topology again?
///
/// A VPIO start failure is a property of the INPUT DEVICE (its channel count and format), not of
/// the moment: retrying it on the same device fails the same way, and the attempt is not free
/// engaging and abandoning the voice processor churns the HAL hard enough to stop the healthy
/// fallback engines. So a failure latches until the default input actually changes; a new device
/// earns exactly one fresh attempt (it may well support VPIO), and its own failure latches again.
struct CombinedTopologyGate {
private var failed = false
/// The default input device the failure was observed on nil is a real value here ("failed
/// with no resolvable input device"), which is why `failed` is tracked separately.
private var failedInput: AudioDeviceID?
/// The combined topology failed with `input` as the default input device.
mutating func noteFailure(input: AudioDeviceID?) {
failed = true
failedInput = input
}
/// True when the combined topology is worth attempting with `input` as the default input
/// device. A device change clears the latch the answer is about the CURRENT hardware, and
/// coming back to a device that failed before earns a fresh attempt too (the failure may have
/// been the mid-transition kind, and one attempt per device change cannot loop).
mutating func shouldTry(input: AudioDeviceID?) -> Bool {
guard failed else { return true }
guard input == failedInput else {
failed = false
failedInput = nil
return true
}
return false
}
}
#endif
/// The delay before the next engine rebuild the base debounce/floor behaviour, plus an
/// escalating floor when rebuilds CHAIN (each one retriggered by its predecessor's own fallout).
///
/// One device switch produces one rebuild: its trigger burst is coalesced upstream, so the next
/// trigger normally arrives minutes later and gets the base floor. A trigger that arrives hard on
/// the heels of the last rebuild, again and again, is a rebuild answering itself and since the
/// recovery cannot always identify its own echo, the backstop is to keep answering but at a
/// doubling floor, so an unforeseen feedback shape costs one audio blip per half-minute instead
/// of a metronome. A quiet stretch resets the ladder to full responsiveness.
struct RebuildBackoff {
/// Let the burst of triggers from one switch land before rebuilding.
static let debounce: TimeInterval = 0.15
/// Floor between two rebuilds.
static let floor: TimeInterval = 0.5
/// The escalated floor's cap: looping recoveries settle at one attempt per this interval.
static let floorCap: TimeInterval = 30
/// A trigger this long after the last rebuild is unrelated to it the chain resets.
static let chainWindow: TimeInterval = 10
/// Consecutive rebuilds whose trigger arrived within `chainWindow` of the previous rebuild.
private(set) var chain = 0
private var lastRebuildAt: TimeInterval = -.infinity
/// The delay to schedule the next rebuild with, for a trigger arriving at `now`
/// (`systemUptime`). Mutates the chain accounting: call once per SCHEDULED rebuild, not per
/// coalesced trigger.
mutating func delay(now: TimeInterval) -> TimeInterval {
let since = now - lastRebuildAt
chain = since < Self.chainWindow ? chain + 1 : 0
let floor = min(Self.floor * pow(2, Double(min(chain, 6))), Self.floorCap)
return max(Self.debounce, floor - since)
}
/// The rebuild actually ran at `now` the reference the next trigger's `delay` measures from.
mutating func noteRebuild(at now: TimeInterval) {
lastRebuildAt = now
}
}
@@ -63,15 +63,24 @@ public final class SessionAudio {
private var micMuted = false
/// The playback jitter ring created by whichever engine starts playback first and KEPT
/// across an engine rebuild (the permission-grant upgrade in `startEngines` swaps engines,
/// not the ring, so the drain thread never has to be re-pointed). Main-thread confined,
/// like every start path.
/// not the ring, so the drain thread never has to be re-pointed). Guarded by `stateLock`:
/// the start paths run on `engineQueue`, while `stats` reads from the main thread.
private var ring: AudioRing?
/// Every engine build, start, stop and rebuild runs here, serially and NOT on the main
/// thread. macOS captures and sends input from the main thread, so the seconds a
/// voice-processing start can take (~1.9 s measured in the 2026-08-14 field loop) would
/// freeze the stream's input for exactly that long the recovery must never make the main
/// thread wait on the audio server. The main queue keeps only the trigger bookkeeping
/// (debounce, backoff, retry ladder), which is cheap by construction.
private let engineQueue = DispatchQueue(
label: "io.unom.punktfunk.audio.engines", qos: .userInitiated)
/// The video plane's end-to-end meter (captureon-glass), if the owner wired one the
/// reference the A/V sync loop steers the ring against. `nil` leaves the loop inert and the
/// ring exactly as it was before sync existed, which is also what the stage-1 fallback
/// presenter gets: it decodes and presents inside the layer with no per-frame stamp, so it can
/// offer no reference, and a loop with no reference must not invent one. Main-thread confined,
/// like `ring`; the meter itself is internally locked and read from the drain thread.
/// offer no reference, and a loop with no reference must not invent one. Written ONCE in
/// `start()` before anything is dispatched (the queue hop orders it for `startDrain`); the
/// meter itself is internally locked and read from the drain thread.
private var videoLatency: LatencyMeter?
#if !os(macOS)
/// AVAudioSession `setCategory`/`setActive` are synchronous and block on the audio server, so
@@ -99,7 +108,8 @@ public final class SessionAudio {
// MARK: - Device changes (see `installDeviceChangeRecovery`)
/// What `start()` was asked for, so a rebuild can put back the SAME topology the session was
/// started with. Main-thread confined, like the start paths that read it.
/// started with. Guarded by `stateLock` (written on the caller's thread, read when a rebuild
/// fires on the main queue).
private var startConfig: StartConfig?
private struct StartConfig {
let speakerUID: String
@@ -110,20 +120,23 @@ public final class SessionAudio {
}
/// Watches the hardware for us (see `AudioDeviceWatcher`). Guarded by `stateLock`.
private var deviceWatcher: AudioDeviceWatcher?
/// Whether the engines have been built at least once. Distinguishes "not started yet" (iOS
/// starts asynchronously) from "started and dead", which is what the recovery may act on.
/// Main-thread confined.
/// Whether the engines have been built at least once. Distinguishes "not started yet" (every
/// platform starts asynchronously now) from "started and dead", which is what the recovery
/// may act on. Guarded by `stateLock` (set on `engineQueue`, read on the main queue).
private var enginesAttempted = false
/// A rebuild is already on the main queue one device switch produces a burst of triggers
/// and they must collapse into one restart. Main-thread confined.
private var rebuildQueued = false
/// `systemUptime` of the last rebuild, so a device that renegotiates in a loop cannot spin
/// the session. Main-thread confined.
private var lastRebuildAt: TimeInterval = 0
/// Let the burst of triggers from one switch land before rebuilding.
private static let rebuildDebounce: TimeInterval = 0.15
/// Floor between two rebuilds.
private static let rebuildFloor: TimeInterval = 0.5
/// Debounce/floor for the next rebuild, with an escalating floor when rebuilds chain (each
/// retriggered by its predecessor see `RebuildBackoff`). Main-thread confined.
private var rebuildBackoff = RebuildBackoff()
#if os(macOS)
/// Latches a voice-processing start failure per input device, so a rebuild never re-attempts
/// a topology that deterministically fails the retry is what turned one failure into a
/// rebuild loop (see `CombinedTopologyGate` and the note on `installDeviceChangeRecovery`).
/// `engineQueue`-confined, like the start paths that consult and feed it.
private var combinedGate = CombinedTopologyGate()
#endif
/// Retries when a rebuild's `start()` loses the race with a device that is still going away
/// (0.3 s, 0.6 s, 1.2 s). A failed rebuild leaves no engine to post the next notification,
/// so this ladder and, on macOS, the HAL listener is all that stands between a mistimed
@@ -151,11 +164,12 @@ public final class SessionAudio {
}
/// Start playback (and, if enabled+authorized, the mic uplink). Empty UIDs = system default
/// device; on iOS the UIDs are ignored entirely (routes are AVAudioSession-managed). On macOS
/// the engines start synchronously on the caller's (main) thread. On iOS/tvOS start() is
/// ASYNCHRONOUS: it activates the AVAudioSession off the main thread, then starts the engines on
/// a later main-queue hop (gated by `!flag.isStopped`) so playback is live shortly after, not
/// on return. The mic may start later still if the permission prompt is pending.
/// device; on iOS the UIDs are ignored entirely (routes are AVAudioSession-managed).
/// ASYNCHRONOUS on every platform: the engines start on `engineQueue` (iOS/tvOS activate the
/// AVAudioSession off the main thread first), gated by `!flag.isStopped` so playback is
/// live shortly after, not on return. An engine start can block on the audio server for
/// seconds, and the caller's (main) thread is where macOS input capture lives it must
/// never wait. The mic may start later still if the permission prompt is pending.
/// `echoCancel` picks the engine topology see the header note and `wantsCombined`.
///
/// `videoLatency` is the session's END-TO-END latency meter (captureon-glass). Pass it to arm
@@ -166,26 +180,33 @@ public final class SessionAudio {
speakerUID: String, micUID: String, micChannel: Int, micEnabled: Bool, echoCancel: Bool,
videoLatency: LatencyMeter? = nil
) {
self.videoLatency = videoLatency
self.videoLatency = videoLatency // before any dispatch below startDrain reads it
// Before any engine exists: the recovery watches the hardware, not the engines, and the
// config it rebuilds from has to be recorded whether or not this start succeeds.
stateLock.lock()
startConfig = StartConfig(
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
micEnabled: micEnabled, echoCancel: echoCancel)
stateLock.unlock()
installDeviceChangeRecovery(micEnabled: micEnabled)
#if os(macOS)
// No AVAudioSession on macOS start the engines directly (caller's thread, as before).
startEngines(
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
micEnabled: micEnabled, echoCancel: echoCancel)
// No AVAudioSession on macOS but the engines start on `engineQueue`, never the
// caller's (main) thread: a voice-processing start can block on the audio server for
// seconds, and the main thread is where input capture lives.
engineQueue.async { [weak self] in
guard let self, !self.flag.isStopped else { return }
self.startEngines(
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
micEnabled: micEnabled, echoCancel: echoCancel)
}
#else
// Configure + activate the session OFF the main thread (it blocks on the audio server),
// then start the engines back on the main thread once it's active engine routing/format
// then start the engines on `engineQueue` once it's active engine routing/format
// depend on the active session. A stop() racing in between is caught by the flag guard.
Self.sessionQueue.async { [weak self] in
guard let self else { return }
self.activateAudioSession(micEnabled: micEnabled)
DispatchQueue.main.async { [weak self] in
self.engineQueue.async { [weak self] in
guard let self, !self.flag.isStopped else { return }
self.startEngines(
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
@@ -342,12 +363,15 @@ public final class SessionAudio {
#endif
/// Build + start the engines combined (voice-processed) or split, per `wantsCombined`
/// with the mic uplink only when enabled + authorized. Main thread (engine setup); on
/// iOS/tvOS the session is already active by the time this runs.
/// with the mic uplink only when enabled + authorized. Runs on `engineQueue` (a start can
/// block on the audio server for seconds never the main thread); on iOS/tvOS the session
/// is already active by the time this runs.
private func startEngines(
speakerUID: String, micUID: String, micChannel: Int, micEnabled: Bool, echoCancel: Bool
) {
stateLock.lock()
enginesAttempted = true // even if every path below fails see `reviveStoppedEngines`
stateLock.unlock()
#if os(tvOS)
// No app-accessible microphone input on tvOS playback only.
startPlayback(speakerUID: speakerUID)
@@ -356,9 +380,25 @@ public final class SessionAudio {
startPlayback(speakerUID: speakerUID)
return
}
#if os(macOS)
// A rebuild must not re-attempt a voice-processing start that already failed on this
// input device: the failure repeats, and the failed attempt's HAL churn stops the healthy
// fallback engines the 2026-08-14 rebuild loop (see `CombinedTopologyGate`).
var combined = wantsCombined(
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
echoCancel: echoCancel)
if combined, !combinedGate.shouldTry(input: AudioDevices.defaultInputDevice()) {
log.info("""
voice processing already failed on this input device split engines, no echo \
cancellation
""")
combined = false
}
#else
let combined = wantsCombined(
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
echoCancel: echoCancel)
#endif
switch AVCaptureDevice.authorizationStatus(for: .audio) {
case .authorized:
if combined {
@@ -374,7 +414,8 @@ public final class SessionAudio {
// drain thread carry over see `makePlaybackChain`).
startPlayback(speakerUID: speakerUID)
AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in
DispatchQueue.main.async {
guard let self else { return }
self.engineQueue.async { [weak self] in
guard let self, granted, !self.flag.isStopped else { return }
if combined {
self.stateLock.lock()
@@ -513,6 +554,17 @@ public final class SessionAudio {
/// - the route-change and media-services-reset notifications, iOS/tvOS, where the session and
/// not the device is what moves.
///
/// And three defenses keep the recovery from ANSWERING ITSELF a rebuild is not a silent
/// act (a voice-processing start builds and tears down HAL aggregates, and every fresh engine
/// renegotiates its IO), so its own fallout can retrigger it. The 2026-08-14 field loop was
/// exactly that: VPIO failed on a 6-channel mic, every rebuild re-tried it, and the failure's
/// churn stopped the fallback engines audio and (via the main thread) INPUT cutting out
/// every ~2.5 s for the whole session. The defenses: a configuration change from an engine
/// that is RUNNING is a rebuild's echo and is ignored (`hardwareMoved`); a VPIO failure is
/// latched per input device and never re-attempted on it (`CombinedTopologyGate`); and
/// rebuilds that chain anyway back off exponentially instead of metronoming
/// (`RebuildBackoff`).
///
/// `micEnabled` only decides whether the mic-bearing session observers are worth installing.
/// Main thread.
private func installDeviceChangeRecovery(micEnabled: Bool) {
@@ -523,7 +575,7 @@ public final class SessionAudio {
let watcher = AudioDeviceWatcher(
isOurs: { [weak self] posted in self?.ownsEngine(posted) ?? false },
onChange: { [weak self] reason in self?.hardwareMoved(reason) })
onChange: { [weak self] reason, posted in self?.hardwareMoved(reason, posted: posted) })
stateLock.lock()
deviceWatcher = watcher
stateLock.unlock()
@@ -549,10 +601,17 @@ public final class SessionAudio {
/// question is playback still where it should be but they answer it differently: an engine
/// that told us it stopped is definitive, while the default device moving might not concern us
/// at all.
private func hardwareMoved(_ reason: AudioDeviceWatcher.Reason) {
private func hardwareMoved(_ reason: AudioDeviceWatcher.Reason, posted: AnyObject?) {
guard !flag.isStopped else { return }
switch reason {
case .engineConfiguration:
// The engine stops itself BEFORE posting this so an engine that is RUNNING when the
// notification lands on the main queue is one a rebuild already replaced or restarted:
// the notification is the rebuild's own echo, and answering it is how the recovery
// loops. A change that stops the engine again after this posts again, and the HAL
// backstop checks placement independently, so ignoring a live engine's echo can never
// strand a stopped one.
if let engine = posted as? AVAudioEngine, engine.isRunning { return }
scheduleEngineRebuild(reason: reason.rawValue)
case .defaultOutputDevice:
#if os(macOS)
@@ -572,7 +631,10 @@ public final class SessionAudio {
/// output device at the moment it connected and leaving it silent for good. On iOS the same
/// flag keeps this from racing the asynchronous start, where no engine yet is normal.
private func reviveStoppedEngines(_ reason: String) {
guard !flag.isStopped, enginesAttempted, !playbackIsLive else { return }
stateLock.lock()
let attempted = enginesAttempted
stateLock.unlock()
guard !flag.isStopped, attempted, !playbackIsLive else { return }
scheduleEngineRebuild(reason: "playback is stopped and \(reason)")
}
@@ -594,15 +656,43 @@ public final class SessionAudio {
private func scheduleEngineRebuild(reason: String) {
guard !rebuildQueued else { return }
rebuildQueued = true
let since = ProcessInfo.processInfo.systemUptime - lastRebuildAt
let delay = max(Self.rebuildDebounce, Self.rebuildFloor - since)
log.info("\(reason) — restarting the audio engines in \(Int(delay * 1000)) ms")
let delay = rebuildBackoff.delay(now: ProcessInfo.processInfo.systemUptime)
if rebuildBackoff.chain >= 2 {
// Each rebuild is retriggering the next a feedback shape the echo guard and the
// topology gate did not identify. Keep answering (a real recovery must not be
// abandoned), but say what is happening: this line repeating IS the diagnosis.
log.warning("""
audio engine rebuilds are chaining (\(self.rebuildBackoff.chain) in a row \
\(reason)); backing off \(Int(delay * 1000)) ms
""")
} else {
log.info("\(reason) — restarting the audio engines in \(Int(delay * 1000)) ms")
}
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.rebuildEngines(attempt: 0)
self?.rebuildFire(attempt: 0)
}
}
/// The scheduled rebuild came due (main queue): close out the bookkeeping and hand the
/// actual engine work to `engineQueue` the teardown + start can block on the audio server
/// for seconds, and the main thread is where macOS captures and sends the stream's input.
/// A trigger arriving while the work is in flight schedules a fresh rebuild rather than
/// being swallowed; `engineQueue` is serial, so the two never interleave.
private func rebuildFire(attempt: Int) {
rebuildQueued = false
guard !flag.isStopped else { return }
stateLock.lock()
let config = startConfig
stateLock.unlock()
guard let config else { return }
rebuildBackoff.noteRebuild(at: ProcessInfo.processInfo.systemUptime)
engineQueue.async { [weak self] in
self?.performRebuild(config: config, attempt: attempt)
}
}
/// Put back the topology this session was started with, on whatever hardware is there now.
/// Runs on `engineQueue`.
///
/// A full rebuild rather than a `start()` on the stopped engine, because the mic side has to
/// follow too: `installMicTap` reads the input's live format, and the voice processor
@@ -610,10 +700,8 @@ public final class SessionAudio {
/// across (`makePlaybackChain` reuses it, `startDrain` is idempotent), so the drain thread
/// keeps decoding right through the switch and its overflow policy has already dropped
/// everything that went stale while the engine was down.
private func rebuildEngines(attempt: Int) {
rebuildQueued = false
guard !flag.isStopped, let config = startConfig else { return }
lastRebuildAt = ProcessInfo.processInfo.systemUptime
private func performRebuild(config: StartConfig, attempt: Int) {
guard !flag.isStopped else { return }
tearDownEngines()
startEngines(
speakerUID: config.speakerUID, micUID: config.micUID, micChannel: config.micChannel,
@@ -626,6 +714,18 @@ public final class SessionAudio {
log.info("audio engines restarted on the current device")
return
}
DispatchQueue.main.async { [weak self] in
self?.rebuildFailed(attempt: attempt)
}
}
/// A rebuild's playback did not come back (main queue) walk the retry ladder. Retries
/// when a rebuild's `start()` loses the race with a device that is still going away
/// (0.3 s, 0.6 s, 1.2 s): a failed rebuild leaves no engine to post the next notification,
/// so this ladder and, on macOS, the HAL listener is all that stands between a mistimed
/// switch and a silent session.
private func rebuildFailed(attempt: Int) {
guard !flag.isStopped else { return }
guard attempt < Self.rebuildAttempts else {
#if os(macOS)
log.error("""
@@ -637,10 +737,11 @@ public final class SessionAudio {
#endif
return
}
guard !rebuildQueued else { return } // a fresh trigger already queued a full rebuild
rebuildQueued = true // holds off a trigger that would only race this ladder
let delay = Self.rebuildDebounce * Double(1 << (attempt + 1))
let delay = RebuildBackoff.debounce * Double(1 << (attempt + 1))
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.rebuildEngines(attempt: attempt + 1)
self?.rebuildFire(attempt: attempt + 1)
}
}
@@ -786,9 +887,13 @@ public final class SessionAudio {
public let avOffsetMS: Int
}
/// A snapshot of `Stats`, or nil before playback starts. Main thread (`ring` is main-confined;
/// the ring's own numbers are taken under its lock, so they describe one instant).
/// A snapshot of `Stats`, or nil before playback starts. Safe from any thread (the handle is
/// taken under `stateLock`; the ring's own numbers are taken under its lock, so they
/// describe one instant).
public var stats: Stats? {
stateLock.lock()
let ring = self.ring
stateLock.unlock()
guard let s = ring?.stats else { return nil }
return Stats(bufferMS: s.bufferedMS, avOffsetMS: s.avOffsetMS)
}
@@ -813,7 +918,7 @@ public final class SessionAudio {
/// The playback jitter ring + the source node draining it shared by the plain playback
/// engine and the combined voice-processing engine, and REUSED across an engine rebuild
/// (same session, same ring: the drain thread keeps writing right through the swap). nil
/// when the host's channel layout can't be expressed (already logged). Main thread.
/// when the host's channel layout can't be expressed (already logged). Runs on `engineQueue`.
private func makePlaybackChain()
-> (ring: AudioRing, source: AVAudioSourceNode, format: AVAudioFormat)?
{
@@ -823,8 +928,10 @@ public final class SessionAudio {
// 1 s interleaved capacity, scaled by the channel count. The de-jitter depth itself is
// the ring's own business now (`AudioRing.targetMS`, mirroring `JitterTuning::COREAUDIO`)
// rather than a prefill passed in here.
stateLock.lock()
let ring = self.ring ?? AudioRing(capacity: 48_000 * channels, channels: channels)
self.ring = ring
stateLock.unlock()
// Engine-native deinterleaved float; the render block deinterleaves from the ring. Surround
// uses an explicit wire-order channel layout; the mixer downmixes to the output device when
@@ -983,6 +1090,17 @@ public final class SessionAudio {
// MARK: - Mic (mic host)
#if !os(tvOS)
/// The combined topology failed to come up. On macOS, latch the input device it failed on so
/// a rebuild goes straight to the split topology instead of re-running the failure the
/// failed attempt is what churns the HAL and retriggers the recovery (see
/// `CombinedTopologyGate`). On iOS routes are session-managed and a VPIO failure is the
/// transient route-transition kind, so nothing is latched there.
private func noteCombinedFailure() {
#if os(macOS)
combinedGate.noteFailure(input: AudioDevices.defaultInputDevice())
#endif
}
/// One engine, both directions: engage the system voice processor on the shared IO unit
/// (AEC + noise suppression + AGC), hang the playback source off its render side and the
/// mic tap off its capture side. Every failure falls back to a WORKING configuration
@@ -1001,6 +1119,7 @@ public final class SessionAudio {
voice processing unavailable (\(error.localizedDescription)) separate \
engines, no echo cancellation
""")
noteCombinedFailure()
startPlayback(speakerUID: speakerUID)
startCapture(micUID: micUID, micChannel: micChannel)
return
@@ -1054,6 +1173,7 @@ public final class SessionAudio {
// processor won't engage at all, already does exactly this; this arm used to give up
// on the mic instead, which is how a whole session could go silent uplink-only.)
engine.stop()
noteCombinedFailure()
startPlayback(speakerUID: speakerUID)
startCapture(micUID: micUID, micChannel: micChannel)
return
@@ -1064,6 +1184,7 @@ public final class SessionAudio {
log.error("combined engine failed to start: \(error.localizedDescription)")
engine.inputNode.removeTap(onBus: 0)
engine.stop()
noteCombinedFailure()
// Same rule: a working mic without echo cancellation beats no mic at all.
startPlayback(speakerUID: speakerUID)
startCapture(micUID: micUID, micChannel: micChannel)
@@ -0,0 +1,66 @@
// The name this device tells a host it is the label an operator approves in the web console.
import Foundation
#if canImport(UIKit)
import UIKit
#endif
/// The name the USER knows this device by: "Enrico's iPad", "Wohnzimmer UG", "Enricos MacBook Pro".
///
/// The host shows it in its pending-approval list the web console's outstanding-pairings view and
/// the dialog that approves a knock and files the device under it in the trust store. It is the
/// ONLY thing distinguishing one waiting device from another there, so it must come from the OS
/// name the user set, not from a placeholder.
///
/// The core's own default (`punktfunk_connect_ex9` and earlier) reads `COMPUTERNAME` / `HOSTNAME`
/// a Windows variable and a shell variable. Neither exists in a `launchd`-started GUI app, so
/// every Apple client used to fall through to the literal "This device" and a console with an
/// iPad, an Apple TV and a Mac pending showed three rows of it. Pass this to
/// `punktfunk_connect_ex10` instead (`PunktfunkConnection.init` does, by default).
public enum DeviceName {
/// This device's user-facing name, never empty.
public static var current: String {
#if os(macOS)
let name = (Host.current().localizedName ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
return name.isEmpty ? (hostName ?? kind) : name
#else
let name = UIDevice.current.name.trimmingCharacters(in: .whitespacesAndNewlines)
// iOS/tvOS 16+ answer `name` with the MODEL ("iPad") unless the app holds the
// user-assigned-device-name entitlement which turns a household's three iPads into
// three identical rows in the host's approval list. The hostname is not behind that
// gate on every OS version, and when the user has named the device it carries that
// name ("Enricos-iPad"), so prefer it whenever `name` came back generic.
if name.isEmpty || name == kind {
if let host = hostName { return host }
}
return name.isEmpty ? kind : name
#endif
}
/// The OS hostname without its mDNS `.local` suffix nil when it is unset or the placeholder
/// every unconfigured device reports, which would name nothing.
private static var hostName: String? {
let host = ProcessInfo.processInfo.hostName
.trimmingCharacters(in: .whitespacesAndNewlines)
let bare = host.hasSuffix(".local") ? String(host.dropLast(6)) : host
guard !bare.isEmpty, bare.caseInsensitiveCompare("localhost") != .orderedSame else {
return nil
}
return bare
}
/// What to call the device when the OS has no name for it the product, which at least tells
/// an operator which of the pending rows is the Apple TV. (iOS/tvOS 16+ answer
/// `UIDevice.current.name` with exactly this unless the app holds the user-assigned-name
/// entitlement, so the two agree more often than not.)
public static var kind: String {
#if os(macOS)
return "Mac"
#elseif os(tvOS)
return "Apple TV"
#else
return UIDevice.current.model // "iPad" / "iPhone"
#endif
}
}
@@ -61,6 +61,16 @@ public struct DiscoveredHost: Identifiable, Sendable, Equatable {
/// (`sanitizeOsChain`) drives the host card's OS mark and is persisted like the MACs.
/// Empty when not advertised (older host). Advisory/unauthenticated like the rest.
public let osChain: String
/// The host's management-API port (mDNS `mgmt` TXT) where the game library is served, NOT
/// `port`, which is the native QUIC plane. nil when not advertised (older host), and the
/// client then assumes `punktfunkDefaultMgmtPort`.
///
/// Persisted onto the saved host like the MACs and the OS chain, and for a sharper reason:
/// `StoredHost.mgmtPort` has existed all along but nothing ever wrote it, so
/// `effectiveMgmtPort` always resolved to 47990. 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
/// therefore had no working library on any Apple client at all.
public let mgmtPort: UInt16?
}
@MainActor
@@ -211,12 +221,12 @@ public final class HostDiscovery: ObservableObject {
public static func debugAdvert(
id: String, name: String, host: String, port: UInt16 = 9777,
fingerprintHex: String? = nil, requiresPairing: Bool = false, allowsTofu: Bool = true,
macAddresses: [String] = [], osChain: String = ""
macAddresses: [String] = [], osChain: String = "", mgmtPort: UInt16? = nil
) -> DiscoveredHost {
DiscoveredHost(
id: id, name: name, host: host, port: port, fingerprintHex: fingerprintHex,
requiresPairing: requiresPairing, allowsTofu: allowsTofu,
macAddresses: macAddresses, osChain: osChain)
macAddresses: macAddresses, osChain: osChain, mgmtPort: mgmtPort)
}
#endif
@@ -429,6 +439,7 @@ public final class HostDiscovery: ObservableObject {
var id: String?
var macs: [String] = []
var osChain = ""
var mgmtPort: UInt16?
if case let .bonjour(txt) = result.metadata {
fp = entry(txt, "fp")
pair = entry(txt, "pair")
@@ -438,13 +449,16 @@ public final class HostDiscovery: ObservableObject {
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
osChain = sanitizeOsChain(entry(txt, "os") ?? "")
// Unauthenticated input, so range-check rather than trust: a non-numeric or 0 value
// means "not advertised" and the client falls back to the default.
mgmtPort = entry(txt, "mgmt").flatMap(UInt16.init).flatMap { $0 > 0 ? $0 : nil }
}
return DiscoveredHost(
id: (id?.isEmpty == false) ? id! : name,
name: name, host: address, port: port,
fingerprintHex: fp, requiresPairing: pair == "required",
allowsTofu: pair == "optional", macAddresses: macs,
osChain: osChain)
osChain: osChain, mgmtPort: mgmtPort)
}
private static func key(_ result: NWBrowser.Result) -> String {
@@ -228,6 +228,16 @@ extension Artwork {
}
}
/// Anything that answers poster bytes for a cover-art URL. The production implementation is
/// [`LibraryArtLoader`]; the screenshot harness substitutes a canned source so store frames carry
/// artwork without a host on the network.
public protocol LibraryArtSource: Sendable {
func data(for url: URL) async throws -> Data
/// Release pooled connections when the owning screen goes away. Sources without connections
/// have nothing to do.
func close() async
}
/// Loads cover art for the library UI, routing each URL to the transport that suits its origin.
///
/// A `GameEntry`'s art candidates mix two very different things: the host's own art proxy
@@ -242,7 +252,7 @@ extension Artwork {
/// TLS handshake per tile.
///
/// Built once per library screen and reused across a whole grid's worth of posters.
public final class LibraryArtLoader: @unchecked Sendable {
public final class LibraryArtLoader: LibraryArtSource, @unchecked Sendable {
private let address: String
private let port: UInt16
private let identity: SecIdentity
@@ -452,6 +452,14 @@ public final class PunktfunkConnection {
/// The host capability bitfield (`Welcome.host_caps`): `PUNKTFUNK_HOST_CAP_GAMEPAD_STATE` /
/// `PUNKTFUNK_HOST_CAP_CLIPBOARD`. `0` for an older host that didn't say.
public private(set) var hostCaps: UInt8 = 0
/// The host's management-API port, from this session's `Welcome` where its game library is
/// served. `0` when the host advertised none (an older host, or one with no management API);
/// resolve through `StoredHost.effectiveMgmtPort` rather than dialing a `0`.
///
/// Read this after a connect and persist it: it is the only source that does not depend on
/// mDNS, so it is what makes a moved mgmt port work for a host reached over a VPN or added by
/// address on a network where discovery never functions.
public private(set) var hostMgmtPort: UInt16 = 0
/// Whether this host advertises the shared clipboard (`HOST_CAP_CLIPBOARD`) the gate for
/// offering the clipboard toggle. Absent on an older host, or one whose operator policy
/// (`PUNKTFUNK_CLIPBOARD=off`) keeps the feature dark.
@@ -596,6 +604,7 @@ public final class PunktfunkConnection {
preferredCodec: UInt8 = 0, // 0 = auto; else PUNKTFUNK_CODEC_* soft preference
clientCaps: UInt8 = 0, // ABI v11: PUNKTFUNK_CLIENT_CAP_CURSOR = render the host cursor locally
launchID: String? = nil,
deviceName: String? = nil, // nil = this device's OS name (`DeviceName.current`)
timeoutMs: UInt32 = 10_000
) throws {
if let pin = pinSHA256, pin.count != 32 { throw PunktfunkClientError.invalidPin }
@@ -608,25 +617,33 @@ public final class PunktfunkConnection {
// host upgrades to a 10-bit / BT.2020 PQ stream only when set. 0 = 8-bit BT.709 SDR.
// `launchID` (a host library id like "steam:570") asks the host to launch that title in
// the session; the host resolves it against its own library nil = the host's default.
// `label` is what an unpaired knock shows up as in the host's approval list (and the web
// console's outstanding-pairings view): this device's OS name unless the caller overrode
// it. Without it the core falls back to environment variables no Apple app has, and every
// device pending approval reads "This device".
let override = deviceName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let label = override.isEmpty ? DeviceName.current : override
handle = host.withCString { cs in
withOptionalCString(identity?.certPEM) { cert in
withOptionalCString(identity?.keyPEM) { key in
withOptionalCString(launchID) { launch in
if let pin = pinSHA256 {
return pin.withUnsafeBytes { p in
punktfunk_connect_ex9(
cs, port, width, height, refreshHz, compositor.rawValue,
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels,
videoCodecs, preferredCodec, clientCaps, launch,
p.bindMemory(to: UInt8.self).baseAddress, &observed,
cert, key, timeoutMs, &connectStatus)
label.withCString { name in
if let pin = pinSHA256 {
return pin.withUnsafeBytes { p in
punktfunk_connect_ex10(
cs, port, width, height, refreshHz, compositor.rawValue,
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels,
videoCodecs, preferredCodec, clientCaps, launch,
p.bindMemory(to: UInt8.self).baseAddress, &observed,
cert, key, name, timeoutMs, &connectStatus)
}
}
return punktfunk_connect_ex10(
cs, port, width, height, refreshHz, compositor.rawValue,
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels,
videoCodecs, preferredCodec, clientCaps, launch,
nil, &observed, cert, key, name, timeoutMs, &connectStatus)
}
return punktfunk_connect_ex9(
cs, port, width, height, refreshHz, compositor.rawValue,
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels,
videoCodecs, preferredCodec, clientCaps, launch,
nil, &observed, cert, key, timeoutMs, &connectStatus)
}
}
}
@@ -677,6 +694,12 @@ public final class PunktfunkConnection {
var caps: UInt8 = 0
_ = punktfunk_connection_host_caps(handle, &caps)
hostCaps = caps
// Where this host serves its game library, straight from the session's Welcome. 0 = the
// host advertised none (older host / no management API), and the caller keeps whatever it
// already had. This is the answer that does NOT require an mDNS advert to have been seen.
var mgmt: UInt16 = 0
_ = punktfunk_connection_mgmt_port(handle, &mgmt)
hostMgmtPort = mgmt
}
/// A bandwidth speed-test measurement (see `startSpeedTest`). Partial until `done`.
@@ -86,6 +86,21 @@ public final class InputCapture {
/// its Esc suppression need it in both states).
private var cmdKeysDown: Set<UInt32> = []
#if os(macOS)
/// Windows VKs the -chord passthrough sent DOWN (see the keyDown monitor). macOS stops
/// delivering keyUp for ordinary keys while Command is held, so the release half of Q/W/
/// cannot be relied on to arrive through the responder chain at all: these are flushed when
/// the last comes up (`flushCommandChord`), which is what stands between the host and a
/// key held down for the rest of the session.
private var commandChordVKs: Set<UInt32> = []
/// Mirrors StreamLayerView's live mouse model M flips it mid-session, so it can't be
/// read from the settings. The -chord passthrough stays off under the desktop model, matching
/// what the SDL clients' keyboard grab does: a remote desktop is something you Tab away from,
/// not into.
public var desktopMouse = false
#endif
#if !os(macOS)
/// The key currently auto-repeating, and the timer driving it. iOS/tvOS only see
/// `startAutoRepeat`. Main-queue only, like every other field here.
@@ -244,19 +259,27 @@ public final class InputCapture {
) { [weak self] _ in
self?.releaseAll()
})
// the capture toggle is detected here so it works in both states. ONLY
// that one combo is intercepted: swallowing keys wholesale at the monitor level
// risks starving GC's own delivery, so the no-beep behavior lives in
// StreamLayerView (first responder consumes keyDown/keyUp while captured).
// (On iOS there is no NSEvent monitor the GC key handler detects the combo.)
// This monitor is the FIRST thing in the app to see a key: AppKit calls it before
// `sendEvent:`, so before any menu key equivalent and before StreamLayerView's keyDown.
// Returning nil discards the event outright which cuts BOTH of those off, and on macOS
// the second one is the host's only key path (the GCKeyboard send is iOS-only; see
// `attach(keyboard:)`). So the rule here is: anything swallowed must either be handled
// client-side or forwarded to the host from inside this block, because nothing downstream
// will get a second chance at it.
//
// (capture toggle) and M (mouse model) are client-side in BOTH states; Q/D/S/A
// and F are client-side only while forwarding (released, the events pass through and the
// menu's identical key equivalents handle them). Every OTHER chord is the HOST's while
// captured see `forwardsCommandChord`. (On iOS there is no NSEvent monitor the GC key
// handler detects the combos.)
#if os(macOS)
keyEventMonitor = NSEvent.addLocalMonitorForEvents(
matching: [.keyDown]
) { [weak self] event in
guard let self else { return event }
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
let flags = Self.chordFlags(event)
if event.keyCode == 53 /* Esc */, flags == .command {
self.suppressedVK = 0x1B // the same physical Esc is en route via GC
self.suppressedVK = 0x1B // VK_ESC its keyUp still reaches the responder chain
self.onToggleCapture?()
return nil
}
@@ -266,7 +289,7 @@ public final class InputCapture {
// (latched like 's Esc) so it doesn't type into the host, and swallow the
// event so it doesn't beep.
if event.keyCode == 46 /* M */, flags == [.control, .option, .shift] {
self.suppressedVK = 0x4D // VK_M the same physical M is en route via GC
self.suppressedVK = 0x4D // VK_M its keyUp still reaches the responder chain
self.onToggleMouseMode?()
return nil
}
@@ -304,10 +327,34 @@ public final class InputCapture {
// captured stream view swallows the menu's identical equivalent); the F is latched so its
// keyUp can't type into the host. keyCode 3 = kVK_ANSI_F (layout-independent).
if self.forwarding, flags == [.control, .command], event.keyCode == 3 /* F */ {
self.suppressedVK = 0x46 // VK_F the same physical F is en route via GC
self.suppressedVK = 0x46 // VK_F its keyUp still reaches the responder chain
self.onToggleFullscreen?()
return nil
}
// Every OTHER chord belongs to the HOST while captured the cross-client "capture
// system shortcuts" setting, which the Apple client had no answer to because SDL's
// keyboard grab is what implements it everywhere else. Without this the app menu's key
// equivalents fire first, so Q quits the client instead of reaching the compositor as
// Super+Q one of the most-bound chords on a Linux desktop, and the reported break.
//
// It has to SEND from here: returning nil is what keeps the menu out, and it takes
// StreamLayerView's keyDown the host's only key path on macOS out with it.
// Chords with no host VK are swallowed but not sent: doing nothing beats a menu
// opening under a captured stream. The itself needs no handling modifiers arrive
// as flagsChanged, which this monitor never sees, so it was already forwarded as
// VK_LWIN/VK_RWIN (or Alt, under the Windows modifier layout) when it went down.
//
// The two cheap conditions are repeated in front of the call on purpose: off-session,
// `SessionSettings.current` re-reads the whole defaults suite, and this monitor sees
// every keystroke the app receives including the ones typed into the host list.
if self.forwarding, flags.contains(.command), Self.forwardsCommandChord(
keyCode: event.keyCode, flags: flags, forwarding: self.forwarding,
inhibitShortcuts: SessionSettings.current.inhibitShortcuts,
desktopMouse: self.desktopMouse
) {
if let vk = Self.keyCodeToVK[event.keyCode] { self.sendCommandChordKey(vk) }
return nil
}
return event
}
#endif
@@ -358,6 +405,9 @@ public final class InputCapture {
cmdKeysDown.removeAll()
chordModifiersDown.removeAll()
suppressedVK = nil
#if os(macOS)
commandChordVKs.removeAll() // their releases are in `pressedVKs`, flushed just below
#endif
for vk in pressedVKs {
emitKey(vk, down: false)
}
@@ -522,7 +572,15 @@ public final class InputCapture {
// Keep cmdKeysDown in step (the toggle + Esc suppression read it); sendKey
// adds the VK to pressedVKs so releaseAll/blur flushes a held modifier cleanly.
if vk == 0x5B || vk == 0x5C {
if down { cmdKeysDown.insert(vk) } else { cmdKeysDown.remove(vk) }
if down {
cmdKeysDown.insert(vk)
} else {
cmdKeysDown.remove(vk)
// Last up: release the chord keys whose own keyUp macOS never delivered. BEFORE
// the 's own release goes out, so the host never sees the letter outlive the
// modifier it was pressed with.
if cmdKeysDown.isEmpty { flushCommandChord() }
}
}
sendKey(vk, down: down)
}
@@ -552,6 +610,68 @@ public final class InputCapture {
}
return (mod.vk, down)
}
// MARK: - chord passthrough
/// The four modifiers a client chord is ever spelled with, isolated from the incidental bits
/// `deviceIndependentFlagsMask` also carries: Caps Lock, and the `.function`/`.numericPad`
/// pair every arrow and F-key sets. Equality against the raw masked flags meant a chord
/// stopped being recognized the moment Caps Lock was on and Q, both escape hatches,
/// included. That was survivable while the monitor claimed six chords; it is not, now that it
/// swallows every chord there is.
static let chordFlagMask: NSEvent.ModifierFlags = [.command, .control, .option, .shift]
/// One event's chord modifiers (see `chordFlagMask`).
static func chordFlags(_ event: NSEvent) -> NSEvent.ModifierFlags {
event.modifierFlags.intersection(chordFlagMask)
}
/// The chords the CLIENT keeps while captured, which is to say: the way out. releases
/// the mouse/keyboard and F leaves fullscreen hand either of those to the host and a
/// captured stream becomes a room with no door. (Q/D/S/A carry no and never reach here.)
static func isClientReservedChord(keyCode: UInt16, flags: NSEvent.ModifierFlags) -> Bool {
if keyCode == 53, flags == .command { return true } // capture toggle
if keyCode == 3, flags == [.control, .command] { return true } // F fullscreen
return false
}
/// Does this keyDown get taken off AppKit and forwarded to the host instead? Only while input
/// is actually captured, only with the cross-client `inhibit_shortcuts` on, and never under the
/// desktop mouse model (where the chords stay local by design) and never for the client's own
/// reserved chords, whatever the setting says.
static func forwardsCommandChord(
keyCode: UInt16, flags: NSEvent.ModifierFlags,
forwarding: Bool, inhibitShortcuts: Bool, desktopMouse: Bool
) -> Bool {
guard forwarding, inhibitShortcuts, !desktopMouse else { return false }
guard flags.contains(.command) else { return false }
return !isClientReservedChord(keyCode: keyCode, flags: flags)
}
/// Forward one key of a chord the monitor just took off AppKit, remembering it so its
/// release can be synthesized (see `commandChordVKs`).
private func sendCommandChordKey(_ vk: UInt32) {
commandChordVKs.insert(vk)
sendKey(vk, down: true)
}
/// Release whatever the -chord passthrough sent down and is still held called when the last
/// physical comes up. A keyUp that DID arrive has already taken its VK out of `pressedVKs`,
/// so this only fires for the ones macOS swallowed.
private func flushCommandChord() {
// Same cause, different victim: a one-shot latch whose key-up never arrived goes on to eat
// the NEXT press of that key (F's F, 's Esc). Once is up, a pending latch is stale.
suppressedVK = nil
guard !commandChordVKs.isEmpty else { return }
for vk in commandChordVKs where pressedVKs.contains(vk) {
pressedVKs.remove(vk)
emitKey(vk, down: false)
if inputDebug {
inputLog.debug("key \(vk, privacy: .public) up SYNTHESIZED (⌘ chord release)")
}
}
commandChordVKs.removeAll()
}
#endif
private func attach(mouse: GCMouse) {
@@ -410,8 +410,9 @@ public final class StreamLayerView: NSView {
// keycode) Windows VK and forward via InputCapture.sendKey, then CONSUME (return without
// super) to stop the responder chain's "unhandled keyDown" beep. Keys with no VK mapping
// are still consumed while captured so they don't beep either. The toggle's Esc is
// swallowed upstream by InputCapture's keyDown monitor (suppressedVK), so it never gets
// here as a send; -combos still arrive via performKeyEquivalent and stay functional (D).
// swallowed upstream by InputCapture's keyDown monitor (suppressedVK), so it never gets here
// as a send and so are combos generally while captured, which that monitor forwards to the
// host itself (`forwardsCommandChord`) rather than letting a menu key equivalent claim them.
// Modifier keys never fire keyDown/keyUp they come through flagsChanged below.
public override var acceptsFirstResponder: Bool { true }
// A click after the app was inactive (Cmd-Tab away and back) must reach mouseDown so the
@@ -570,6 +571,9 @@ public final class StreamLayerView: NSView {
let wasCaptured = captured
if wasCaptured { releaseCapture() }
desktopMouse = on
// The -chord passthrough is off under the desktop model (system chords stay local there,
// as on every other client) and the model moves live, so the capture is told, not asked.
inputCapture?.desktopMouse = on
if wasCaptured { engageCapture(fromClick: false) }
window?.invalidateCursorRects(for: self)
if on, let p = reappearAt, let sp = cgScreenPoint(forHostX: p.x, p.y) {
@@ -917,6 +921,7 @@ public final class StreamLayerView: NSView {
) ?? .capture
let absOK = connection.resolvedCompositor != .gamescope
desktopMouse = mode == .desktop && absOK
capture.desktopMouse = desktopMouse
if mode == .desktop && !absOK {
streamInputLog.info("desktop mouse mode unavailable on a gamescope host (relative-only) — using capture")
}
@@ -157,6 +157,16 @@ public enum DefaultsKey {
/// Read live at the wire boundary by `InputCapture`. Control/Shift never move (same position on
/// both keyboards).
public static let modifierLayout = "punktfunk.modifierLayout"
/// Send system chords to the host while input is captured the cross-client
/// `inhibit_shortcuts`, ON by default. On the SDL clients it is SDL's keyboard grab (Alt+Tab,
/// the Windows key); macOS has no such grab from a plain app, so `InputCapture`'s keyDown
/// monitor implements it by taking every chord off AppKit before a menu key equivalent can
/// fire and forwarding it instead which is what makes Q reach the host's compositor rather
/// than quitting the client. Off keeps the chords local (the second-screen/work profile).
/// The client's own reserved chords (, F, ) are never forwarded either way, and as
/// on the SDL clients the setting has no effect under the `desktop` mouse model, which is
/// something you Tab *away* from. macOS-only today; nothing reads it on iOS/tvOS.
public static let inhibitShortcuts = "punktfunk.inhibitShortcuts"
/// iPad: capture the mouse/trackpad pointer (pointer lock relative movement) for games,
/// rather than forwarding an absolute cursor position. On by default. Only meaningful on iPad
/// with a hardware mouse/trackpad; the system grants the lock only to a full-screen, frontmost
@@ -33,6 +33,9 @@ public struct EffectiveSettings: Equatable, Sendable {
public var touchMode = "trackpad"
public var mouseMode = "capture"
public var invertScroll = false
/// Cross-client `inhibit_shortcuts` (default on): system chords reach the host while input is
/// captured. See `DefaultsKey.inhibitShortcuts` on macOS this is the -chord passthrough.
public var inhibitShortcuts = true
public var gamepadType = 0
public var gamepadForwarding = true
/// Cross-client `system_buttons`: "auto" | "forward" | "local".
@@ -97,6 +100,7 @@ public struct EffectiveSettings: Equatable, Sendable {
touchMode = str(DefaultsKey.touchMode, touchMode)
mouseMode = str(DefaultsKey.mouseMode, mouseMode)
invertScroll = bool(DefaultsKey.invertScroll, invertScroll)
inhibitShortcuts = bool(DefaultsKey.inhibitShortcuts, inhibitShortcuts)
gamepadType = int(DefaultsKey.gamepadType, gamepadType)
gamepadForwarding = bool(DefaultsKey.gamepadForwarding, gamepadForwarding)
systemButtons = str(DefaultsKey.systemButtons, systemButtons)
@@ -177,6 +181,7 @@ public struct EffectiveSettings: Equatable, Sendable {
if let v = overlay.touchMode { s.touchMode = v }
if let v = overlay.mouseMode { s.mouseMode = v }
if let v = overlay.invertScroll { s.invertScroll = v }
if let v = overlay.inhibitShortcuts { s.inhibitShortcuts = v }
if let v = overlay.gamepadType { s.gamepadType = v }
if let v = overlay.gamepadForwarding { s.gamepadForwarding = v }
if let v = overlay.systemButtons { s.systemButtons = v }
@@ -109,6 +109,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
public var touchMode: String?
public var mouseMode: String?
public var invertScroll: Bool?
public var inhibitShortcuts: Bool?
public var gamepadType: Int?
public var gamepadForwarding: Bool?
public var systemButtons: String?
@@ -153,6 +154,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
case touchMode = "touch_mode"
case mouseMode = "mouse_mode"
case invertScroll = "invert_scroll"
case inhibitShortcuts = "inhibit_shortcuts"
case gamepadType = "gamepad"
case gamepadForwarding = "gamepad_forwarding"
case systemButtons = "system_buttons"
@@ -189,6 +191,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
touchMode = str(.touchMode)
mouseMode = str(.mouseMode)
invertScroll = bool(.invertScroll)
inhibitShortcuts = bool(.inhibitShortcuts)
gamepadType = int(.gamepadType)
gamepadForwarding = bool(.gamepadForwarding)
systemButtons = str(.systemButtons)
@@ -227,6 +230,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
try c.encodeIfPresent(touchMode, forKey: AnyKey(Key.touchMode.rawValue))
try c.encodeIfPresent(mouseMode, forKey: AnyKey(Key.mouseMode.rawValue))
try c.encodeIfPresent(invertScroll, forKey: AnyKey(Key.invertScroll.rawValue))
try c.encodeIfPresent(inhibitShortcuts, forKey: AnyKey(Key.inhibitShortcuts.rawValue))
try c.encodeIfPresent(gamepadType, forKey: AnyKey(Key.gamepadType.rawValue))
try c.encodeIfPresent(
gamepadForwarding, forKey: AnyKey(Key.gamepadForwarding.rawValue))
@@ -283,6 +287,7 @@ public enum OverlayField {
case "touch_mode": overlay.touchMode = nil
case "mouse_mode": overlay.mouseMode = nil
case "invert_scroll": overlay.invertScroll = nil
case "inhibit_shortcuts": overlay.inhibitShortcuts = nil
case "gamepad": overlay.gamepadType = nil
case "gamepad_forwarding": overlay.gamepadForwarding = nil
case "system_buttons": overlay.systemButtons = nil
@@ -321,6 +326,7 @@ public enum OverlayField {
case "touch_mode": return o.touchMode != nil
case "mouse_mode": return o.mouseMode != nil
case "invert_scroll": return o.invertScroll != nil
case "inhibit_shortcuts": return o.inhibitShortcuts != nil
case "gamepad": return o.gamepadType != nil
case "gamepad_forwarding": return o.gamepadForwarding != nil
case "system_buttons": return o.systemButtons != nil
@@ -32,7 +32,7 @@ final class AudioDeviceWatcherTests: XCTestCase {
let engine = AVAudioEngine()
var reasons: [AudioDeviceWatcher.Reason] = []
let watcher = AudioDeviceWatcher(
isOurs: { $0 === engine }, onChange: { reasons.append($0) })
isOurs: { $0 === engine }, onChange: { reason, _ in reasons.append(reason) })
watcher.start()
defer { watcher.stop() }
@@ -51,7 +51,7 @@ final class AudioDeviceWatcherTests: XCTestCase {
let stranger = AVAudioEngine()
var reasons: [AudioDeviceWatcher.Reason] = []
let watcher = AudioDeviceWatcher(
isOurs: { $0 === ours }, onChange: { reasons.append($0) })
isOurs: { $0 === ours }, onChange: { reason, _ in reasons.append(reason) })
watcher.start()
defer { watcher.stop() }
@@ -66,7 +66,7 @@ final class AudioDeviceWatcherTests: XCTestCase {
let engine = AVAudioEngine()
var reasons: [AudioDeviceWatcher.Reason] = []
let watcher = AudioDeviceWatcher(
isOurs: { $0 === engine }, onChange: { reasons.append($0) })
isOurs: { $0 === engine }, onChange: { reason, _ in reasons.append(reason) })
watcher.start()
watcher.stop()
@@ -93,7 +93,7 @@ final class AudioDeviceWatcherTests: XCTestCase {
}
var reasons: [AudioDeviceWatcher.Reason] = []
let watcher = AudioDeviceWatcher(isOurs: { _ in false }, onChange: { reasons.append($0) })
let watcher = AudioDeviceWatcher(isOurs: { _ in false }, onChange: { reason, _ in reasons.append(reason) })
watcher.start()
defer {
_ = Self.setDefaultOutput(original)
@@ -0,0 +1,110 @@
// The two decisions that ended the 2026-08-14 rebuild loop, driven with a synthetic clock.
//
// The loop's shape, for the plant-the-defect cases below: the voice-processing engine fails to
// start (~1.9 s spent trying), the fallback comes up, and its own HAL fallout retriggers the
// recovery ~0.6 s later forever. Restore either defect (retry the failed topology, or keep the
// flat 0.5 s floor) and the session pays an audio gap every ~2.5 s for as long as it lives.
import XCTest
@testable import PunktfunkKit
final class AudioRebuildPolicyTests: XCTestCase {
// MARK: - RebuildBackoff
/// The first trigger of a session keeps the old behaviour: the burst-coalescing debounce.
func testFirstTriggerWaitsOnlyTheDebounce() {
var backoff = RebuildBackoff()
XCTAssertEqual(backoff.delay(now: 1000), RebuildBackoff.debounce)
}
/// One rebuild, then quiet: the next real device switch minutes later is answered at full
/// responsiveness the ladder must never make a HEALTHY recovery sluggish.
func testAnIsolatedSwitchLongAfterTheLastRebuildResetsTheChain() {
var backoff = RebuildBackoff()
_ = backoff.delay(now: 1000)
backoff.noteRebuild(at: 1000.2)
// Chained once (a second switch soon after legitimate, e.g. AirPods out then back in).
_ = backoff.delay(now: 1001)
backoff.noteRebuild(at: 1002)
// Minutes of quiet, then a fresh switch: base debounce again, chain forgotten.
XCTAssertEqual(backoff.delay(now: 1300), RebuildBackoff.debounce)
XCTAssertEqual(backoff.chain, 0)
}
/// THE FIELD LOOP, against the real constants: a trigger 0.6 s after every rebuild, ten
/// minutes long. The flat 0.5 s floor produced a rebuild every ~2.5 s ~240 audio gaps.
/// The ladder must cut that by an order of magnitude and settle at the floor cap.
func testAChainedLoopBacksOffToTheFloorCap() {
var backoff = RebuildBackoff()
var now: TimeInterval = 0
var rebuilds = 0
var lastDelay: TimeInterval = 0
let end: TimeInterval = 600
while now < end {
lastDelay = backoff.delay(now: now)
now += lastDelay // the scheduled rebuild fires...
backoff.noteRebuild(at: now)
rebuilds += 1
now += 0.6 // ...and its fallout retriggers the recovery 0.6 s later.
}
XCTAssertEqual(
lastDelay, RebuildBackoff.floorCap - 0.6, accuracy: 0.01,
"a persistent loop should settle at one rebuild per floorCap")
XCTAssertLessThanOrEqual(
rebuilds, 30,
"\(rebuilds) rebuilds in 10 min — the ladder is not escalating (the shipped flat "
+ "floor produced ~240)")
// And the loop's END must restore responsiveness: quiet, then a real switch.
XCTAssertEqual(backoff.delay(now: now + 120), RebuildBackoff.debounce)
}
/// The ladder's exponent is clamped a loop that runs for hours must neither overflow nor
/// push the interval past the cap.
func testTheFloorNeverExceedsTheCap() {
var backoff = RebuildBackoff()
var now: TimeInterval = 0
for _ in 0..<1000 {
let delay = backoff.delay(now: now)
XCTAssertLessThanOrEqual(delay, RebuildBackoff.floorCap)
now += delay
backoff.noteRebuild(at: now)
now += 0.1
}
}
#if os(macOS)
// MARK: - CombinedTopologyGate
/// The loop's fuel: re-attempting the voice-processing start that just failed. Same input
/// device never again.
func testAFailureLatchesForTheDeviceItFailedOn() {
var gate = CombinedTopologyGate()
XCTAssertTrue(gate.shouldTry(input: 42), "an unfailed gate must allow the attempt")
gate.noteFailure(input: 42)
XCTAssertFalse(gate.shouldTry(input: 42))
XCTAssertFalse(gate.shouldTry(input: 42), "the latch must hold across rebuilds")
}
/// The failure is a property of the DEVICE: a different default input earns a fresh attempt,
/// and its own failure latches again one attempt per device change can never loop.
func testADifferentInputDeviceEarnsOneFreshAttempt() {
var gate = CombinedTopologyGate()
gate.noteFailure(input: 42)
XCTAssertTrue(gate.shouldTry(input: 7))
gate.noteFailure(input: 7)
XCTAssertFalse(gate.shouldTry(input: 7))
// Back to the first device: the earlier failure may have been mid-transition one fresh
// attempt again, not a permanent ban.
XCTAssertTrue(gate.shouldTry(input: 42))
}
/// "No resolvable input device" is a real failure key too, distinct from "never failed".
func testFailingWithNoInputDeviceLatchesForNoInputDevice() {
var gate = CombinedTopologyGate()
gate.noteFailure(input: nil)
XCTAssertFalse(gate.shouldTry(input: nil))
XCTAssertTrue(gate.shouldTry(input: 42), "a device appearing is a device change")
}
#endif
}
@@ -0,0 +1,116 @@
#if os(macOS)
import AppKit
import XCTest
@testable import PunktfunkKit
/// Pins the macOS -chord passthrough the rule deciding which keyDowns `InputCapture`'s local
/// monitor takes off AppKit and forwards to the host instead of letting a menu key equivalent
/// claim them. Two things are worth a test rather than a comment:
///
/// * Q reaching the host at all. That is the whole point it is the compositor chord on
/// Hyprland/KDE/GNOME, and it used to quit the client.
/// * and F NOT reaching it, under every combination. They are the way out of a captured
/// stream; forward either and the user is locked in.
final class CommandChordTests: XCTestCase {
// kVK_ANSI_* physical positions, layout-independent (the same constants the monitor uses).
private let q: UInt16 = 12, w: UInt16 = 13, h: UInt16 = 4, m: UInt16 = 46
private let f: UInt16 = 3, esc: UInt16 = 53, leftArrow: UInt16 = 123
/// Captured, setting on, capture mouse model the shipping default.
private func forwards(
_ keyCode: UInt16, _ flags: NSEvent.ModifierFlags,
forwarding: Bool = true, inhibit: Bool = true, desktop: Bool = false
) -> Bool {
InputCapture.forwardsCommandChord(
keyCode: keyCode, flags: flags, forwarding: forwarding,
inhibitShortcuts: inhibit, desktopMouse: desktop)
}
func testCommandChordsGoToTheHostWhileCaptured() {
XCTAssertTrue(forwards(q, .command)) // Q the reported break
XCTAssertTrue(forwards(w, .command))
XCTAssertTrue(forwards(h, .command))
XCTAssertTrue(forwards(m, .command))
XCTAssertTrue(forwards(q, [.command, .shift])) // Q
XCTAssertTrue(forwards(m, [.command, .control, .option, .shift]))
}
func testTheEscapeHatchesAreNeverForwarded() {
// releases capture, F leaves fullscreen. Neither may ever reach the host.
XCTAssertFalse(forwards(esc, .command))
XCTAssertFalse(forwards(f, [.control, .command]))
XCTAssertTrue(InputCapture.isClientReservedChord(keyCode: esc, flags: .command))
XCTAssertTrue(
InputCapture.isClientReservedChord(keyCode: f, flags: [.control, .command]))
}
/// The reservation is exact: it is and F specifically, not "anything with Esc or F in
/// it". and F are the host's like any other chord.
func testNeighbouringChordsAreNotReserved() {
XCTAssertTrue(forwards(esc, [.command, .shift]))
XCTAssertTrue(forwards(f, .command))
XCTAssertFalse(InputCapture.isClientReservedChord(keyCode: f, flags: .command))
}
func testNothingWithoutCommandIsClaimedHere() {
// The family and bare keys reach the monitor's earlier blocks / the responder chain.
XCTAssertFalse(forwards(q, [.control, .option, .shift]))
XCTAssertFalse(forwards(q, []))
XCTAssertFalse(forwards(esc, []))
}
func testReleasedCaptureLeavesTheMenuAlone() {
// Not forwarding = the user is in the local UI: Q must quit the app, W close the window.
XCTAssertFalse(forwards(q, .command, forwarding: false))
XCTAssertFalse(forwards(w, .command, forwarding: false))
}
func testTheCrossClientSettingTurnsItOff() {
XCTAssertFalse(forwards(q, .command, inhibit: false))
}
func testTheDesktopMouseModelKeepsChordsLocal() {
// Matches the SDL clients' keyboard grab: a remote desktop is something you Tab away from.
XCTAssertFalse(forwards(q, .command, desktop: true))
XCTAssertFalse(forwards(q, .command, inhibit: true, desktop: true))
}
/// `deviceIndependentFlagsMask` also carries Caps Lock and the `.function`/`.numericPad` bits
/// every arrow key sets, so comparing it for equality made chords stop being recognized in
/// exactly the states a user does not connect to their keyboard: Caps Lock on, or the chord
/// spelled with an arrow. `chordFlags` isolates the four real modifiers.
func testCapsLockAndArrowBitsDoNotChangeAChord() throws {
let capsQ = try XCTUnwrap(keyEvent(q, [.command, .capsLock]))
XCTAssertEqual(InputCapture.chordFlags(capsQ), .command)
XCTAssertTrue(forwards(q, InputCapture.chordFlags(capsQ)))
// with Caps Lock on is still the escape hatch, not a chord for the host.
let capsEsc = try XCTUnwrap(keyEvent(esc, [.command, .capsLock]))
XCTAssertEqual(InputCapture.chordFlags(capsEsc), .command)
XCTAssertFalse(forwards(esc, InputCapture.chordFlags(capsEsc)))
// arrows set .function|.numericPad, which say nothing about the chord.
let cmdLeft = try XCTUnwrap(keyEvent(leftArrow, [.command, .function, .numericPad]))
XCTAssertEqual(InputCapture.chordFlags(cmdLeft), .command)
XCTAssertTrue(forwards(leftArrow, InputCapture.chordFlags(cmdLeft)))
}
/// A forwarded chord is only useful if the key has a host VK the monitor swallows either
/// way, so an unmapped one would silently do nothing. Spot-check the common letters.
func testTheCommonChordKeysMapToHostVKs() {
XCTAssertEqual(InputCapture.keyCodeToVK[q], 0x51) // VK 'Q'
XCTAssertEqual(InputCapture.keyCodeToVK[w], 0x57) // VK 'W'
XCTAssertEqual(InputCapture.keyCodeToVK[h], 0x48) // VK 'H'
XCTAssertEqual(InputCapture.keyCodeToVK[m], 0x4D) // VK 'M'
XCTAssertEqual(InputCapture.keyCodeToVK[leftArrow], 0x25) // VK_LEFT
}
private func keyEvent(_ keyCode: UInt16, _ flags: NSEvent.ModifierFlags) -> NSEvent? {
NSEvent.keyEvent(
with: .keyDown, location: .zero, modifierFlags: flags, timestamp: 0,
windowNumber: 0, context: nil, characters: "", charactersIgnoringModifiers: "",
isARepeat: false, keyCode: keyCode)
}
}
#endif
+16 -4
View File
@@ -45,7 +45,7 @@ BUNDLE_ID="io.unom.punktfunk"
# The App Store set, in listing order — the first three are what most people ever see, so they are
# the stream itself, the machines it found, and the couch/controller mode. Everything else in
# ShotScenes.all is a dev scene; capture those with `SCENES="06-gamepad-home 10-edithost" ...`.
SCENES=(${SCENES:-01-stream 02-hosts 06-gamepad-home 09e-waking-modal 05-settings 03-pair})
SCENES=(${SCENES:-01-stream 02-hosts 11-library 12-controllers 06-gamepad-home 09e-waking-modal 05-settings 03-pair})
SETTLE="${SETTLE:-4}" # seconds to let a scene lay out before capturing
mkdir -p "$OUT"
@@ -63,9 +63,13 @@ require_xcode() {
# ---------------------------------------------------------------------------- macOS
shoot_macos() {
log "macOS — building (swift build -c release)…"
swift build -c release >/dev/null
local bin=".build/release/PunktfunkClient"
# DEBUG build, deliberately: the whole shot harness lives behind `#if DEBUG`
# (ScreenshotHost/ScreenshotScenes), so a release binary launches as the NORMAL app, never
# prints PF_SHOT_WINDOW, and every scene "never reported a window". Debug renders the same
# pixels — SwiftUI has no release-only visuals.
log "macOS — building (swift build)…"
swift build >/dev/null
local bin=".build/debug/PunktfunkClient"
[ -x "$bin" ] || die "build produced no $bin"
for scene in "${SCENES[@]}"; do
@@ -142,6 +146,14 @@ shoot_sim() {
# incremental build instead of cold-building into a throwaway tmpdir — CI pins this
# (apple.yml); local runs keep the self-cleaning mktemp default.
local dd; dd="${PF_SHOT_DERIVED_DATA:-$(mktemp -d)}"; mkdir -p "$dd"
# tvOS-SIMULATOR trap (Xcode 26.6 and the 27 beta, local only so far): the build planner
# schedules the SwiftPM MACRO plugin targets that swiftui-navigation-transitions pulls in
# (OnceMacro/SwizzlingMacro/AssociationMacro) for the *tvOS* triple and never plans their
# swift-syntax dependencies at all — "unable to resolve module dependency: 'SwiftSyntax'".
# Device archives and iOS builds don't hit it (only the tvOS target links that package), and
# prebuilt-vs-source swift-syntax makes no difference. Until Xcode fixes the planner, the
# workaround is temporarily unlinking SwiftUINavigationTransitions from the tvOS target
# (HomeView's use is canImport-guarded — the push transition degrades to the crossfade).
xcodebuild -project Punktfunk.xcodeproj -scheme "$scheme" -configuration Debug \
-sdk "$sdk" -destination "id=$udid" -derivedDataPath "$dd" \
CODE_SIGNING_ALLOWED=NO build >/dev/null \
+3 -1
View File
@@ -796,7 +796,9 @@ from the config directory for a true factory reset."
);
return NEEDS_INTERACTION;
}
match library::fetch_games(&host.addr, library::DEFAULT_MGMT_PORT, &identity, pin) {
// The port this host actually serves its library on — learned from its advert and saved,
// falling back to 47990. Reaching for the constant here is what broke a moved port.
match library::fetch_games(&host.addr, host.effective_mgmt_port(), &identity, pin) {
Ok(games) => {
if has(args, "--json") {
let rows: Vec<serde_json::Value> = games
+1
View File
@@ -50,6 +50,7 @@ fn plan_for(req: &ConnectRequest, fp_hex: &str, tofu: bool, opts: &SpawnOpts) ->
fp_hex: Some(fp_hex.to_string()),
mac: req.mac.clone(),
id: None,
mgmt_port: None, // this shell resolves the library port itself (`mgmt_port_for`)
},
req.launch.as_ref().map(|(id, _)| id.clone()),
// A plain card click carries no one-off: the resolver honors the host's own binding
+36 -9
View File
@@ -1108,6 +1108,18 @@ impl HostsPage {
{
crate::trust::learn_os(&k.fp_hex, &k.addr, k.port, &a.os);
}
// Same for its management port — and this one is not cosmetic: without it a host
// that moved off 47990 loses its library the moment mDNS is unavailable, because
// the advert was the only place the real port ever lived.
if let Some(a) = self
.adverts
.values()
.find(|a| matches(k, a) && a.mgmt_port.is_some())
{
if let Some(p) = a.mgmt_port {
crate::trust::learn_mgmt_port(&k.fp_hex, &k.addr, k.port, p);
}
}
saved.push_back(HostCard {
connecting: self.connecting.as_deref() == Some(k.fp_hex.as_str()),
kind: CardKind::Saved {
@@ -1183,18 +1195,33 @@ impl HostsPage {
});
}
/// The advertised mgmt port for the host `req` points at, when a matching live
/// advert carries the `mgmt` TXT.
/// The mgmt port for the host `req` points at: a matching live advert's `mgmt` TXT first,
/// else the port a previous advert taught us and we saved on the host record.
///
/// The saved rung is not redundant. Reading the advert alone meant a host that had moved its
/// mgmt port off 47990 served its library on the LAN and nowhere else — over a VPN, a routed
/// subnet, or any multicast-dead network there is no advert to read, and the fallback silently
/// went back to a port nothing was listening on. `None` here still means "assume the default".
fn mgmt_port_for(&self, req: &ConnectRequest) -> Option<u16> {
self.adverts
let matches_req = |fp: &str, addr: &str, port: u16| {
req.fp_hex
.as_deref()
.is_some_and(|want| !fp.is_empty() && fp == want)
|| (addr == req.addr && port == req.port)
};
if let Some(p) = self
.adverts
.values()
.find(|a| {
req.fp_hex
.as_deref()
.is_some_and(|fp| !a.fp_hex.is_empty() && a.fp_hex == fp)
|| (a.addr == req.addr && a.port == req.port)
})
.find(|a| matches_req(&a.fp_hex, &a.addr, a.port))
.and_then(|a| a.mgmt_port)
{
return Some(p);
}
crate::trust::KnownHosts::load()
.hosts
.iter()
.find(|h| matches_req(&h.fp_hex, &h.addr, h.port))
.and_then(|h| h.mgmt_port)
}
/// Rename a saved host — an entry in an alert, then upsert + refresh.
+18 -1
View File
@@ -73,8 +73,11 @@ pub fn run(target: Option<&str>) -> u8 {
paired: k.is_some_and(|h| h.paired) || fake,
saved: k.is_some(),
online: false,
// Explicit --mgmt wins; else the port this host's advert taught us and we saved;
// else 47990. The middle rung is what survives mDNS being unavailable later.
mgmt_port: arg_value("--mgmt")
.and_then(|p| p.parse().ok())
.or_else(|| k.and_then(|h| h.mgmt_port))
.unwrap_or(library::DEFAULT_MGMT_PORT),
can_wake: false,
last_used: k.and_then(|h| h.last_used),
@@ -181,7 +184,7 @@ pub fn run(target: Option<&str>) -> u8 {
vsync: settings_at_start.vsync,
allow_vrr: settings_at_start.allow_vrr,
json_status,
on_connected: Some(Box::new(move |fingerprint: [u8; 32]| {
on_connected: Some(Box::new(move |fingerprint: [u8; 32], mgmt_port: u16| {
let fp_hex = trust::hex(&fingerprint);
trust::touch_last_used(&fp_hex);
// A request-access connect just succeeded → the operator approved us. Save the
@@ -191,6 +194,10 @@ pub fn run(target: Option<&str>) -> u8 {
trust::persist_host(&p.name, &p.addr, p.port, &fp_hex, true);
}
}
// Where this host serves its library, from the session's own Welcome — recorded
// AFTER the persist above so a host saved by this very connect gets it too. `0` =
// the host advertised none, and the call is a no-op.
trust::learn_mgmt_port_by_fp(&fp_hex, mgmt_port);
})),
overlay: Some(Box::new(overlay)),
window_size: crate::session_main::window_size(&settings_at_start),
@@ -682,6 +689,12 @@ impl ServiceState {
|| (d.addr == h.addr && d.port == h.port)
});
let online = advert.is_some() || probed.get(&key).copied().unwrap_or(false);
// Write the advertised mgmt port down while the host is visible, so this console
// keeps working against a moved port once it is not. No-op (and no disk write)
// when unchanged, so this is safe on every refresh tick.
if let Some(p) = advert.and_then(|d| d.mgmt_port) {
pf_client_core::trust::learn_mgmt_port(&h.fp_hex, &h.addr, h.port, p);
}
let row = HostRow {
key: key.clone(),
name: host_display_name(&h.name, &h.addr),
@@ -691,8 +704,12 @@ impl ServiceState {
paired: h.paired,
saved: true,
online,
// Live advert first, then what we saved from an earlier one, then 47990 —
// the same three rungs `os` uses just below. Reading the advert ALONE is why
// a host on a moved mgmt port lost its library the moment mDNS went quiet.
mgmt_port: advert
.and_then(|d| d.mgmt_port)
.or(h.mgmt_port)
.unwrap_or(library::DEFAULT_MGMT_PORT),
can_wake: !online && !h.mac.is_empty(),
last_used: h.last_used,
+9 -2
View File
@@ -986,9 +986,16 @@ mod session_main {
vsync: settings.vsync,
allow_vrr: settings.allow_vrr,
json_status: true,
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
on_connected: Some(Box::new(|fingerprint: [u8; 32], mgmt_port: u16| {
let fp = trust::hex(&fingerprint);
// This host's card carries the accent bar in the desktop client now.
trust::touch_last_used(&trust::hex(&fingerprint));
trust::touch_last_used(&fp);
// Save where this host serves its library, learned from the session's own
// Welcome rather than an mDNS advert — so it keeps working on a network where
// discovery never does. `0` = the host advertised none; leave what we have.
if mgmt_port != 0 {
trust::learn_mgmt_port_by_fp(&fp, mgmt_port);
}
})),
// The Skia console UI (stats OSD, capture HUD) — compiled out of the
// power-user build (`--no-default-features` drops the `ui` feature).
+8 -2
View File
@@ -3,8 +3,14 @@
MSIX package manifest for the punktfunk Windows client (WinUI 3 via windows-reactor).
This is a TEMPLATE: packaging/pack-msix.ps1 substitutes {VERSION} (4-part numeric, e.g.
0.2.137.0) and {PUBLISHER} (must EXACTLY equal the signing cert's subject DN — default
`CN=unom` for the self-signed CI cert; a real code-signing cert just passes its own subject).
0.2.137.0) and {PUBLISHER} (must EXACTLY equal the signing cert's subject DN — the default is
the verified subject of the Azure `unom-io` certificate profile; the self-signed fallback mints
a throwaway cert with that same subject so canary and release share a package identity).
Package identity is Name + Publisher, so changing {PUBLISHER} makes this a DIFFERENT package:
installs of the older publisher cannot be upgraded in place and must be uninstalled first. That
is a user-visible migration, not a packaging detail — mention it in the release notes. pack-msix.ps1
reads the signature back off the packed .msix and fails the build if the two ever drift.
Why this packages cleanly even though the app was built "unpackaged": windows-reactor calls
MddBootstrapInitialize2 with OnPackageIdentity_NOOP (crates/libs/reactor/src/app.rs), so under
+33 -20
View File
@@ -12,7 +12,8 @@ x64 Windows runner — `x86_64-pc-windows-msvc` builds natively, `aarch64-pc-win
cross-compiled (the x64 MSVC toolset ships the ARM64 cross compiler; since M10 nothing in the
package links FFmpeg, so neither arch needs a per-arch `FFMPEG_DIR` tree staged on the runner —
one less thing the ARM64 leg can be missing). Artifacts are arch-suffixed
(`..._x64.msix` / `..._arm64.msix`, each with its matching `.cer`); `pack-msix.ps1 -Arch x64|arm64`
(`..._x64.msix` / `..._arm64.msix`, plus a matching `.cer` only in the fallback signing modes 2 and 3
— Azure signing emits none); `pack-msix.ps1 -Arch x64|arm64`
stamps the manifest `ProcessorArchitecture` and names the output. See
[`windows-client.yml`](../../../.gitea/workflows/windows-client.yml) for the cross-build rationale.
@@ -52,38 +53,50 @@ low-level input hooks, WASAPI and SDL3.
MSIX requires a strictly 4-part numeric version. The workflow computes:
- `vX.Y.Z` tag → `X.Y.Z.0` (THE release; any `-rc`/`+meta` suffix is dropped for MSIX). Published to
the stable `latest/` alias and attached to the unified Gitea Release.
- `main` push / `workflow_dispatch``0.3.<run_number>.0` (canary, climbs by run number; `canary/` alias).
- `main` push / `workflow_dispatch``X.<Y+1>.<run_number>.0` (canary — the minor *after* the latest
`v*` tag, per `scripts/ci/pf-version.ps1`, climbing by run number; `canary/` alias).
## Signing & install
CI signs every build with a **stable self-signed code-signing cert** (`CN=unom`, SHA-1
`CD1EFDEEEC9743AFC38F56C5AF30C5A3009BE941`, valid to 2036). Its public half is checked in as
[`punktfunk-codesign.cer`](punktfunk-codesign.cer); the private `.pfx` + password live in the
`MSIX_CERT_PFX_B64` / `MSIX_CERT_PASSWORD` Actions secrets. Because it's the *same* cert every build,
trusting it is **one-time, per machine** — once imported, every future build and in-place upgrade is
trusted with no further prompt:
CI signs every build with **Azure Artifact Signing** (formerly Trusted Signing) — account
`unomsigning`, certificate profile `unom-io`, endpoint `https://neu.codesigning.azure.net/`. That
chain is publicly trusted, so **there is nothing to import**:
```powershell
# once per machine (elevated): trust the publisher
Import-Certificate -FilePath .\punktfunk-codesign.cer -CertStoreLocation Cert:\LocalMachine\TrustedPeople
# then install the package for your CPU (and re-run for each upgrade — no re-trust needed)
# install the package for your CPU (and re-run for each upgrade)
Add-AppxPackage -Path .\punktfunk-client-windows_<ver>_x64.msix # Intel/AMD
Add-AppxPackage -Path .\punktfunk-client-windows_<ver>_arm64.msix # ARM64 (Snapdragon, etc.)
```
The matching `.cer` is also published next to each `.msix` in the registry, so it's always at hand.
The MSIX declares a dependency on the Windows App SDK 2.x runtime; install
[the App SDK runtime](https://aka.ms/windowsappsdk) if `Add-AppxPackage` reports a missing
`Microsoft.WindowsAppRuntime.2` framework.
`pack-msix.ps1` signing precedence: it uses the **`MSIX_CERT_PFX_B64` / `MSIX_CERT_PASSWORD`** secrets
when present (the stable cert above), else generates an *ephemeral* self-signed cert (forks / local
builds without the secrets). Either way it exports the signing cert's public `.cer` for the import.
**To move to a publicly-trusted (no-import) cert** — Azure Artifact Signing or a public OV cert —
replace the two secrets with the new `.pfx`; the cert's subject DN must equal the manifest
`Publisher`, so pass a matching `-Publisher` (it's stamped into the package `Identity`, and changing
it changes the package identity → a one-time reinstall).
### How signing resolves
`pack-msix.ps1` picks a backend in this order:
1. **Azure Artifact Signing** when `AZURE_CODESIGNING_ENDPOINT` / `_ACCOUNT` / `_PROFILE` are all
set (the workflow sets them; they aren't secret). Credentials come from `AZURE_TENANT_ID` /
`AZURE_CLIENT_ID` / `AZURE_CLIENT_SECRET` — the `punktfunk-ci-signing` service principal, which
holds only the *Artifact Signing Certificate Profile Signer* role scoped to the `unom-io` profile.
Keys are HSM-backed and never leave Azure, so there is no `.pfx` and no `.cer` is emitted.
2. **`MSIX_CERT_PFX_B64` / `MSIX_CERT_PASSWORD`** — the older stable self-signed cert (`CN=unom`,
public half checked in as [`punktfunk-codesign.cer`](punktfunk-codesign.cer)), kept as a fallback.
3. An **ephemeral** self-signed cert (forks / local builds with no secrets at all).
Modes 2 and 3 still export a `.cer` to import into `Cert:\LocalMachine\TrustedPeople` first. On a
`v*` tag, a build with no real signing backend **fails closed** rather than shipping a throwaway.
Two things about Azure mode that are easy to get wrong:
- **Timestamping is mandatory, not best-effort.** Azure mints a leaf cert per request that expires in
about three days. An untimestamped signature therefore stops verifying within days of release, so
the script refuses to retry without one (modes 2 and 3 keep the old best-effort retry).
- **The manifest `Publisher` must equal the signer's subject exactly**, because MSIX package identity
is Name + Publisher. The default `-Publisher` is the `unom-io` profile's verified subject; after
signing, the script reads the signature back off the `.msix` and fails the build on any drift.
Changing it makes a *different* package — existing installs must be uninstalled, not upgraded.
## Building locally
+130 -23
View File
@@ -13,15 +13,22 @@
packaging/windows/pack-host-installer.ps1 still ships them for its amf-qsv encode path.
Signing cert precedence:
0. Azure Artifact Signing (formerly Trusted Signing) when AZURE_CODESIGNING_ENDPOINT/_ACCOUNT/
_PROFILE are all set. HSM-backed, so there is no .pfx and nothing to export: the chain is
publicly trusted, so no .cer is produced and MSIX_CER_PATH stays unset.
1. -PfxBase64 / -PfxPassword (a real or shared code-signing cert, e.g. from CI secrets) the
cert's subject DN MUST match -Publisher (which is stamped into the manifest Identity).
2. otherwise an EPHEMERAL self-signed code-signing cert with subject = -Publisher is generated
in-process. The package installs only where that cert is trusted, so the matching public
.cer is exported next to the .msix for the user to import (Trusted People) before install.
Swap in a real cert later with zero manifest changes just pass -PfxBase64/-Publisher.
This fallback is for canary/CI/dev ONLY: on a v* tag build a missing cert is a hard failure
(-RequireSignedCert), never a silent downgrade to a throwaway cert.
WHICHEVER mode runs, the signed .msix is read back and its signer subject compared to -Publisher;
a mismatch fails the build. MSIX package identity is Name + Publisher, so a publisher that does
not match the signer is not a cosmetic problem Add-AppxPackage rejects the package outright,
and it would only be discovered by a user trying to install the release.
Run on the Windows runner (or the dev VM) with the MSVC/Windows SDK present.
.EXAMPLE
@@ -36,9 +43,21 @@ param(
[Parameter(Mandatory = $true)][string]$TargetDir, # cargo --release output dir (has the exe)
[ValidateSet('x64', 'arm64')][string]$Arch = 'x64', # package ProcessorArchitecture + artifact suffix
[string]$OutDir = (Join-Path $TargetDir 'msix'),
[string]$Publisher = 'CN=unom', # MUST equal the signing cert subject DN
# MUST equal the signing cert subject DN — this is the verified subject the Azure 'unom-io'
# certificate profile issues. The 'ü' is written as an escape, not a literal: this file is UTF-8
# with no BOM, and read by anything other than pwsh 7 a literal would silently mojibake into a
# publisher that no longer matches the signer, which surfaces only as an Add-AppxPackage refusal
# on a user's machine. Verified against the real signer after signing below.
[string]$Publisher = "CN=unom - Enrico B$([char]0xFC)hler, O=unom - Enrico B$([char]0xFC)hler, L=Rottweil, S=Baden-W$([char]0xFC)rttemberg, C=DE",
[string]$PfxBase64 = $env:MSIX_CERT_PFX_B64, # optional: base64 of a code-signing .pfx
[string]$PfxPassword = $env:MSIX_CERT_PASSWORD,
# Azure Artifact Signing. All three select it, ahead of any .pfx. Credentials arrive through the
# environment via DefaultAzureCredential (AZURE_TENANT_ID / AZURE_CLIENT_ID / AZURE_CLIENT_SECRET)
# rather than as arguments, so they cannot leak into a process listing or a transcript.
[string]$AzureEndpoint = $env:AZURE_CODESIGNING_ENDPOINT, # e.g. https://neu.codesigning.azure.net/
[string]$AzureAccount = $env:AZURE_CODESIGNING_ACCOUNT, # signing account name
[string]$AzureProfile = $env:AZURE_CODESIGNING_PROFILE, # certificate profile name
[string]$AzureDlib = $env:AZURE_CODESIGNING_DLIB, # path to Azure.CodeSigning.Dlib.dll
# 'auto' (default) = required iff this is a v* tag build; 'true'/'false' to force. See below.
[ValidateSet('auto', 'true', 'false')][string]$RequireSignedCert = 'auto'
)
@@ -64,6 +83,28 @@ function Find-SdkTool([string]$name) {
if (-not $hit) { throw "$name not found under $root — install the Windows 10/11 SDK." }
$hit.FullName
}
# Azure.CodeSigning.Dlib.dll ships in the Microsoft.Trusted.Signing.Client NuGet package, which has
# no installer and no fixed location — hence an explicit override first, then the paths the runner
# setup uses (packaging/windows/README.md). Newest wins, so a package update needs no edit here.
function Find-AzureDlib([string]$Explicit) {
if ($Explicit) {
if (-not (Test-Path $Explicit)) { throw "AZURE_CODESIGNING_DLIB points at a missing file: $Explicit" }
return (Resolve-Path $Explicit).Path
}
$roots = @(
(Join-Path $env:USERPROFILE '.nuget\packages\microsoft.trusted.signing.client'),
'C:\trusted-signing\microsoft.trusted.signing.client'
) | Where-Object { $_ -and (Test-Path $_) }
$hit = $roots | ForEach-Object { Get-ChildItem -Path $_ -Recurse -Filter 'Azure.CodeSigning.Dlib.dll' -ErrorAction SilentlyContinue } |
Where-Object { $_.FullName -match '\\bin\\x64\\' } |
Sort-Object LastWriteTime | Select-Object -Last 1
if (-not $hit) {
throw ("Azure.CodeSigning.Dlib.dll not found. Install the signing client on this box, e.g. " +
"``nuget install Microsoft.Trusted.Signing.Client -OutputDirectory " +
"`$env:USERPROFILE\.nuget\packages``, or set AZURE_CODESIGNING_DLIB to its full path.")
}
$hit.FullName
}
$makeappx = Find-SdkTool 'makeappx.exe'
$signtool = Find-SdkTool 'signtool.exe'
Write-Host "makeappx: $makeappx"
@@ -159,13 +200,34 @@ $requireCert = if ($RequireSignedCert -eq 'auto') { $env:GITHUB_REF -like 'refs/
else { [Convert]::ToBoolean($RequireSignedCert) }
$pfxPath = Join-Path $OutDir 'signing.pfx'
$cerPath = Join-Path $OutDir "punktfunk-client-windows_${Version}_${Arch}.cer"
if ($PfxBase64) {
$azureMetadata = Join-Path $OutDir 'azure-codesigning.json'
$signMode = 'selfsigned'
if ($AzureEndpoint -and $AzureAccount -and $AzureProfile) {
$signMode = 'azure'
$AzureDlib = Find-AzureDlib $AzureDlib
# signtool takes the account/profile from this file (/dmdf), not the command line.
@{
Endpoint = $AzureEndpoint
CodeSigningAccountName = $AzureAccount
CertificateProfileName = $AzureProfile
} | ConvertTo-Json | Set-Content -Path $azureMetadata -Encoding utf8
Write-Host "signing via Azure Artifact Signing: $AzureAccount/$AzureProfile at $AzureEndpoint"
Write-Host " dlib: $AzureDlib"
foreach ($v in 'AZURE_TENANT_ID', 'AZURE_CLIENT_ID', 'AZURE_CLIENT_SECRET') {
if (-not [Environment]::GetEnvironmentVariable($v)) {
throw ("Azure signing selected but $v is not set. The dlib authenticates with " +
"DefaultAzureCredential; without the service-principal trio it falls through to an " +
"interactive login that cannot complete on a runner and hangs the build.")
}
}
} elseif ($PfxBase64) {
$signMode = 'pfx'
Write-Host "signing with supplied code-signing cert (MSIX_CERT_PFX_B64)"
[IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($PfxBase64))
} elseif ($requireCert) {
throw ("release build ($env:GITHUB_REF) with no MSIX_CERT_PFX_B64 — refusing to fall back to an " +
"ephemeral self-signed cert. Restore the MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD repo " +
"secrets, or pass -RequireSignedCert false if this really is a test build.")
throw ("release build ($env:GITHUB_REF) with neither AZURE_CODESIGNING_* nor MSIX_CERT_PFX_B64 — " +
"refusing to fall back to an ephemeral self-signed cert. Restore the signing secrets " +
"(packaging/windows/README.md), or pass -RequireSignedCert false if this really is a test build.")
} else {
Write-Host "no MSIX_CERT_PFX_B64 -> generating an ephemeral self-signed cert (subject $Publisher)"
if (-not $PfxPassword) { $PfxPassword = 'punktfunk' }
@@ -178,35 +240,80 @@ if ($PfxBase64) {
Remove-Item "Cert:\CurrentUser\My\$($tmp.Thumbprint)" -Force
}
# Always export the public .cer from the pfx. For a self-signed / private-trust cert it's the file
# users import once (Trusted People) — a STABLE cert (same pfx every build via the secret) means that
# import is a one-time, per-machine step that keeps working across upgrades. For a public-CA cert
# it's just an unused extra (harmless). The manifest Publisher must equal the cert's subject DN.
$pwsec = if ($PfxPassword) { ConvertTo-SecureString -String $PfxPassword -Force -AsPlainText } else { $null }
$pubCert = if ($pwsec) { Get-PfxCertificate -FilePath $pfxPath -Password $pwsec } else { Get-PfxCertificate -FilePath $pfxPath }
Export-Certificate -Cert $pubCert -FilePath $cerPath | Out-Null
Write-Host "signing cert subject=$($pubCert.Subject) thumbprint=$($pubCert.Thumbprint)"
if ($pubCert.Subject -ne $Publisher) {
Write-Warning "cert subject '$($pubCert.Subject)' != manifest Publisher '$Publisher' — Add-AppxPackage will reject the mismatch. Pass -Publisher '$($pubCert.Subject)'."
# Export the public .cer from the pfx. For a self-signed / private-trust cert it's the file users
# import once (Trusted People) — a STABLE cert (same pfx every build via the secret) means that
# import is a one-time, per-machine step that keeps working across upgrades. Azure signing is
# HSM-backed: there is no pfx to read and its chain is publicly trusted, so no .cer is produced.
if ($signMode -ne 'azure') {
$pwsec = if ($PfxPassword) { ConvertTo-SecureString -String $PfxPassword -Force -AsPlainText } else { $null }
$pubCert = if ($pwsec) { Get-PfxCertificate -FilePath $pfxPath -Password $pwsec } else { Get-PfxCertificate -FilePath $pfxPath }
Export-Certificate -Cert $pubCert -FilePath $cerPath | Out-Null
Write-Host "signing cert subject=$($pubCert.Subject) thumbprint=$($pubCert.Thumbprint)"
}
# --- sign (timestamp best-effort) ---
$signArgs = @('sign', '/fd', 'SHA256', '/f', $pfxPath)
if ($PfxPassword) { $signArgs += @('/p', $PfxPassword) }
& $signtool ($signArgs + @('/tr', 'http://timestamp.digicert.com', '/td', 'SHA256', $msix))
# --- sign ---
# The timestamp is best-effort for a .pfx whose cert outlives the release, but MANDATORY under Azure
# signing: those leaf certs are minted per request and expire in ~3 days, so an untimestamped
# signature stops verifying within days of shipping. Retrying without one there would produce a
# package that installs on the runner and fails for every user that weekend — so the fallback is
# gated on the mode rather than applied blindly.
if ($signMode -eq 'azure') {
$signArgs = @('sign', '/fd', 'SHA256', '/dlib', $AzureDlib, '/dmdf', $azureMetadata)
$ts = 'http://timestamp.acs.microsoft.com'
} else {
$signArgs = @('sign', '/fd', 'SHA256', '/f', $pfxPath)
if ($PfxPassword) { $signArgs += @('/p', $PfxPassword) }
$ts = 'http://timestamp.digicert.com'
}
& $signtool ($signArgs + @('/tr', $ts, '/td', 'SHA256', $msix))
if ($LASTEXITCODE -ne 0) {
if ($signMode -eq 'azure') {
throw ("timestamped sign failed ($LASTEXITCODE) — NOT retrying without a timestamp. An Azure " +
"signing cert is valid for ~3 days; an untimestamped signature would go untrusted " +
"within days of release.")
}
Write-Warning "timestamped sign failed — retrying without a timestamp"
& $signtool ($signArgs + @($msix))
if ($LASTEXITCODE -ne 0) { throw "signtool sign failed ($LASTEXITCODE)" }
}
Remove-Item $pfxPath -Force -ErrorAction SilentlyContinue
Remove-Item $azureMetadata -Force -ErrorAction SilentlyContinue
# Read the signature back off the packed .msix and hold it against the manifest Publisher. MSIX
# package identity is Name + Publisher, so a publisher that doesn't match the signer isn't cosmetic:
# Add-AppxPackage refuses the package outright. Checking the ACTUAL signer (rather than a pfx we
# happen to hold) is the only form of this check that works in every signing mode, and failing the
# build here is the difference between a red pipeline and a release nobody can install.
# Deliberately asymmetric: a subject we CAN read and that DISAGREES is a hard failure, but a subject
# we cannot read at all is only a warning. Get-AuthenticodeSignature's support for the .msix/.appx
# subject interface varies by Windows version, and signtool has already reported success by this
# point — turning "the check could not run" into a build break would trade a real defect we catch for
# an imaginary one we invent.
$signerSubject = $null
try { $signerSubject = (Get-AuthenticodeSignature $msix).SignerCertificate.Subject } catch { }
if (-not $signerSubject) {
Write-Warning ("could not read a signer subject back from $msix, so Publisher/signer agreement is " +
"UNVERIFIED on this box. If the package is rejected at Add-AppxPackage time, compare " +
"`signtool verify /pa /v` against the manifest Publisher '$Publisher' by hand.")
} elseif ($signerSubject -ne $Publisher) {
throw ("signer subject does not match the manifest Publisher, so this package cannot install:`n" +
" signer : '$signerSubject'`n" +
" Publisher : '$Publisher'`n" +
"Pass -Publisher '$signerSubject' (or fix the certificate profile) and repack.")
} else {
Write-Host "verified signer subject matches manifest Publisher: $signerSubject"
}
Write-Host ""
Write-Host "==> MSIX: $msix"
Write-Host "==> trust the cert once per machine (then it stays trusted across all future builds):"
Write-Host " Import-Certificate -FilePath '$cerPath' -CertStoreLocation Cert:\LocalMachine\TrustedPeople"
if ($signMode -eq 'azure') {
Write-Host "==> signed by a publicly trusted CA — nothing for users to import."
} else {
Write-Host "==> trust the cert once per machine (then it stays trusted across all future builds):"
Write-Host " Import-Certificate -FilePath '$cerPath' -CertStoreLocation Cert:\LocalMachine\TrustedPeople"
}
# emit paths for the workflow to publish (only under CI, where GITHUB_ENV is set)
if ($env:GITHUB_ENV) {
"MSIX_PATH=$msix" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"MSIX_CER_PATH=$cerPath" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
if ($signMode -ne 'azure') { "MSIX_CER_PATH=$cerPath" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 }
}
+19
View File
@@ -691,6 +691,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
fp_hex: Some(k.fp_hex.clone()),
pair_optional: false,
mac: k.mac.clone(),
mgmt_port: k.mgmt_port,
profile: None,
launch: None,
};
@@ -715,6 +716,18 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
}) {
crate::trust::learn_os(&k.fp_hex, &k.addr, k.port, &a.os);
}
// Same for its management port — load-bearing, unlike the two above: a host moved off
// 47990 loses its library entirely once mDNS is gone unless we write the port down.
if let Some(p) = hosts
.iter()
.find(|h| {
(h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port))
&& h.mgmt_port.is_some()
})
.and_then(|h| h.mgmt_port)
{
crate::trust::learn_mgmt_port(&k.fp_hex, &k.addr, k.port, p);
}
let can_wake = !online && !k.mac.is_empty();
let menu = {
let (svc, target) = (props.svc.clone(), target.clone());
@@ -1046,6 +1059,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
fp_hex: (!h.fp_hex.is_empty()).then(|| h.fp_hex.clone()),
pair_optional: h.pair == "optional",
mac: h.mac.clone(),
mgmt_port: h.mgmt_port,
profile: None,
launch: None,
};
@@ -1140,6 +1154,11 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
fp_hex: None,
pair_optional: false,
mac: Vec::new(),
// Added by hand, so nothing has told us where its mgmt API is: fall back to
// 47990 (exactly today's behaviour) until an advert teaches us otherwise.
// A host that moved its mgmt port AND is never visible on mDNS still needs the
// host to announce the port in-band — see the note in `Target::mgmt_port`.
mgmt_port: None,
profile: None,
launch: None,
},
+5 -2
View File
@@ -104,7 +104,7 @@ pub(crate) fn start_fetch(ctx: &Arc<AppCtx>, set_library: &AsyncSetState<Library
let mut state = LibraryState::default();
let games = match library::fetch_games(
&target.addr,
library::DEFAULT_MGMT_PORT,
target.mgmt_port.unwrap_or(library::DEFAULT_MGMT_PORT),
&identity,
pin,
) {
@@ -120,7 +120,10 @@ pub(crate) fn start_fetch(ctx: &Arc<AppCtx>, set_library: &AsyncSetState<Library
}
// Seed cached posters; queue the art pipeline for the rest.
let base = library::base_url(&target.addr, library::DEFAULT_MGMT_PORT);
let base = library::base_url(
&target.addr,
target.mgmt_port.unwrap_or(library::DEFAULT_MGMT_PORT),
);
let cache = art_cache_dir();
let mut jobs: VecDeque<(String, Vec<String>)> = VecDeque::new();
for g in &games {
+9
View File
@@ -103,6 +103,11 @@ pub(crate) struct Target {
/// Wake-on-LAN MAC(s) for this host (from the saved store or the live advert) — used to send a
/// magic packet before connecting to an offline host. Empty when none is known.
pub(crate) mac: Vec<String>,
/// This host's management-API port (saved store or live advert), where the library screen
/// fetches from. `None` = unknown, use [`pf_client_core::library::DEFAULT_MGMT_PORT`]. Carried
/// on the target for the same reason as `mac`: the library screen has no `KnownHost` in hand,
/// and assuming 47990 there is what made a moved mgmt port work on the LAN but not over a VPN.
pub(crate) mgmt_port: Option<u16>,
/// A ONE-OFF settings profile for this connect ("Connect with"): `Some(id)` overrides the
/// host's binding for this launch, `Some("")` forces the global defaults on a bound host,
/// `None` honors the binding. It never rebinds anything — the default changes only through
@@ -406,6 +411,7 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
fp_hex: p.host.fp_hex.clone(),
pair_optional: false,
mac: p.host.mac.clone(),
mgmt_port: p.host.mgmt_port,
profile: p.profile_override.clone(),
launch: None, // routed explicitly below (initiate_launch*)
};
@@ -447,6 +453,9 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
fp_hex: u.fp.clone(),
pair_optional: false,
mac: Vec::new(),
// A link carries no mgmt port (nor a MAC), so this stays unknown until
// an advert teaches it — same fallback as the hand-added case.
mgmt_port: None,
profile: u.profile.clone(),
launch: u.launch.clone(),
};
+6
View File
@@ -28,6 +28,11 @@ pub struct DiscoveredHost {
/// `linux[/<family>][/<id>]`), sanitized — drives the host tile's OS mark and is
/// persisted like `mac`. Empty if absent (older host).
pub os: String,
/// The management API's port from the mDNS `mgmt` TXT — where the game library is served.
/// Persisted like `mac` (`trust::learn_mgmt_port`), and load-bearing rather than cosmetic:
/// a host moved off 47990 loses its library once mDNS is gone unless we write this down.
/// `None` if absent (older host) — resolve via `library::DEFAULT_MGMT_PORT`.
pub mgmt_port: Option<u16>,
}
/// Forces the running browse to re-query now — the hosts page's Refresh. Mirrors
@@ -124,6 +129,7 @@ pub fn browse() -> (async_channel::Receiver<DiscoveredHost>, Rescan) {
.filter(|s| !s.is_empty())
.collect(),
os: pf_client_core::os::sanitize_os(&val("os")),
mgmt_port: val("mgmt").parse().ok(),
};
if tx.send_blocking(host).is_err() {
break; // UI gone — stop browsing
+1
View File
@@ -160,6 +160,7 @@ pub(crate) fn spawn_session(
fp_hex: Some(fp_hex.to_string()),
mac: Vec::new(), // wake ran before this spawn (initiate_waking) — not the plan's job
id: None,
mgmt_port: None, // the library fetch runs in the shell (`Target`), never off a spawn plan
},
launch.map(str::to_string),
profile.map(str::to_string),
+2 -2
View File
@@ -8,6 +8,6 @@
//! still load via a serde alias in core.
pub use pf_client_core::trust::{
hex, learn_mac, learn_os, load_or_create_identity, pair_error_message, parse_hex32, KnownHost,
KnownHosts, Settings,
hex, learn_mac, learn_mgmt_port, learn_os, load_or_create_identity, pair_error_message,
parse_hex32, KnownHost, KnownHosts, Settings,
};
+9 -1
View File
@@ -62,10 +62,18 @@
{
"type": "application",
"name": "punktfunk-gamescope",
"version": "upstream gamescope pinned by packaging/nix/gamescope.nix (nixpkgs) or built by packaging/gamescope/build-punktfunk-gamescope.sh, plus 3 local patches from packaging/gamescope/patches/",
"version": "upstream gamescope pinned by packaging/nix/gamescope.nix (nixpkgs) or built by packaging/gamescope/build-punktfunk-gamescope.sh, plus the local patch series from packaging/gamescope/patches/",
"description": "Patched gamescope compositor distributed via sysext/Arch/nix channels alongside the host",
"licenses": [{ "license": { "id": "BSD-2-Clause" } }],
"externalReferences": [{ "type": "vcs", "url": "https://github.com/ValveSoftware/gamescope" }]
},
{
"type": "application",
"name": "Bun",
"version": "1.3.14 (pinned in .gitea/workflows/windows-host.yml)",
"description": "Portable JavaScript runtime bundled in the Windows host installer to run the web console (.output) and the plugin/script runner. Embeds JavaScriptCore (LGPL-2.1).",
"licenses": [{ "license": { "id": "MIT" } }],
"externalReferences": [{ "type": "vcs", "url": "https://github.com/oven-sh/bun" }]
}
]
}
+51
View File
@@ -0,0 +1,51 @@
# Vendored & bundled components — CVE watch and update cadence
Due-diligence record for every third-party component that ships with Punktfunk but is
**not** tracked by a package manager's advisory feed (CRA Art. 13(5); Annex I Part II §1).
Everything resolved through Cargo/bun/pnpm lockfiles is already scanned weekly by
`.gitea/workflows/audit.yml` (cargo-audit against RustSec, bun/pnpm audit) — this file
covers what those scanners cannot see: vendored source trees, git-rev pins, and binaries
staged into installers. The component inventory itself lives in
`compliance/sbom/manual-components.cdx.json` and is merged into every release SBOM;
keep the two files in sync when a component is added, removed, or re-pinned.
Owner for all of it: Enrico (sole maintainer). Standing cadence: **walk this table once
per quarter and before every stable release**; act immediately on any advisory from the
watch feeds below.
| Component | Where / pin | How to update | Watch |
|---|---|---|---|
| **pyrowave** (+ Granite, volk, Vulkan-Headers subtree) | `crates/pyrowave-sys/vendor/pyrowave`, pin = `PYROWAVE_COMMIT` in `scripts/vendor-pyrowave.sh`; exact commits recorded in `vendor/pyrowave/PUNKTFUNK-VENDOR.txt` | Bump the commit in the script, re-run it (network required; never from CI), re-apply `crates/pyrowave-sys/patches/`. ⚠️ **Bitstream changes are protocol-affecting** — the wire bit means "PyroWave as of this pin"; a bitstream-changing bump must bump the protocol version and re-diff the Apple Metal hand-port (see the script header). | GitHub releases/commits of Themaister/pyrowave + Themaister/Granite (niche projects, no CVE feed — repo watch is the feed) |
| **libvpl** 2.17.0 | `crates/libvpl-sys/vendor/libvpl` (dispatcher statically linked; needs cmake + libclang) | Manual re-vendor from intel/libvpl at the new tag; rebuild `libvpl-sys` | Intel Security Center (INTEL-SA advisories for oneVPL/media) + intel/libvpl releases |
| **windows-rs** git pin | `rev = acb5a1a7…` on microsoft/windows-rs (workspace `[patch]`/git deps: `windows`, `windows-reactor`, …) | Move the rev / return to crates.io once the needed fixes are released. Note: cargo-audit matches these by name+version from Cargo.lock, but a pre-release rev may not map cleanly onto RustSec advisories — treat the pin itself as the thing to retire. | RustSec (already weekly) + microsoft/windows-rs releases |
| **usbfs-iso / uac-host** git pin | `rev = f3de1fd…` on unom-io/usbfs-iso | First-party fork — we are upstream; fix in the fork, move the rev | Own repo (issues land in our tracker) |
| **FFmpeg** (host encode only) | Linux: system `libav*` (distro-updated, not ours to patch — but Arch soname majors can break us, see ffmpeg9 note). Windows: AMF/QSV shared DLLs staged from `FFMPEG_DIR` by `pack-host-installer.ps1`; LGPL notice bundled | Windows: rebuild/refresh the staged DLL set, ship in the next installer. Linux: nothing to ship; verify against new distro majors | ffmpeg-security announcements (ffmpeg.org security page) — a libav* CVE in decode/parse paths we use ⇒ refresh the Windows DLLs without undue delay |
| **SDL3** | Desktop clients, dynamically linked; system-provided or bundled per platform package | Bump the bundled copy in the affected package; system copies are distro-updated | libsdl-org/SDL GitHub security advisories + releases |
| **gamescope** + patch series | Pin in `packaging/nix/gamescope.nix` / built by `packaging/gamescope/build-punktfunk-gamescope.sh`; local patches in `packaging/gamescope/patches/` | Bump the pin, re-rebase the patch series, rebuild sysext/Arch/nix + .deb channels. ⚠️ the gamescope CI legs are best-effort: a broken patch shows up as a *missing package*, not a red build | ValveSoftware/gamescope releases + security advisories |
| **Bun runtime** 1.3.14 | Pinned in `.gitea/workflows/windows-host.yml` (`bun-v1.3.14`); bundled portable in the Windows host installer to run the web console + plugin runner. Embeds JavaScriptCore | Bump the version string in the workflow; next installer build picks it up | oven-sh/bun releases (security notes ride in release notes) |
Not on this list on purpose:
- **VB-CABLE** — no longer bundled (audio-substrate program, 2026-08; the host mints its
own virtual audio devices). If it ever returns, it returns to this table first.
- **openh264 / rav1d CPU decode floor** — crates.io dependencies with vendored C/asm
inside the `-sys` crates; cargo-audit tracks the crate advisories, and the upstream
(Cisco openh264, memorysafety/rav1d) security feeds surface through RustSec. No
separate manual watch needed unless we pin them to git.
## Security-update availability (CRA: ≥10 years)
Where users fetch fixes, and why old artifacts don't vanish (verified 2026-08-14):
- **Gitea releases + package registries** (git.unom.io): no cleanup rules configured,
and Gitea does not expire releases or packages on its own — the full release history
(v0.17.x through current) is still served with assets. Blobs live in the `unom-git`
S3 bucket with an R2 mirror, and the box is restic-backed every 6 h. Old release
assets (and their `.sha256` sidecars) therefore stay downloadable.
- **Bazzite sysext feeds**: stable channels publish with `KEEP=0` (keep everything);
only canary channels prune (`KEEP=6`) — see `rpm.yml` + `publish-sysext-feed.sh`.
- **Flatpak repo** (flatpak.unom.io): published by rsync *without* `--delete`; old
OSTree commits accumulate, both channels stay in the signed summary.
- **Policy**: never add cleanup that deletes *security* releases; if storage pressure
ever forces pruning, prune canary builds, never tagged stable releases. SBOMs are
release assets, so the ≥10-year SBOM retention rides on the same guarantee.
+62 -22
View File
@@ -1450,16 +1450,21 @@ pub fn pipewire_thread(
RGB CSC; PUNKTFUNK_PIPEWIRE_NV12=0 restores the packed-RGB negotiation)"
);
}
// Modifiers our import stack handles for BGRx: the EGL-importable (tiled) set, plus LINEAR
// (0) — NVIDIA's EGL won't list it, but LINEAR dmabufs (gamescope's only offer) import via
// CUDA external memory instead. For the VAAPI passthrough path we advertise LINEAR only:
// radeonsi/iHD import it and any compositor can allocate it.
let mut modifiers = importer
.as_mut()
.map(|i| i.supported_modifiers(pf_frame::drm_fourcc(PixelFormat::Bgrx).unwrap()))
.unwrap_or_default();
if (importer.is_some() || vaapi_passthrough) && !modifiers.contains(&0) {
modifiers.push(0); // DRM_FORMAT_MOD_LINEAR
// Modifiers our import stack handles, enumerated PER FOURCC. `XR24` (BGRx) and `AR24` (BGRA)
// are asked separately on purpose: EGL/libva answer per format, and nothing entitles us to
// assume a driver that imports one imports the other. Keeping them apart is also what makes
// the BGRA pod below correct on AMD and Intel rather than an NVIDIA-shaped guess — each list
// is whatever THIS GPU's stack actually said.
//
// To each list we add LINEAR (0) — NVIDIA's EGL won't list it, but LINEAR dmabufs (gamescope's
// only offer) import via CUDA external memory instead. For the VAAPI passthrough path there is
// no importer at all, so the lists start empty and LINEAR is all we advertise: radeonsi/iHD
// import it and any compositor can allocate it.
let mut modifiers = Vec::new();
let mut modifiers_bgra = Vec::new();
if let Some(i) = importer.as_mut() {
modifiers = i.supported_modifiers(pf_frame::drm_fourcc(PixelFormat::Bgrx).unwrap());
modifiers_bgra = i.supported_modifiers(pf_frame::drm_fourcc(PixelFormat::Bgra).unwrap());
}
// PyroWave passthrough: the encoder imports through Vulkan, not libva — extend the
// advertisement with every modifier its device samples from, so compositors that
@@ -1468,12 +1473,20 @@ pub fn pipewire_thread(
// the host's `pyrowave` feature is on AND the session (or the global encoder pref) is
// PyroWave — so capture never calls back into `encode` and needs no feature gate of its
// own (the emptiness check gates it).
if vaapi_passthrough && !policy.pyrowave_modifiers.is_empty() {
for &m in &policy.pyrowave_modifiers {
if !modifiers.contains(&m) {
modifiers.push(m);
let extend_pyrowave = vaapi_passthrough && !policy.pyrowave_modifiers.is_empty();
for list in [&mut modifiers, &mut modifiers_bgra] {
if (importer.is_some() || vaapi_passthrough) && !list.contains(&0) {
list.push(0); // DRM_FORMAT_MOD_LINEAR
}
if extend_pyrowave {
for &m in &policy.pyrowave_modifiers {
if !list.contains(&m) {
list.push(m);
}
}
}
}
if extend_pyrowave {
tracing::info!(
count = modifiers.len(),
"zero-copy: advertising the PyroWave device's Vulkan-importable dmabuf modifiers"
@@ -1540,9 +1553,14 @@ pub fn pipewire_thread(
);
} else if want_dmabuf {
tracing::info!(
count = modifiers.len(),
bgrx_count = modifiers.len(),
bgra_count = modifiers_bgra.len(),
// `sample` is TRUNCATED to 6, and LINEAR is pushed last — so reading the sample as the
// whole list makes a perfectly good offer look tiled-only. That misreading cost a full
// debugging session on 2026-08-14, hence stating the one bit that was actually wanted.
linear_offered = modifiers.contains(&0),
sample = ?&modifiers[..modifiers.len().min(6)],
"zero-copy: advertising EGL-importable dmabuf modifiers"
"zero-copy: advertising EGL-importable dmabuf modifiers (BGRx + BGRA pods)"
);
} else if consumer.cpu_is_downgrade() {
// Reached only when no dmabuf is advertised at all (every arm above rules out a
@@ -2094,17 +2112,39 @@ pub fn pipewire_thread(
.map(|fmt| build_hdr_dmabuf_format(*fmt, preferred))
.collect::<Result<Vec<_>>>()?
} else if want_dmabuf {
let mut pods = Vec::with_capacity(if prefer_native_nv12 { 2 } else { 1 });
let mut pods = Vec::with_capacity(if prefer_native_nv12 { 3 } else { 2 });
if prefer_native_nv12 {
// First compatible consumer pod wins. Gamescope advertises NV12 and BGRx; pinning
// BT.709 limited here selects its RGB→NV12 shader with our bitstream colorimetry.
pods.push(build_dmabuf_format(VideoFormat::NV12, &[0], preferred)?);
}
pods.push(build_dmabuf_format(
VideoFormat::BGRx,
&modifiers,
preferred,
)?);
if !modifiers.is_empty() {
pods.push(build_dmabuf_format(
VideoFormat::BGRx,
&modifiers,
preferred,
)?);
}
// xdph (Hyprland/sway) offers ONLY **BGRA** on its dmabuf EnumFormat — it lists BGRA *and*
// BGRx on the SHM pod, so a BGRx-only dmabuf offer intersects with nothing and PipeWire
// fails the link outright:
// pw.link: negotiating -> error no more input formats (-22)
// Measured 2026-08-14 on Hyprland 0.55.4 + xdph 1.3.12: the 12 tiled modifiers matched on
// both sides perfectly — only the fourcc never did, which is why the failure reads like a
// GPU/modifier problem and is not one.
//
// BGRA and BGRx are the same 32-bit layout; the alpha byte is ignored the whole way to the
// encoder (`vk_util` maps both to `B8G8R8A8_UNORM`, VAAPI both to `Pixel::BGRA`), and the
// dmabuf import is driven by the NEGOTIATED format's fourcc, so an AR24 frame imports as
// AR24. 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.
if !modifiers_bgra.is_empty() {
pods.push(build_dmabuf_format(
VideoFormat::BGRA,
&modifiers_bgra,
preferred,
)?);
}
pods
} else {
vec![serialize_pod(obj)?]
+7
View File
@@ -38,6 +38,12 @@ pub struct HostTarget {
pub fp_hex: Option<String>,
pub mac: Vec<String>,
pub id: Option<String>,
/// The host's management-API port (saved store or live advert) — where the library is
/// served, distinct from `port` (the native QUIC plane). Carried on the target for the same
/// reason as `mac`: a front-end holding a plan has no `KnownHost` in hand, and resolving to
/// [`crate::library::DEFAULT_MGMT_PORT`] there is what made a moved mgmt port work on the
/// LAN but not over a VPN. `None` = unknown, fall back to the constant.
pub mgmt_port: Option<u16>,
}
impl From<&KnownHost> for HostTarget {
@@ -49,6 +55,7 @@ impl From<&KnownHost> for HostTarget {
fp_hex: (!h.fp_hex.is_empty()).then(|| h.fp_hex.clone()),
mac: h.mac.clone(),
id: h.id.clone(),
mgmt_port: h.mgmt_port,
}
}
}
+134
View File
@@ -313,6 +313,20 @@ pub struct KnownHost {
/// sleep. `default` (and elided when empty) so pre-existing stores load unchanged.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub os: String,
/// The host's management-API port (mDNS `mgmt` TXT), where the game library is served —
/// distinct from `port`, which is the native QUIC plane. Learned from the advert while the
/// host is online and persisted here for the same reason as `mac` and `os`: so it survives the
/// advert going away.
///
/// That is not a cosmetic loss like a missing OS icon. A host that moved its mgmt port off
/// 47990 — the supported fix for sharing a machine with a Sunshine fork, whose web UI owns
/// that port — was reachable only for as long as mDNS was: on a VPN, a routed subnet, or a
/// multicast-dead network the library silently went blank, because the port the client had
/// already been told was never written down. `None` = never learned, resolve via
/// [`KnownHost::effective_mgmt_port`]. Optional + `default` so pre-existing stores load
/// (the Apple client's `StoredHost.mgmtPort` is the same field for the same reason).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mgmt_port: Option<u16>,
/// Share this machine's clipboard with THIS host (design/clipboard-and-file-transfer.md
/// §5.3 — the Apple client's `StoredHost.clipboardSync`). Per-host, not global: handing a
/// host your clipboard is a trust decision about that host. Default off; the host must
@@ -353,6 +367,7 @@ impl Default for KnownHost {
last_used: None,
mac: Vec::new(),
os: String::new(),
mgmt_port: None,
clipboard_sync: false,
profile_id: None,
pinned_profiles: Vec::new(),
@@ -362,6 +377,17 @@ impl Default for KnownHost {
}
impl KnownHost {
/// Where this host's management API actually is: the port learned from its advert, else the
/// compiled-in 47990. The twin of the Apple client's `StoredHost.effectiveMgmtPort`.
///
/// Every library/art call resolves through this rather than reaching for
/// [`crate::library::DEFAULT_MGMT_PORT`] directly — that constant is the FALLBACK, not the
/// answer, and call sites that treated it as the answer are why a moved port only worked while
/// mDNS was up.
pub fn effective_mgmt_port(&self) -> u16 {
self.mgmt_port.unwrap_or(crate::library::DEFAULT_MGMT_PORT)
}
/// This host's pinned profiles that still exist, in card order, without duplicates — what
/// a grid renders. Dangling pins (the profile was deleted) simply disappear, per design
/// §5.2a: a pin is presentation state, never a reason to show an error.
@@ -506,6 +532,13 @@ impl KnownHosts {
if !entry.os.is_empty() {
h.os = entry.os;
}
// And for the learned mgmt port. Stated explicitly rather than left to the
// does-not-mention-it rule below: this one is load-bearing (a host that moved off
// 47990 is unreachable for the library without it), so a reconnect upsert that
// carries `None` must visibly not clear what a discovery taught us.
if entry.mgmt_port.is_some() {
h.mgmt_port = entry.mgmt_port;
}
// Everything below is state the user set ON this record, which a refresh (a
// reconnect, a re-pair, a rediscovery) never carries and therefore must never
// clear: the per-host clipboard decision — which survives today only because this
@@ -581,6 +614,9 @@ impl KnownHosts {
if h.os.is_empty() {
h.os = old.os;
}
if h.mgmt_port.is_none() {
h.mgmt_port = old.mgmt_port;
}
if h.profile_id.is_none() {
h.profile_id = old.profile_id;
}
@@ -692,6 +728,27 @@ pub fn learn_os(fp_hex: &str, addr: &str, port: u16, os: &str) {
let _ = known.save();
}
/// Learn/refresh a saved host's management-API port from its live advert (mDNS `mgmt` TXT),
/// matched like [`learn_mac`]: by fingerprint or address. No-op — and no disk write — when
/// unchanged, so the hosts page can call it on every discovery tick without churning the store.
///
/// This is what makes a moved mgmt port outlive mDNS. Until it existed the port was read straight
/// off the live advert and thrown away, so the library worked on the LAN and went blank over a VPN.
pub fn learn_mgmt_port(fp_hex: &str, addr: &str, port: u16, mgmt_port: u16) {
if mgmt_port == 0 {
return;
}
let mut known = KnownHosts::load();
let Some(h) = learn_target(&mut known, fp_hex, addr, port) else {
return;
};
if h.mgmt_port == Some(mgmt_port) {
return;
}
h.mgmt_port = Some(mgmt_port);
let _ = known.save();
}
/// Re-key a saved host's address/port after it rediscovered on a new DHCP lease (matched by
/// fingerprint). No-op — and no disk write — when unchanged. Called from the wake-and-wait flow when
/// a woken host reappears on a different IP than the stored one, so this and future connects dial the
@@ -725,6 +782,28 @@ pub fn touch_last_used(fp_hex: &str) {
}
}
/// Save a host's management-API port learned from the **session's own `Welcome`**, keyed by
/// fingerprint alone — the identity a just-connected client is certain of.
///
/// This is the mDNS-free path, and the one that matters most: [`learn_mgmt_port`] can only fire
/// where an advert is visible, whereas this fires on any successful connect, including a host
/// added by IP on a network where discovery has never worked. No-op — and no disk write — when
/// the fingerprint isn't stored or the value is unchanged, so it is safe on every connect.
pub fn learn_mgmt_port_by_fp(fp_hex: &str, mgmt_port: u16) {
if fp_hex.is_empty() || mgmt_port == 0 {
return;
}
let mut known = KnownHosts::load();
let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp_hex) else {
return;
};
if h.mgmt_port == Some(mgmt_port) {
return;
}
h.mgmt_port = Some(mgmt_port);
let _ = known.save();
}
/// Run the SPAKE2 PIN ceremony against a host. `device_name` is the label the HOST
/// stores this client under (its paired-devices list); the 90 s budget covers a
/// human-typed PIN. Returns the host's now-verified certificate fingerprint to pin.
@@ -1781,6 +1860,9 @@ mod tests {
last_used: Some(1000),
mac: vec!["aa:bb:cc:dd:ee:ff".into()],
os: "linux/fedora/bazzite".into(),
// Deliberately NOT 47990: a host that moved its mgmt port is the case this field
// exists for, so the default would make the assertions below pass vacuously.
mgmt_port: Some(47991),
clipboard_sync: true,
profile_id: Some("aaaaaaaaaaaa".into()),
pinned_profiles: vec!["bbbbbbbbbbbb".into()],
@@ -1804,6 +1886,9 @@ mod tests {
assert_eq!(h.mac, vec!["aa:bb:cc:dd:ee:ff".to_string()]);
// The learned OS chain rides the same rule as `mac`: a carrier-less upsert keeps it.
assert_eq!(h.os, "linux/fedora/bazzite");
// And the learned mgmt port. If a reconnect could reset this to None the host would fall
// back to 47990 and its library would 404 — the exact regression this rule prevents.
assert_eq!(h.mgmt_port, Some(47991));
assert!(h.clipboard_sync);
assert_eq!(h.profile_id.as_deref(), Some("aaaaaaaaaaaa"));
assert_eq!(h.pinned_profiles, vec!["bbbbbbbbbbbb".to_string()]);
@@ -1823,6 +1908,51 @@ mod tests {
assert_eq!(k.hosts[0].pinned_profiles, vec!["dddddddddddd".to_string()]);
}
/// The mgmt port a host advertises has to OUTLIVE the advert: a store written before the field
/// existed must load, resolve to 47990, and then take and keep a learned value. Without the
/// middle rung a host moved off 47990 (to share a box with a Sunshine fork, whose web UI owns
/// that port) served its library on the LAN and nowhere else — over a VPN or a routed subnet
/// there is no advert to read and the client silently went back to a dead port.
#[test]
fn mgmt_port_survives_a_store_that_predates_it_and_then_persists() {
// A store written before the field existed: no `mgmt_port` key at all.
let old = r#"{"hosts":[{
"name": "Gaming PC", "addr": "192.168.1.50", "port": 9777,
"fp_hex": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"paired": true
}]}"#;
let mut k: KnownHosts = serde_json::from_str(old).unwrap();
assert_eq!(k.hosts[0].mgmt_port, None, "absent key decodes to None");
assert_eq!(
k.hosts[0].effective_mgmt_port(),
crate::library::DEFAULT_MGMT_PORT,
"unknown resolves to the compiled-in default, i.e. today's behaviour"
);
// Unset stays out of the serialized form, so an untouched store is byte-stable.
assert!(!serde_json::to_string(&k).unwrap().contains("mgmt_port"));
// Learning one (what a discovery tick does) takes effect and round-trips.
k.hosts[0].mgmt_port = Some(47991);
assert_eq!(k.hosts[0].effective_mgmt_port(), 47991);
let round: KnownHosts = serde_json::from_str(&serde_json::to_string(&k).unwrap()).unwrap();
assert_eq!(round.hosts[0].mgmt_port, Some(47991));
// A re-key carries it onto the surviving record — otherwise a host that regenerated its
// identity would silently drop back to 47990.
let fresh = fp('a');
let mut k2 = k;
k2.upsert_trusted(KnownHost {
name: "Gaming PC".into(),
addr: "192.168.1.50".into(),
port: 9777,
fp_hex: fresh.clone(),
paired: true,
..Default::default()
});
let kept = k2.hosts.iter().find(|h| h.fp_hex == fresh).unwrap();
assert_eq!(kept.mgmt_port, Some(47991), "re-key must not lose the port");
}
/// A host that regenerated its identity (reinstall, wiped ProgramData, re-key) ends up with
/// ONE record for its address — the live one. This is the `.173` lockout: `upsert` keys on
/// the fingerprint, so the re-paired host used to be appended beside the dead record, and
@@ -1840,6 +1970,7 @@ mod tests {
last_used: Some(1000),
mac: vec!["aa:bb:cc:dd:ee:ff".into()],
os: "windows".into(),
mgmt_port: Some(47991),
clipboard_sync: true,
profile_id: Some("aaaaaaaaaaaa".into()),
pinned_profiles: vec!["bbbbbbbbbbbb".into()],
@@ -1864,6 +1995,9 @@ mod tests {
// What describes the BOX rides along, so a reinstall doesn't cost the user their setup.
assert_eq!(h.mac, vec!["aa:bb:cc:dd:ee:ff".to_string()]);
assert_eq!(h.os, "windows");
// The mgmt port describes the BOX, not the retired certificate: a reinstall must not send
// the library back to 47990 on a host that serves it somewhere else.
assert_eq!(h.mgmt_port, Some(47991));
assert_eq!(h.profile_id.as_deref(), Some("aaaaaaaaaaaa"));
assert_eq!(h.pinned_profiles, vec!["bbbbbbbbbbbb".to_string()]);
assert_eq!(h.last_used, Some(1000));
+4
View File
@@ -21,6 +21,10 @@ tracing = "0.1"
# `FramePayload::Cuda` owns a zero-copy `DeviceBuffer`; `libc` for the per-thread `setpriority`.
pf-zerocopy = { path = "../pf-zerocopy" }
libc = "0.2"
# The rtkit fallback in `thread_qos` (one blocking system-bus call per boosted thread). Same zbus
# the host already pulls via ashpd; `tokio` mirrors ashpd's backend choice so this adds the
# `blocking-api` surface without changing the resolved I/O backend, and no default `async-io`.
zbus = { version = "5", default-features = false, features = ["tokio", "blocking-api"] }
[target.'cfg(target_os = "windows")'.dependencies]
# The DXGI capture identity (`WinCaptureTarget`/`D3d11Frame`/`pack_luid`/`make_device`) + the GPU
+61 -8
View File
@@ -44,10 +44,9 @@ pub fn boost_thread_priority(critical: bool) {
// Best-effort nice of the CALLING thread. On Linux `setpriority(PRIO_PROCESS, 0, …)` acts on
// the calling thread (the kernel resolves who==0 to the current task/tid), and both call
// sites run inside their worker thread — so this nices exactly the capture/encode (critical)
// and send (non-critical) threads, nothing else. Silently no-ops without CAP_SYS_NICE / a
// raised RLIMIT_NICE, which is fine. We deliberately do NOT use SCHED_RR/FIFO by default: a
// realtime CPU class can preempt the compositor AND the game's own render thread, adding the
// very frame-time we refuse to add (opt-in only — see PUNKTFUNK_SCHED_RR).
// and send (non-critical) threads, nothing else. We deliberately do NOT use SCHED_RR/FIFO by
// default: a realtime CPU class can preempt the compositor AND the game's own render thread,
// adding the very frame-time we refuse to add (opt-in only — see PUNKTFUNK_SCHED_RR).
let nice = if critical { -10 } else { -5 };
// SAFETY: `setpriority` takes three by-value integers and no pointers, so there is nothing to
// alias or outlive. `PRIO_PROCESS` with `who == 0` targets the calling task on Linux and
@@ -57,10 +56,24 @@ pub fn boost_thread_priority(critical: bool) {
if rc == 0 {
tracing::debug!(critical, nice, "thread nice raised");
} else {
tracing::debug!(
critical,
"setpriority(nice) no-op (needs CAP_SYS_NICE / RLIMIT_NICE)"
);
// The direct call needs CAP_SYS_NICE or a raised RLIMIT_NICE, and the host binary can
// NEVER carry a file capability (a capped process's /proc/<pid>/exe is unreadable to
// KWin, which kills desktop streaming — the 0.26.0-1 field incident). RealtimeKit is
// the sanctioned unprivileged path: the same broker PipeWire's clients use, present on
// effectively every desktop install. Packaging also ships a `user@.service.d`
// LimitNICE drop-in so the direct call works on rtkit-less boxes — but only from the
// next login, and existing installs upgrade the binary alone; rtkit is what fixes the
// installed base. A 2026-08-14 field log showed exactly this rung missing: every
// fresh-launch shader storm descheduled the unprioritized audio/send threads.
match linux_rtkit::make_high_priority(nice) {
Ok(()) => tracing::debug!(critical, nice, "thread nice raised via rtkit"),
Err(e) => tracing::debug!(
critical,
reason = %e,
"setpriority(nice) no-op (needs CAP_SYS_NICE / RLIMIT_NICE, and rtkit \
was unavailable)"
),
}
}
}
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
@@ -68,3 +81,43 @@ pub fn boost_thread_priority(critical: bool) {
let _ = critical;
}
}
/// RealtimeKit fallback for [`boost_thread_priority`]: ask the system-bus broker
/// (`org.freedesktop.RealtimeKit1`) to renice the calling thread when the direct
/// `setpriority` was refused. This is how PulseAudio/PipeWire clients get their boosts on a
/// stock desktop — no capability anywhere, which matters here because a file capability on the
/// host binary breaks KWin's client identification outright.
///
/// Only the high-priority (nice) verb is used, never `MakeThreadRealtime` — the SCHED_RR
/// reservations in [`boost_thread_priority`]'s comment apply to rtkit-granted RR too (and the
/// RT verb additionally demands an RLIMIT_RTTIME we don't set).
#[cfg(target_os = "linux")]
mod linux_rtkit {
/// One-shot blocking D-Bus call. Must be made from a plain worker thread, never from async
/// context — which already holds for every caller: `boost_thread_priority` acts on the
/// calling thread, so it only ever runs inside the dedicated capture/encode/send threads.
/// The connection is per-call rather than cached: this runs at most a handful of times per
/// session (thread starts), and holding a system-bus connection for the session's lifetime
/// to save microseconds at session start is a bad trade against a wedged bus daemon pinning
/// a socket in every session forever.
pub(super) fn make_high_priority(nice: i32) -> Result<(), zbus::Error> {
// SAFETY: `gettid` takes no arguments, touches no memory, and returns the calling
// thread's kernel tid — always valid on Linux.
let tid = unsafe { libc::syscall(libc::SYS_gettid) } as u64;
let pid = u64::from(std::process::id());
let conn = zbus::blocking::Connection::system()?;
// `MakeThreadHighPriorityWithPID(u64 process, u64 thread, i32 priority)` — priority is a
// nice level, floored by rtkit's MinNiceLevel (defaults well below our -10). The WithPID
// variant with our own pid is the explicit spelling of "this thread of this process";
// rtkit still authenticates the caller via the bus, so it grants nothing a plain
// `setpriority` caller couldn't be granted.
conn.call_method(
Some("org.freedesktop.RealtimeKit1"),
"/org/freedesktop/RealtimeKit1",
Some("org.freedesktop.RealtimeKit1"),
"MakeThreadHighPriorityWithPID",
&(pid, tid, nice),
)?;
Ok(())
}
}
+47
View File
@@ -144,6 +144,30 @@ pub struct HostConfig {
/// text ("Living Room PC"); the DNS-level `<label>.local.` target keeps using a sanitized
/// machine-safe label, so a spacey display name can't produce an invalid mDNS record.
pub host_name: Option<String>,
/// `PUNKTFUNK_MGMT_BIND` — the management API's listen address (`IP:PORT`), equivalent to the
/// `--mgmt-bind` CLI flag, which still wins when both are given. Unset = `0.0.0.0:47990`.
///
/// This exists so moving the port SURVIVES: `--mgmt-bind` lives in a unit file / service
/// registration that a package upgrade rewrites, whereas `host.env` is operator-owned and is
/// the documented place every other knob lives. The motivating case is coexistence with a
/// Sunshine fork — 47990 is *their* web UI port as well as our management API, and it is the
/// only port the two share once the GameStream planes are off, so moving it is the whole fix.
///
/// Kept as the raw string rather than a parsed `SocketAddr`: this crate is the
/// parse-once-from-env layer, and `main.rs` owns turning a bad value into the same
/// `bad --mgmt-bind (want IP:PORT)` error the flag produces, from one place.
pub mgmt_bind: Option<String>,
/// `PUNKTFUNK_NATIVE_PORT` — the native punktfunk/1 (QUIC) control port, equivalent to the
/// `--native-port` CLI flag, which still wins. Unset = 9777.
///
/// Same survives-an-upgrade argument as [`Self::mgmt_bind`]: `--native-port` lives in an
/// ExecStart a package rewrites. Unlike the mgmt port, the CLIENT side of moving this already
/// worked — `KnownHost.port` is persisted per host and `--connect HOST:PORT` names it — so this
/// key is the last piece of making the native port genuinely movable.
///
/// Raw string, parsed in `main.rs`, for the same reason as `mgmt_bind`: a typo'd port must be a
/// startup ERROR, not a silent fall back to 9777 while the operator believes they moved it.
pub native_port: Option<String>,
/// `PUNKTFUNK_GAMESTREAM` — enable the GameStream/Moonlight-compat planes (nvhttp pairing,
/// RTSP, ENet control, `_nvstream` mDNS) from `host.env`, equivalent to the `--gamestream`
/// CLI flag (either source turns it on). **Default OFF** — the secure native-only host: the
@@ -214,6 +238,15 @@ pub struct HostConfig {
/// showing the wrong monitor is worse than showing none). Linux-only today; see
/// `design/per-monitor-portal-capture.md`.
pub capture_monitor: Option<String>,
/// `PUNKTFUNK_PORTAL_CURSOR_MODE` — `auto` (default) · `hidden` · `embedded` · `metadata`.
/// Pin the ScreenCast cursor mode the Linux portal backends PREFER, instead of the one the
/// session negotiates (`metadata` when the client draws the pointer itself, `embedded`
/// otherwise). The pin is a preference, not a command: it still runs through
/// `portal_cursor::pick`, so it can never ask a backend for a mode the backend does not
/// advertise — that closes the session rather than degrading, which is the failure this knob
/// sits next to. Exists for the backend that advertises a mode it implements badly, where
/// negotiation has nothing to go on; `embedded` is the safe answer there.
pub portal_cursor_mode: Option<String>,
/// `PUNKTFUNK_COMPOSITOR` — explicit compositor override (operator/CI/test). NOT the runtime-detected
/// session — this one is a constant operator knob; `apply_session_env` never writes it.
pub compositor: Option<String>,
@@ -365,6 +398,14 @@ impl HostConfig {
host_name: val("PUNKTFUNK_HOST_NAME")
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
// Blank-is-unset, like `host_name` above: an operator who comments a value out by
// emptying it (`PUNKTFUNK_MGMT_BIND=`) means "default", not "parse the empty string".
mgmt_bind: val("PUNKTFUNK_MGMT_BIND")
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
native_port: val("PUNKTFUNK_NATIVE_PORT")
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
// Default OFF, explicit-on grammar: the Moonlight-compat planes are opt-in
// everywhere (see the field doc); `--gamestream` on the CLI also turns them on.
gamestream: env_on("PUNKTFUNK_GAMESTREAM").unwrap_or(false),
@@ -401,6 +442,12 @@ impl HostConfig {
capture_monitor: val("PUNKTFUNK_CAPTURE_MONITOR")
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
// Same emptied-to-None rule: a bare `PUNKTFUNK_PORTAL_CURSOR_MODE=` left in a host.env
// means "not set", not an unrecognised value to warn about. The spellings are parsed
// (and warned about) at the use site, `pf-vdisplay`'s `portal_cursor::want`.
portal_cursor_mode: val("PUNKTFUNK_PORTAL_CURSOR_MODE")
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
compositor: val("PUNKTFUNK_COMPOSITOR"),
gamepad: val("PUNKTFUNK_GAMEPAD"),
vdisplay: val("PUNKTFUNK_VDISPLAY"),
@@ -0,0 +1,79 @@
//! The compositor output absolute coordinates belong to, by NAME — the Linux counterpart of the
//! Windows `stream_target` slot, and what the wlroots virtual-pointer backend aims at.
//!
//! `MouseMoveAbs` carries its own reference extent (`w`/`h` — the client's letterboxed video rect
//! in ITS window, not the streamed mode), and the wlr protocol normalizes `x`/`y` against it and
//! maps the result onto whichever `wl_output` the virtual pointer was **created with**. So the
//! extent takes care of itself and the OUTPUT is the whole question. The injector used to pass the
//! first `wl_output` the registry advertised, which is the oldest global — on any multi-head box
//! the operator's physical head, never the per-session headless output the client is looking at.
//! On the EXTEND backends (Hyprland, wlroots/sway) the streamed head sits *beside* the operator's,
//! so absolute samples landed on a screen no session was streaming. Reported from the field as
//! "no cursor was visible in the session", and later as a cursor pinned near the left edge that
//! vanished part-way across.
//!
//! The host publishes the streamed output's compositor name at capture bring-up
//! ([`set_stream_output`]) — Hyprland's `PF-<pid>-<n>`, sway's `HEADLESS-N`, or a mirrored head's
//! connector — and the wlr backend re-creates its virtual pointer bound to the matching `wl_output`
//! (`wl_output.name`, protocol v4; the name is explicitly "the same for all clients", so the name
//! `hyprctl`/`swaymsg` minted is the name we can match here).
//!
//! **One slot per process**, exactly like the Windows original: the injector is host-lifetime and
//! every concurrent session's input flows through it, so with parallel sessions the LAST capture
//! bring-up wins for every session's absolute input. Per-session routing needs source-tagged input
//! events (the injector has to become session-aware first — see [`crate::set_absolute_anchor`]'s
//! note), and the single slot is never worse than what it replaces: today EVERY session's absolute
//! input lands on a head that no session is streaming.
//!
//! With nothing published — before the first bring-up, or on a compositor whose `wl_output` is
//! older than v4 and therefore nameless — the pointer is bound to NO output, which maps absolute
//! coordinates over the whole layout. On a single-output compositor that is identical to binding
//! that output; on a multi-head one it is at least *reachable*, unlike a pin to the wrong head.
use std::sync::RwLock;
/// The streamed output's compositor name, or `None` when nothing has been published yet.
static STREAM_OUTPUT: RwLock<Option<String>> = RwLock::new(None);
/// Publish the compositor output (by name) that absolute input maps into. The host calls this at
/// capture bring-up, and ONLY there: nothing clears it at teardown, because an output that goes
/// away simply stops resolving (the backend falls back to whole-layout mapping, and between
/// sessions nothing injects anyway). A later bring-up is what rewrites it — including to `None`,
/// which a backend that needs no named binding passes so a stale name cannot outlive its
/// compositor. See the module doc for the one-slot-per-process trade with parallel sessions.
pub fn set_stream_output(name: Option<String>) {
let mut cur = STREAM_OUTPUT.write().unwrap_or_else(|e| e.into_inner());
if *cur != name {
tracing::info!(output = ?name, "absolute-input stream output set");
*cur = name;
}
}
/// The streamed output's compositor name, if one has been published.
pub fn stream_output() -> Option<String> {
STREAM_OUTPUT
.read()
.unwrap_or_else(|e| e.into_inner())
.clone()
}
#[cfg(test)]
mod tests {
use super::*;
/// ONE test on purpose, like the libei anchor's: the slot is process-wide and cargo runs
/// tests on threads in one process, so splitting this into several would let them race.
#[test]
fn publishes_clears_and_round_trips() {
set_stream_output(Some("PF-1643-1".into()));
assert_eq!(stream_output().as_deref(), Some("PF-1643-1"));
// Re-publishing the same name is a no-op, not a second "set" (the backend keys its
// pointer re-creation off the resolved name, but the log line should not repeat).
set_stream_output(Some("PF-1643-1".into()));
assert_eq!(stream_output().as_deref(), Some("PF-1643-1"));
set_stream_output(Some("HEADLESS-2".into()));
assert_eq!(stream_output().as_deref(), Some("HEADLESS-2"));
set_stream_output(None);
assert_eq!(stream_output(), None);
}
}
+283 -26
View File
@@ -5,6 +5,11 @@
//! virtual keyboard (the host's layout via the standard `XKB_DEFAULT_LAYOUT` et al., defaulting
//! to evdev/US), and translate events into virtual pointer/keyboard requests, tracking modifier
//! state so the compositor resolves shifted keysyms correctly.
//!
//! **Absolute** motion is mapped by the compositor onto the `wl_output` the virtual pointer was
//! CREATED with, so which output that is decides where every absolute sample lands. We aim it at
//! the head the session is actually streaming — published by name in [`crate::stream_output`] and
//! re-resolved (re-creating the pointer) whenever it changes; see [`WlrootsInjector::retarget`].
use super::{gs_button_to_evdev, vk_to_evdev, InputEvent, InputInjector};
use anyhow::{bail, Context, Result};
@@ -12,7 +17,12 @@ use punktfunk_core::input::InputKind;
use std::io::Write;
use std::os::fd::{AsFd, FromRawFd};
use std::time::Instant;
use wayland_client::protocol::{wl_output::WlOutput, wl_pointer, wl_registry, wl_seat::WlSeat};
use wayland_client::backend::WaylandError;
use wayland_client::protocol::{
wl_output::{self, WlOutput},
wl_pointer, wl_registry,
wl_seat::WlSeat,
};
use wayland_client::{Connection, Dispatch, EventQueue, Proxy, QueueHandle};
use wayland_protocols_misc::zwp_virtual_keyboard_v1::client::{
zwp_virtual_keyboard_manager_v1::ZwpVirtualKeyboardManagerV1,
@@ -27,13 +37,65 @@ use xkbcommon::xkb;
/// `code` value marking a horizontal scroll event (mirrors `gamestream::input`).
const SCROLL_HORIZONTAL: u32 = 1;
/// `wl_output.name` — the connector name we match the streamed head on — arrived in v4. Nothing
/// else we ask of an output needs more than v1, so a lower advert only costs us the names (and
/// with them the ability to aim absolute input; see [`index_named`]). Same constant, same reason,
/// as `pf_vdisplay`'s `kwin_dpms`.
const WL_OUTPUT_MAX: u32 = 4;
/// One `wl_output` the compositor has advertised.
struct Output {
/// The registry global name — the key `wl_registry.global_remove` reports, and the user data
/// each `wl_output` event carries back so we know which head it describes.
global: u32,
proxy: WlOutput,
/// `wl_output.name` (protocol v4): the compositor's own name for the head — `HDMI-A-1`,
/// Hyprland's `PF-<pid>-<n>`, sway's `HEADLESS-N`. The protocol guarantees this is "the same
/// output name for all clients", which is what lets us match the name `hyprctl`/`swaymsg`
/// minted on the vdisplay side. `None` on a compositor stuck at v3, which has no name event at
/// all — then there is nothing to match on and the pointer stays unbound.
name: Option<String>,
}
/// Globals bound from the registry (the Wayland dispatch state).
#[derive(Default)]
struct Globals {
pointer_mgr: Option<ZwlrVirtualPointerManagerV1>,
keyboard_mgr: Option<ZwpVirtualKeyboardManagerV1>,
seat: Option<WlSeat>,
output: Option<WlOutput>,
/// EVERY advertised output, in advertisement order — not just the first. The streamed head is
/// created per session, so it is never the first one advertised (that is the operator's
/// oldest physical head), and binding only the first is what aimed absolute input at the
/// wrong screen on every EXTEND box.
outputs: Vec<Output>,
}
/// Which advertised output — by position in `names`, which is advertisement order — the virtual
/// pointer should bind to for the published target `want`.
///
/// The rule has **no fallback on purpose**, and that absence is the fix: what this replaced was a
/// fallback ("bind whatever `wl_output` came first"), and the first-advertised output is the oldest
/// global, i.e. the operator's physical head — never the per-session headless one the client is
/// looking at. A target that matches nothing therefore yields `None`, which binds the pointer to no
/// output and maps absolute coordinates over the whole layout: wrong-ish, but reachable, where a
/// pin to the wrong head is unreachable.
///
/// Split out of [`Globals::output_named`] so the rule is testable — a `WlOutput` proxy cannot be
/// constructed without a live Wayland connection, but the decision it feeds can.
fn index_named<'a>(
names: impl IntoIterator<Item = Option<&'a str>>,
want: Option<&str>,
) -> Option<usize> {
let want = want?;
names.into_iter().position(|n| n == Some(want))
}
impl Globals {
/// The `wl_output` whose compositor name is `want`, if it is currently advertised.
fn output_named(&self, want: &str) -> Option<WlOutput> {
index_named(self.outputs.iter().map(|o| o.name.as_deref()), Some(want))
.map(|i| self.outputs[i].proxy.clone())
}
}
impl Dispatch<wl_registry::WlRegistry, ()> for Globals {
@@ -45,13 +107,12 @@ impl Dispatch<wl_registry::WlRegistry, ()> for Globals {
_: &Connection,
qh: &QueueHandle<Self>,
) {
if let wl_registry::Event::Global {
name,
interface,
version,
} = event
{
match interface.as_str() {
match event {
wl_registry::Event::Global {
name,
interface,
version,
} => match interface.as_str() {
"zwlr_virtual_pointer_manager_v1" => {
state.pointer_mgr = Some(registry.bind(name, version.min(2), qh, ()));
}
@@ -61,16 +122,52 @@ impl Dispatch<wl_registry::WlRegistry, ()> for Globals {
"wl_seat" => {
state.seat = Some(registry.bind(name, version.min(7), qh, ()));
}
"wl_output" if state.output.is_none() => {
state.output = Some(registry.bind(name, version.min(3), qh, ()));
"wl_output" => {
// The `name` event is the only thing that tells the streamed head from the
// operator's. Older compositors bind lower and stay nameless (harmless:
// `output_named` then matches nothing and the pointer maps over the layout).
// The registry global name rides along as user data so the events that follow
// land on the right entry.
let proxy = registry.bind(name, version.min(WL_OUTPUT_MAX), qh, name);
state.outputs.push(Output {
global: name,
proxy,
name: None,
});
}
_ => {}
},
// A head went away — a session's headless output being torn down is the common case,
// and the pointer must stop being aimed at a dead object (`retarget` re-resolves and
// falls back to the whole layout on the next absolute sample).
wl_registry::Event::GlobalRemove { name } => {
state.outputs.retain(|o| o.global != name);
}
_ => {}
}
}
}
impl Dispatch<WlOutput, u32> for Globals {
fn event(
state: &mut Self,
_: &WlOutput,
event: wl_output::Event,
global: &u32,
_: &Connection,
_: &QueueHandle<Self>,
) {
// Only the name matters here: geometry/mode/scale are the compositor's problem, because
// binding the pointer to an output makes IT do the mapping (see `retarget`).
if let wl_output::Event::Name { name } = event {
if let Some(o) = state.outputs.iter_mut().find(|o| o.global == *global) {
o.name = Some(name);
}
}
}
}
// The managers, the two virtual devices, the seat and the output emit no events we use.
// The managers, the two virtual devices and the seat emit no events we use.
macro_rules! ignore_events {
($($t:ty),* $(,)?) => {$(
impl Dispatch<$t, ()> for Globals {
@@ -80,7 +177,6 @@ macro_rules! ignore_events {
}
ignore_events!(
WlSeat,
WlOutput,
ZwlrVirtualPointerManagerV1,
ZwlrVirtualPointerV1,
ZwpVirtualKeyboardManagerV1,
@@ -92,6 +188,14 @@ pub struct WlrootsInjector {
queue: EventQueue<Globals>,
globals: Globals,
pointer: ZwlrVirtualPointerV1,
/// The compositor name of the output `pointer` is bound to, or `None` when it is bound to no
/// output (absolute coordinates then span the whole layout). Compared against
/// [`crate::stream_output`] on every absolute sample; a difference re-creates the pointer.
bound_output: Option<String>,
/// evdev codes of the mouse buttons currently held on `pointer`, so re-creating the device
/// can release them first — the compositor has no reason to, and a virtual pointer destroyed
/// mid-press leaves the host with a stuck mouse button.
pressed: Vec<u32>,
keyboard: ZwpVirtualKeyboardV1,
xkb_state: xkb::State,
_keymap_file: std::fs::File, // keep the memfd alive for the compositor's mmap
@@ -100,6 +204,25 @@ pub struct WlrootsInjector {
start: Instant,
}
/// Resolve the published stream output ([`crate::stream_output`]) against the outputs this
/// connection has bound: `(proxy, name)` when the target is live, `(None, None)` otherwise.
///
/// `(None, None)` covers three cases that all want the same answer — nothing published yet (before
/// the first capture bring-up), the target's `wl_output` global not advertised yet (the injector
/// opens on the first input event, which can beat the session's display), and the target torn down
/// (session end). A pointer bound to no output maps absolute coordinates over the whole layout,
/// which on a single-output compositor is exactly that output and on a multi-head one at least
/// keeps the streamed head reachable — unlike a pin to a head nobody is streaming.
fn resolve_target(globals: &Globals) -> (Option<WlOutput>, Option<String>) {
let Some(want) = crate::stream_output() else {
return (None, None);
};
match globals.output_named(&want) {
Some(proxy) => (Some(proxy), Some(want)),
None => (None, None),
}
}
/// Cap on distinct characters the dynamic text keymap holds before it restarts from scratch
/// (keycodes grow upward from 9; xkb tops out at 255, so stay well under).
const TEXT_KEYMAP_MAX: usize = 200;
@@ -140,12 +263,16 @@ impl WlrootsInjector {
.clone()
.context("compositor advertised no wl_seat")?;
let pointer = pointer_mgr.create_virtual_pointer_with_output(
Some(&seat),
globals.output.as_ref(),
&qh,
(),
);
// A second roundtrip: the first only said WHICH globals exist. The `wl_output.name` events
// that identify each head are emitted on the objects we bound *during* that roundtrip, so
// they only land now — and the pointer's output has to be resolved before we create it.
queue
.roundtrip(&mut globals)
.context("Wayland output-name roundtrip")?;
let (target, bound_output) = resolve_target(&globals);
let pointer =
pointer_mgr.create_virtual_pointer_with_output(Some(&seat), target.as_ref(), &qh, ());
let keyboard = keyboard_mgr.create_virtual_keyboard(&seat, &qh, ());
// The keymap the compositor resolves our raw evdev keycodes with. Empty names defer to
@@ -174,7 +301,9 @@ impl WlrootsInjector {
conn.flush().ok();
tracing::info!(
output = globals.output.is_some(),
outputs = globals.outputs.len(),
want = ?crate::stream_output(),
bound = ?bound_output,
"wlroots virtual input ready (pointer + keyboard)"
);
Ok(Self {
@@ -182,6 +311,8 @@ impl WlrootsInjector {
queue,
globals,
pointer,
bound_output,
pressed: Vec::new(),
keyboard,
xkb_state,
_keymap_file: file,
@@ -190,6 +321,90 @@ impl WlrootsInjector {
})
}
/// Aim the virtual pointer at the output the session is streaming, re-creating it when that
/// changes — the fix for absolute input landing on the operator's screen.
///
/// The wlr protocol maps `motion_absolute` onto the output the pointer was **created with**
/// and offers no way to re-aim one, so a change means destroy + create. Cheap and rare: the
/// host publishes the target once per capture bring-up, so a re-create fires at most a couple
/// of times per session. The no-change path — every other absolute sample — costs one `RwLock`
/// read and a scan of the output list, which has one entry per head.
///
/// Called from the `MouseMoveAbs` arm immediately BEFORE the motion is sent, so a re-created
/// pointer gets its first position in the same batch rather than sitting wherever the
/// compositor puts a brand-new device.
///
/// Resolution is by NAME, never by size: `MouseMoveAbs`'s extent is the client's letterboxed
/// content rect in ITS window, not the streamed mode, so no size ladder could identify the
/// head. Falling back to no output at all (whole-layout mapping) when the target is unknown is
/// deliberate — see [`crate::stream_output`]'s module doc.
fn retarget(&mut self) {
let (target, want) = resolve_target(&self.globals);
if want == self.bound_output {
return;
}
let (Some(mgr), Some(seat)) = (self.globals.pointer_mgr.clone(), self.globals.seat.clone())
else {
return; // cannot re-create without the manager/seat; keep the pointer we have
};
// Never destroy a device with a button held: nothing else will release it.
if !self.pressed.is_empty() {
let t = self.now_ms();
for btn in std::mem::take(&mut self.pressed) {
self.pointer
.button(t, btn, wl_pointer::ButtonState::Released);
}
self.pointer.frame();
}
self.pointer.destroy();
self.pointer = mgr.create_virtual_pointer_with_output(
Some(&seat),
target.as_ref(),
&self.queue.handle(),
(),
);
tracing::info!(
from = ?self.bound_output,
to = ?want,
"wlroots virtual pointer re-aimed (absolute input now maps into this output)"
);
self.bound_output = want;
}
/// Drain the compositor's half of the connection, then push our batch to it — run after every
/// injected event.
///
/// The **read** is the load-bearing half, and it used to be missing: `dispatch_pending`'s own
/// documentation says it "will not perform reads on the Wayland socket", so the queue only
/// ever held what [`Self::open`]'s roundtrips put there. Two consequences, both real. The
/// injector could never learn about a `wl_output` created AFTER it opened — which is exactly
/// the ordering the field report was captured in, and would have left [`Self::retarget`] with
/// nothing to resolve. And everything the compositor sent us piled up unread in the socket
/// buffer for the host's lifetime, including the protocol errors the code here claimed to be
/// surfacing but structurally could not.
///
/// Non-blocking by construction: `read()` is documented to answer `WouldBlock` when the socket
/// has nothing for us, which is the common case at input rates and is not an error.
fn pump(&mut self) -> Result<()> {
// `prepare_read` will not hand out a guard while events are still queued, so dispatch first.
self.queue
.dispatch_pending(&mut self.globals)
.context("wayland dispatch")?;
if let Some(guard) = self.conn.prepare_read() {
match guard.read() {
Ok(_) => {
self.queue
.dispatch_pending(&mut self.globals)
.context("wayland dispatch (post-read)")?;
}
Err(WaylandError::Io(e)) if e.kind() == std::io::ErrorKind::WouldBlock => {}
Err(e) => return Err(e).context("wayland read"),
}
}
self.conn.flush().context("wayland flush")?;
Ok(())
}
fn now_ms(&self) -> u32 {
self.start.elapsed().as_millis() as u32
}
@@ -271,6 +486,12 @@ impl InputInjector for WlrootsInjector {
let w = (event.flags >> 16) & 0xffff;
let h = event.flags & 0xffff;
if w > 0 && h > 0 {
// The compositor maps these onto the pointer's bound output, so make sure that
// is the head this session streams before sending any. Checked here rather
// than per inject: only absolute motion depends on the binding, and a pointer
// swapped mid-drag is the one thing `retarget` has to work to be safe about.
self.retarget();
let t = self.now_ms(); // `retarget` may have consumed time releasing buttons
let x = event.x.clamp(0, w as i32) as u32;
let y = event.y.clamp(0, h as i32) as u32;
self.pointer.motion_absolute(t, x, y, w, h);
@@ -280,8 +501,12 @@ impl InputInjector for WlrootsInjector {
InputKind::MouseButtonDown | InputKind::MouseButtonUp => {
if let Some(btn) = gs_button_to_evdev(event.code) {
let st = if event.kind == InputKind::MouseButtonDown {
if !self.pressed.contains(&btn) {
self.pressed.push(btn);
}
wl_pointer::ButtonState::Pressed
} else {
self.pressed.retain(|&b| b != btn);
wl_pointer::ButtonState::Released
};
self.pointer.button(t, btn, st);
@@ -328,12 +553,7 @@ impl InputInjector for WlrootsInjector {
// wlroots has no virtual-touch protocol wired here; touch is the libei path only.
InputKind::TouchDown | InputKind::TouchMove | InputKind::TouchUp => {}
}
// Surface protocol errors / disconnects, then push the batch to the compositor.
self.queue
.dispatch_pending(&mut self.globals)
.context("wayland dispatch")?;
self.conn.flush().context("wayland flush")?;
Ok(())
self.pump()
}
}
@@ -383,3 +603,40 @@ fn memfd_with(s: &str) -> Result<std::fs::File> {
f.write_all(&[0]).context("write keymap NUL")?;
Ok(f)
}
#[cfg(test)]
mod tests {
use super::*;
/// The live-box layout the field report came from: the operator's `HDMI-A-1` is advertised
/// FIRST (it exists from compositor start), and the session's headless head is added later —
/// so "first advertised" is always the wrong answer, whichever order the injector and the
/// display happen to come up in.
const HYPRLAND_BOX: [Option<&str>; 2] = [Some("HDMI-A-1"), Some("PF-87756-3")];
#[test]
fn binds_the_streamed_head_not_the_first_advertised_one() {
assert_eq!(index_named(HYPRLAND_BOX, Some("PF-87756-3")), Some(1));
assert_eq!(index_named(HYPRLAND_BOX, Some("HDMI-A-1")), Some(0));
// sway's own naming, and a mirrored physical head, resolve the same way.
let sway = [Some("HEADLESS-1"), Some("DP-2"), Some("HEADLESS-2")];
assert_eq!(index_named(sway, Some("HEADLESS-2")), Some(2));
assert_eq!(index_named(sway, Some("DP-2")), Some(1));
}
/// Every "we don't know" must land on NO output (whole-layout mapping), never on a guess —
/// the regression this whole change exists to prevent.
#[test]
fn an_unknown_target_binds_nothing_rather_than_falling_back() {
// Published but not advertised (yet, or any more — the injector opens on the first input
// event, which can beat the display, and the head goes away at session end).
assert_eq!(index_named(HYPRLAND_BOX, Some("PF-87756-9")), None);
// Nothing published at all — before the first capture bring-up.
assert_eq!(index_named(HYPRLAND_BOX, None), None);
// A compositor older than wl_output v4 emits no `name` event, so nothing is matchable.
assert_eq!(index_named([None, None], Some("PF-87756-3")), None);
// …and a compositor advertising no outputs at all cannot resolve anything either.
let headless: [Option<&str>; 0] = [];
assert_eq!(index_named(headless, Some("PF-87756-3")), None);
}
}
+17
View File
@@ -149,6 +149,15 @@ static ABSOLUTE_ANCHOR: std::sync::RwLock<Option<AbsoluteAnchor>> = std::sync::R
/// record in `design/per-monitor-portal-capture.md` §5.3) and wrong for anything per-client. A
/// per-session anchor needs the injector to become session-aware first; don't call this from a
/// session path until it is.
///
/// The wlroots backend does **not** consult this — it aims at a named output via
/// `stream_output::set_stream_output` (Linux), which the host DOES publish per session and which
/// therefore takes exactly the last-bring-up-wins trade this warning describes: on purpose, and
/// stated in the open in that module's doc, matching the Windows `stream_target` slot that already
/// made the same call. The two are separate slots because they answer different questions and are
/// written by different owners: this anchor is the operator's host-wide capture pin, recomputed
/// from policy whenever the console writes it — which would wipe a per-session value written here —
/// while the stream output is whatever head the session's capture actually attached to.
pub fn set_absolute_anchor(anchor: Option<AbsoluteAnchor>) {
let anchor = anchor.filter(|a| !a.is_empty());
tracing::debug!(?anchor, "input: absolute-coordinate anchor set");
@@ -529,6 +538,14 @@ pub mod pen;
pub mod stream_target;
#[cfg(target_os = "windows")]
pub use stream_target::set_stream_target;
/// Linux: the streamed compositor output (by name) that absolute coordinates map into — the
/// counterpart of the Windows `stream_target` module, published by the host at capture bring-up and
/// consumed by the wlroots virtual-pointer backend, which binds its pointer to that `wl_output`.
#[cfg(target_os = "linux")]
#[path = "inject/linux/stream_output.rs"]
pub mod stream_output;
#[cfg(target_os = "linux")]
pub use stream_output::{set_stream_output, stream_output};
/// Stub — pen injection needs the Linux uinput tablet or Windows synthetic pointers;
/// `pen_supported()` is false here, so no host advertises the cap and no batches arrive.
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
+21 -4
View File
@@ -39,6 +39,14 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
/// [`SessionOpts::on_connected`]'s callback: the host's certificate fingerprint, then the
/// management-API port from its `Welcome` (`0` = it advertised none).
///
/// A named type rather than the inline `Box<dyn FnMut(...)>` because adding the second parameter
/// tipped it over `clippy::type_complexity` — factoring it out is what that lint asks for, and it
/// gives the two positional arguments somewhere to be documented.
pub type ConnectedFn = Box<dyn FnMut([u8; 32], u16)>;
pub struct SessionOpts {
pub window_title: String,
/// Start fullscreen (gamescope / `--fullscreen`).
@@ -84,9 +92,14 @@ pub struct SessionOpts {
pub allow_vrr: bool,
/// Emit the `{"ready":true}` stdout line after the first presented frame.
pub json_status: bool,
/// Called once on `Connected` with the host's fingerprint (trust persistence is the
/// binary's business — this loop stays store-agnostic).
pub on_connected: Option<Box<dyn FnMut([u8; 32])>>,
/// Called once on `Connected` with the host's fingerprint and the management-API port the
/// host reported in its `Welcome` (`0` = it advertised none). Trust persistence is the
/// binary's business — this loop stays store-agnostic.
///
/// The port rides along because this is the one moment a client is guaranteed to have it
/// WITHOUT mDNS: the session it just authenticated carries it. A client that saves it here
/// can browse the library of a host it has only ever reached by address.
pub on_connected: Option<ConnectedFn>,
/// The console-UI overlay (§6.1) — `None` is the Skia-free power-user build (stats
/// stay stdout-only). An overlay whose `init` fails degrades to `None` with a
/// warning rather than killing the session. Browse mode requires one.
@@ -1377,9 +1390,13 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
apply_capture(&mut window, &mouse, true, cap.desktop(), inhibit_shortcuts);
st.capture = Some(cap);
st.cursor_chan = Some(crate::cursor::CursorChannel::new(&c));
// Read the mgmt port BEFORE `c` is moved into `st` — the Welcome's answer to
// "where is this host's library", which the binary persists so it survives
// without ever needing an mDNS advert.
let mgmt_port = c.mgmt_port();
st.connector = Some(c);
if let Some(f) = opts.on_connected.as_mut() {
f(fingerprint);
f(fingerprint, mgmt_port);
}
if let Some(o) = overlay.as_mut() {
o.session_phase(SessionPhase::Streaming);
+87
View File
@@ -0,0 +1,87 @@
<?xml version="1.0" encoding="UTF-8"?>
<protocol name="dpms">
<copyright><![CDATA[
SPDX-FileCopyrightText: 2015 Martin Gräßlin
SPDX-License-Identifier: LGPL-2.1-or-later
]]></copyright>
<interface name="org_kde_kwin_dpms_manager" version="1">
<description summary="Output dpms manager">
The Dpms manager allows to get a org_kde_kwin_dpms for a given wl_output.
The org_kde_kwin_dpms provides the currently used VESA Display Power Management
Signaling state (see https://en.wikipedia.org/wiki/VESA_Display_Power_Management_Signaling ).
In addition it allows to request a state change. A compositor is not obliged to honor it
and will normally automatically switch back to on state.
Warning! The protocol described in this file is a desktop environment
implementation detail. Regular clients must not use this protocol.
Backward incompatible changes may be added without bumping the major
version of the extension.
</description>
<request name="get">
<description summary="Get org_kde_kwin_dpms for wl_output">
Factory request to get the org_kde_kwin_dpms for a given wl_output.
</description>
<arg name="id" type="new_id" interface="org_kde_kwin_dpms"/>
<arg name="output" type="object" interface="wl_output"/>
</request>
</interface>
<interface name="org_kde_kwin_dpms" version="1">
<description summary="Dpms for a wl_output">
This interface provides information about the VESA DPMS state for a wl_output.
It gets created through the request get on the org_kde_kwin_dpms_manager interface.
On creating the resource the server will push whether DPSM is supported for the output,
the currently used DPMS state and notifies the client through the done event once all
states are pushed. Whenever a state changes the set of changes is committed with the
done event.
</description>
<event name="supported">
<description summary="Event indicating whether DPMS is supported on the wl_output">
This event gets pushed on binding the resource and indicates whether the wl_output
supports DPMS. There are operation modes of a Wayland server where DPMS might not
make sense (e.g. nested compositors).
</description>
<arg name="supported" type="uint" summary="Boolean value whether DPMS is supported (1) for the wl_output or not (0)"/>
</event>
<enum name="mode">
<entry name="On" value="0"/>
<entry name="Standby" value="1"/>
<entry name="Suspend" value="2"/>
<entry name="Off" value="3"/>
</enum>
<event name="mode">
<description summary="Event indicating used DPMS mode">
This mode gets pushed on binding the resource and provides the currently used
DPMS mode. It also gets pushed if DPMS is not supported for the wl_output, in that
case the value will be On.
The event is also pushed whenever the state changes.
</description>
<arg name="mode" type="uint" summary="The new currently used mode"/>
</event>
<event name="done">
<description summary="All changes are pushed">
This event gets pushed on binding the resource once all other states are pushed.
In addition it gets pushed whenever a state changes to tell the client that all
state changes have been pushed.
</description>
</event>
<request name="set">
<description summary="Request DPMS state change for the wl_output">
Requests that the compositor puts the wl_output into the passed mode. The compositor
is not obliged to change the state. In addition the compositor might leave the mode
whenever it seems suitable. E.g. the compositor might return to On state on user input.
The client should not assume that the mode changed after requesting a new mode.
Instead the client should listen for the mode event.
</description>
<arg name="mode" type="uint" summary="Requested mode"/>
</request>
<request name="release" type="destructor">
<description summary="release the dpms object"/>
</request>
</interface>
</protocol>
+37
View File
@@ -79,6 +79,10 @@ pub(crate) fn emit_display_event(ev: DisplayEvent) {
#[path = "vdisplay/backend.rs"]
pub(crate) mod backend;
pub use backend::{DisplayOwnership, VirtualDisplay, VirtualOutput};
/// The NEGOTIATED ScreenCast cursor mode of a portal-backed output, reported per session by
/// [`VirtualDisplay::last_portal_cursor_mode`]. (The module itself stays private — the ladder that
/// picks the mode is this crate's business; the verdict is the caller's.)
pub use portal_cursor::Mode as PortalCursorMode;
/// Time-bounded child-process helpers — every compositor query shells out, and an unbounded one
/// can wedge the calling (session) thread forever.
@@ -824,6 +828,30 @@ pub mod admission;
#[path = "vdisplay/linux/portal_config.rs"]
mod portal_config;
/// Which ScreenCast cursor mode to REQUEST — negotiated against `AvailableCursorModes` instead of
/// hardcoded, because a mode the backend does not advertise closes the session outright.
///
/// Declared unconditionally for the same reason as `portal_config` above: the ladder is pure
/// integer work whose tests are the only place its behaviour is observable without a compositor,
/// so they should run on every platform's CI rather than only where the callers compile.
#[path = "vdisplay/linux/portal_cursor.rs"]
mod portal_cursor;
/// The line fed to xdph's custom picker to select an output headlessly.
///
/// Declared unconditionally for the same reason again: it is a wire format with no schema and no
/// error report, so the transcribed-parser tests are the only place a malformed line is visible
/// without a compositor. That is not hypothetical — a missing separator shipped, and the one
/// assertion that existed for it passed throughout.
#[path = "vdisplay/linux/portal_picker.rs"]
mod portal_picker;
/// The single, never-dropped tokio runtime the portal handshakes run on. Linux-only: it exists to
/// outlive ashpd's process-global cached D-Bus connection, and only the Linux backends speak to it.
#[cfg(target_os = "linux")]
#[path = "vdisplay/linux/portal_rt.rs"]
mod portal_rt;
#[cfg(target_os = "linux")]
#[path = "vdisplay/linux/hyprland.rs"]
mod hyprland;
@@ -839,6 +867,15 @@ mod kwin;
#[path = "vdisplay/linux/kwin_output_mgmt.rs"]
mod kwin_output_mgmt;
// DPMS control of the box's live KDE desktop (org_kde_kwin_dpms) — how a bare-spawn gamescope
// session honors `Topology::Exclusive`: the spawn is its own headless compositor, so the desktop's
// physical outputs can't be *disabled* (KWin refuses zero enabled outputs and no output there is
// ours) — they are put to DPMS-off for the stream instead, refcounted across concurrent spawns.
// Consumed by `gamescope` (best-effort, with kscreen fallback).
#[cfg(target_os = "linux")]
#[path = "vdisplay/linux/kwin_dpms.rs"]
mod kwin_dpms;
#[cfg(target_os = "windows")]
#[path = "vdisplay/windows/manager.rs"]
pub mod manager;
@@ -76,6 +76,20 @@ pub struct VirtualOutput {
/// capturer must hold frames until that renegotiation lands. Linux-only.
#[cfg(target_os = "linux")]
pub expect_exact_dims: bool,
/// The compositor's own name for this output (Hyprland's `PF-<pid>-<n>`, sway's `HEADLESS-N`,
/// a mirrored head's connector) — the Linux answer to what `win_capture` carries on Windows:
/// the identity the host needs to aim **absolute input** at the head it is streaming
/// (`pf_inject::set_stream_output`, called from `capture::capture_virtual_output`).
///
/// It is the `wl_output.name` of that head, which the protocol guarantees is the same string
/// for every client — so the injector can match it on its own Wayland connection. `None` on
/// the backends whose absolute mapping does not need it (KWin/Mutter inject through libei,
/// which selects by region; gamescope owns its whole seat).
///
/// This crate must not depend on pf-inject (see the crate doc), so the name is only CARRIED
/// here — the host publishes it.
#[cfg(target_os = "linux")]
pub output_name: Option<String>,
}
impl VirtualOutput {
@@ -101,6 +115,8 @@ impl VirtualOutput {
pool_gen: None,
#[cfg(target_os = "linux")]
expect_exact_dims: false,
#[cfg(target_os = "linux")]
output_name: None,
}
}
}
@@ -184,6 +200,33 @@ pub trait VirtualDisplay: Send {
fn hw_cursor(&self) -> bool {
false
}
/// The ScreenCast cursor mode the backend's portal actually NEGOTIATED for the most recent
/// [`create`](Self::create) — the answer to [`set_hw_cursor`](Self::set_hw_cursor), which is
/// only ever a *request*.
///
/// This is the difference between the two that matters downstream: on the whole wlr family
/// (xdph, xdpw) `AvailableCursorModes` is `Hidden|Embedded`, so a session that asked for
/// metadata is served **`Embedded`** — the compositor paints the pointer into the frames and
/// sends no `SPA_META_Cursor`, ever, wherever the pointer is. A consumer that reads "no cursor
/// overlay" as a symptom (the host's park schedule reads it as "the seat pointer has not
/// reached the streamed output" — true on Mutter, which suppresses metadata while the pointer
/// is off the recorded view) is then acting on noise; see
/// [`PortalCursorMode::delivers_metadata`](crate::PortalCursorMode::delivers_metadata).
///
/// `None` — the default, and what every non-portal backend reports — means "nothing was
/// negotiated through the xdg ScreenCast portal here, so this says nothing at all": KWin
/// (`zkde_screencast` `pointer` mode), Mutter (`RecordVirtual` `cursor-mode`), gamescope (no
/// pointer either way) and Windows (IddCx) all get exactly what they ask for through their own
/// protocols, and their consumers must keep behaving as they always did. It is also `None`
/// before the first `create`.
///
/// Reported by the wlr-family backends (`hyprland`, `wlroots`) and by the monitor
/// [`mirror`](crate::open_mirror) when it delegates to one. Those outputs are never registry-
/// pooled (`remote_fd.is_some()` — the portal fd cannot be re-opened per attach), so a reused
/// kept display can never hand back a *stale* answer here.
fn last_portal_cursor_mode(&self) -> Option<crate::PortalCursorMode> {
None
}
/// The stable identity slot the backend resolved for the most recent [`create`](Self::create) —
/// the per-client id the identity policy assigned (`Some`), or `None` for shared/anonymous. The
/// registry reads it right after `create` to key the display's group **arrangement** (manual
@@ -69,6 +69,11 @@ pub struct GamescopeDisplay {
/// the decision and this session's `create`. `None` = nothing resolved it (a caller that never
/// ran `apply_input_env`); `create` then falls through to the bare spawn, the safe default.
route: Option<crate::GamescopeRoute>,
/// The topology-restore action the bare-spawn `create` prepared under `Topology::Exclusive` —
/// the release of this display's [`crate::kwin_dpms`] darken hold — pending pickup by the
/// registry via [`VirtualDisplay::take_topology_restore`], so it runs at the display's
/// teardown (§6.1) and never before.
pending_restore: Option<Box<dyn FnOnce() + Send>>,
}
/// A running host-managed session (its transient systemd --user unit) + the mode it was launched at.
@@ -441,6 +446,14 @@ impl VirtualDisplay for GamescopeDisplay {
self.route = route;
}
fn take_topology_restore(&mut self) -> Option<Box<dyn FnOnce() + Send>> {
// The DPMS darken-hold release the bare-spawn `create` registered (Exclusive topology
// only). The registry stores it on this display's entry and runs it at teardown — which,
// for gamescope, is the display's OWN teardown: every spawn is its own group, and the
// cross-session ordering lives in `kwin_dpms`'s refcount, not in the group float.
self.pending_restore.take()
}
fn poolable_now(&self) -> bool {
// Only a bare SPAWN is registry-poolable (its `create` reports `Owned`); Managed and
// Attach report `SessionManaged`/`External`, so the registry must not reuse a kept spawn
@@ -512,6 +525,9 @@ impl VirtualDisplay for GamescopeDisplay {
reused_gen: None,
pool_gen: None,
expect_exact_dims: false,
// gamescope owns its own seat and injects through its EIS socket, not the wlr
// virtual pointer (`point_injector_at_eis`) — nothing here to aim by name.
output_name: None,
});
}
check_gamescope_version(); // diagnostic only — warns on known-deadlock-prone versions
@@ -576,6 +592,23 @@ impl VirtualDisplay for GamescopeDisplay {
hz = mode.refresh_hz,
"gamescope virtual output ready"
);
// `Topology::Exclusive`, bare-spawn edition: this spawn is its OWN headless compositor —
// nothing above touched the box's live desktop (KWin), which would otherwise keep driving
// the physical panel with the idle desktop for the whole stream. The KWin route disables
// the physicals outright, but that door is closed here (KWin refuses zero enabled outputs,
// and no output on that desktop is ours to leave enabled) — so the desktop's panels go to
// DPMS-off instead, best-effort and self-gating (a box with no KDE desktop declines
// quietly inside `kwin_dpms`). Placed AFTER the spawn succeeded, so a failed create never
// blanks the user's screen. The hold is refcounted in `kwin_dpms` rather than floated
// through the registry's group restore, because every gamescope spawn is its own group
// (`registry::group_key`) — the float alone would re-light the panel when the FIRST of two
// concurrent spawns ends, under the second's still-live stream. Skipped for Managed (its
// takeover already stopped the desktop) and Attach (it mirrors a gamescope that may itself
// be driving the physical panel) — both returned earlier in this function.
if crate::effective_topology() == crate::policy::Topology::Exclusive {
crate::kwin_dpms::acquire_stream_darken();
self.pending_restore = Some(Box::new(crate::kwin_dpms::release_stream_darken));
}
// Bare SPAWN: we own the nested gamescope process → registry-poolable (keep-alive-able).
Ok(VirtualOutput::owned(
node_id,
@@ -688,6 +721,9 @@ fn create_managed_session(client: &str, mode: Mode, hdr: bool) -> Result<Virtual
reused_gen: None,
pool_gen: None,
expect_exact_dims: false,
// gamescope owns its own seat and injects through its EIS socket, not the wlr
// virtual pointer (`point_injector_at_eis`) — nothing here to aim by name.
output_name: None,
});
}
// B1b: a desktop-session Steam (outside any gamescope unit) also holds the single instance and
@@ -804,6 +840,9 @@ fn managed_output(node_id: u32, mode: Mode) -> VirtualOutput {
reused_gen: None,
pool_gen: None,
expect_exact_dims: false,
// gamescope owns its own seat and injects through its EIS socket, not the wlr
// virtual pointer (`point_injector_at_eis`) — nothing here to aim by name.
output_name: None,
}
}
@@ -3565,6 +3604,9 @@ pub(crate) fn stream_existing_output(
Ok(crate::mirror::MirrorStream {
node_id,
remote_fd: None,
// No xdg portal in this path at all (gamescope publishes the node itself), and no pointer
// in the node either way — nothing to report.
cursor_mode: None,
keepalive: Box::new(()),
})
}
+279 -90
View File
@@ -16,10 +16,12 @@
//! 3. The xdg ScreenCast portal (served by **xdph**) yields the output's PipeWire node. There is
//! no GUI to pick an output headlessly, so xdph is steered through its **custom picker**: a
//! managed config (`~/.config/hypr/xdph.conf`) points `screencopy:custom_picker_binary` at a tiny
//! installed shim that cats a per-session selection file we write (`[SELECTION]screen:<NAME>`)
//! right before the handshake — byte-for-byte the xdpw pattern, xdph's picker wire format.
//! 4. Teardown is RAII: drop stops the portal thread (its zbus connection ends the cast) and runs
//! `hyprctl output remove NAME`.
//! installed shim that cats a per-session selection file we write right before the handshake —
//! `[SELECTION]/screen:<NAME>`, whose leading `/` is xdph's mandatory empty-flags separator (see
//! [`crate::portal_picker`], which owns the format and its tests).
//! 4. Teardown is RAII **and ordered**: drop closes the ScreenCast session and WAITS for the portal
//! to confirm it, and only then runs `hyprctl output remove NAME`. Removing the output first is
//! what made every stream after the first one fail on Hyprland — see [`StopGuard`].
//!
//! Requirements: the host runs inside (or can reach) the Hyprland session — either
//! `HYPRLAND_INSTANCE_SIGNATURE` is inherited, or [`is_available`] discovers it from
@@ -27,7 +29,8 @@
//! the ScreenCast interface routed to xdph (`scripts/headless/portals.conf`).
//!
//! Contracts verified on **Hyprland 0.55.4 + xdph 1.3.x** (`design/hyprland-support.md` Phase 0):
//! `hyprctl` subcommands / JSON shapes, the `[SELECTION]screen:<name>` picker format, the
//! `hyprctl` subcommands / JSON shapes, the `[SELECTION]/screen:<name>` picker format (re-derived
//! from xdph 1.3.12's own parser on 2026-08-14, which is when the missing `/` turned up), the
//! `~/.config/hypr/xdph.conf` path + `screencopy:custom_picker_binary` key, and that `eval` needs
//! the Lua config manager. Not yet exercised end-to-end on real DRM hardware: a headless output's
//! GBM/dmabuf allocation (fails on a nested/NVIDIA test box — Sunshine#4197); `set_monitor_rule`
@@ -44,7 +47,7 @@ use std::thread;
use std::time::{Duration, Instant};
/// Per-session file the xdph custom picker reads the selected output from. We write
/// `screen:<NAME>\n` here right before the portal handshake selects sources. Lives under
/// [`picker_selection_line`] here right before the portal handshake selects sources. Lives under
/// `$XDG_RUNTIME_DIR` (per-user, 0700) — NOT a world-writable /tmp path another local user could
/// pre-create or rewrite between our write and xdph's read (steer capture elsewhere). Mirrors the
/// wlroots chooser file.
@@ -61,13 +64,11 @@ fn picker_shim_path() -> String {
format!("{dir}/punktfunk-xdph-picker.sh")
}
/// The picker line for output `name`. Verified against xdph 1.3.x / hyprland-share-picker on
/// Hyprland 0.55.4: xdph reads the custom picker's stdout and requires the `[SELECTION]` marker
/// followed by `screen:<name>` (or `window:<addr>` / `region:…`); anything else is rejected as
/// "strange output" and falls back to the interactive picker. So a monitor selection is
/// `[SELECTION]screen:<name>`.
/// The picker line for output `name` — `[SELECTION]/screen:<name>`, whose every byte is load-bearing.
/// Lives in [`crate::portal_picker`] with a transcription of xdph's parser, because it is a wire
/// format with no error report and this file only compiles on Linux.
fn picker_selection_line(name: &str) -> String {
format!("[SELECTION]screen:{name}\n")
crate::portal_picker::selection_line(name)
}
/// Monotonic per-process counter for headless output names (`PF-<pid>-1`, `PF-<pid>-2`, …). Named
@@ -115,18 +116,34 @@ fn output_owner_pid(name: &str) -> Option<u32> {
/// The Hyprland virtual-display driver. Stateless — each [`create`](VirtualDisplay::create) adds one
/// named headless output and spins up a portal thread owning the cast on it.
pub struct HyprlandDisplay {
/// Out-of-band cursor request (`set_hw_cursor`, the negotiated cursor channel): portal
/// Out-of-band cursor request (`set_hw_cursor`, the negotiated cursor channel): PREFER portal
/// `CursorMode::Metadata` — shapes/positions ride `SPA_META_Cursor` for the channel + the
/// composite blend. Off (every non-channel session): `Embedded` — the compositor paints the
/// pointer into frames, zero host-side cursor work (the pre-channel default this backend
/// always had). ⚠️ Metadata is UNTESTED on-glass for this backend (Phase B wired it so the
/// channel isn't silently dead here; KWin/Mutter are the validated legs).
/// composite blend. Off (every non-channel session): prefer `Embedded` — the compositor paints
/// the pointer into frames, zero host-side cursor work (the pre-channel default this backend
/// always had).
///
/// Both are only a PREFERENCE: [`crate::portal_cursor`] settles it against what xdph actually
/// advertises, because requesting an unadvertised mode makes xdg-desktop-portal fail the call.
/// This used to be asserted instead, which is exactly how a cursor-forward session here became
/// a black client.
///
/// ⚠️ On current xdph the metadata arm is UNREACHABLE, not merely untested: measured on .21
/// 2026-08-14 (Hyprland 0.56.2, xdph 1.4.1) `AvailableCursorModes` = 3 — `Hidden|Embedded`
/// only. Every session on this backend therefore resolves to `Embedded` today; KWin/Mutter
/// remain the legs where the metadata channel is actually exercised.
hw_cursor: bool,
/// What the portal actually gave us on the most recent [`create`](VirtualDisplay::create) — see
/// [`VirtualDisplay::last_portal_cursor_mode`], which is how the host learns that a cursor
/// overlay is never coming instead of inferring it from an absence.
last_cursor_mode: Option<crate::portal_cursor::Mode>,
}
impl HyprlandDisplay {
pub fn new() -> Result<Self> {
Ok(HyprlandDisplay { hw_cursor: false })
Ok(HyprlandDisplay {
hw_cursor: false,
last_cursor_mode: None,
})
}
}
@@ -192,6 +209,10 @@ impl VirtualDisplay for HyprlandDisplay {
self.hw_cursor
}
fn last_portal_cursor_mode(&self) -> Option<crate::PortalCursorMode> {
self.last_cursor_mode
}
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
// Log the permission-system caveat once per process (silent black frames otherwise).
preflight_once();
@@ -215,16 +236,21 @@ impl VirtualDisplay for HyprlandDisplay {
// thread (it parks to keep the cast alive, like the other backends). Serialized: the
// selection is one per-user file, so a concurrent session's write between ours and xdph's
// read would silently capture the wrong output (see `SELECTION_LOCK`).
let (fd, node_id, stop) = {
let (fd, node_id, cursor_mode, stop) = {
let _sel = SELECTION_LOCK.lock().unwrap_or_else(|e| e.into_inner());
select_and_cast(&name, self.hw_cursor)?
};
// Latched for `last_portal_cursor_mode`: on today's xdph this is `embedded` whatever we
// asked for, and the session's whole cursor behaviour follows from that fact rather than
// from `hw_cursor`.
self.last_cursor_mode = Some(cursor_mode);
tracing::info!(
node_id,
output = %name,
w = mode.width,
h = mode.height,
hz = mode.refresh_hz,
cursor = cursor_mode.name(),
"hyprland headless output ready"
);
Ok(VirtualOutput {
@@ -242,24 +268,100 @@ impl VirtualDisplay for HyprlandDisplay {
reused_gen: None,
pool_gen: None,
expect_exact_dims: false,
// Hyprland is an EXTEND topology: this head sits BESIDE the operator's, so absolute
// input has to be aimed at it by name or it lands on their screen. `hyprctl`'s monitor
// name is the head's `wl_output.name`, which is what the injector matches.
output_name: Some(name),
})
}
}
/// Drop order matters: stop the portal thread first (zbus connection drop ends the cast), then
/// remove the output (fields drop in declaration order).
/// Drop order matters, and it is the whole fix: [`StopGuard`] **blocks until the ScreenCast session
/// is actually closed**, and only then does [`OutputGuard`] remove the compositor output (fields drop
/// in declaration order).
///
/// 🛑 THIS ORDERING USED TO BE A LIE. `StopGuard::drop` only set an atomic and returned, while the
/// portal thread noticed it 200 ms later — so `OutputGuard::drop` ran `hyprctl output remove` on an
/// output xdph was still actively capturing, every single teardown. See [`StopGuard`] for what that
/// did to xdph.
struct Keepalive {
_stop: StopGuard,
_output: OutputGuard,
}
/// Dropping this ends the portal keepalive thread, closing its zbus connection — the portal then
/// tears the screencast session down.
struct StopGuard(Arc<AtomicBool>);
/// How long teardown waits for the portal to confirm the ScreenCast session is closed before giving
/// up and removing the output anyway. One D-Bus round trip through xdg-desktop-portal to xdph; three
/// seconds is generous. Bounded on purpose: a portal that has already wedged must not be able to
/// wedge the host's teardown with it — every other blocking helper on this path is bounded the same
/// way (see [`HYPRCTL_BUDGET`]).
const CAST_CLOSE_BUDGET: Duration = Duration::from_secs(3);
/// Ends the cast: signals the portal thread, then **waits for it to have closed the ScreenCast
/// session**, so the caller may safely remove the output afterwards.
///
/// 🛑 THE WAIT IS THE POINT — "only the first stream after a portal start works" on Hyprland was
/// this, root-caused 2026-08-14 against Hyprland 0.55.4 + xdph 1.3.12 + xdg-desktop-portal 1.20.4.
///
/// This used to be a bare `AtomicBool` that `drop` merely SET. The portal thread polled it every
/// 200 ms and then just dropped its zbus connection, and xdph destroys a session on exactly one
/// event — an explicit `org.freedesktop.impl.portal.Session.Close` (`Session.cpp:37`,
/// `onCloseSession`); it has no peer-vanished watcher of its own. The frontend does have one
/// (`xdg-desktop-portal.c:230` `peer_died_cb` → `close_sessions_for_sender`), but it only fires once
/// our unique bus name goes away, which is *after* the 200 ms poll, and it runs asynchronously on a
/// GTask thread. Meanwhile `OutputGuard::drop` had already removed the output — synchronously,
/// microseconds after the flag was set.
///
/// So every teardown destroyed the `wl_output` out from under a live screencopy session. xdph's next
/// `Start` then built a PipeWire stream against that wreckage and fell into
///
/// ```text
/// while (pSession->sharingData.nodeID == SPA_ID_INVALID) { // Screencopy.cpp:307-313
/// int ret = pw_loop_iterate(g_pPortalManager->m_sPipewire.loop, 0); // timeout 0 = NON-blocking
/// ```
///
/// — an unbounded hot spin on xdph's ONLY event-loop thread, inside the `Start` handler, holding its
/// `m_mEventLock`. From that moment xdph answers no D-Bus, no Wayland and no PipeWire, ever again, and
/// every later `select_and_cast` dies on our 20 s timeout. MEASURED on the box: the wedged instance's
/// unit reported `Consumed 3min 51.971s CPU time over 23min 41.092s wall clock`, and there were
/// exactly 232.7 s of wall clock between its last log flush and its restart — 231.971 s of CPU
/// against 232.7 s of wall, i.e. one core pinned solid for precisely the wedged interval.
///
/// Waiting here closes that window: `Session.Close` is answered synchronously by the frontend
/// (`xdp-session.c:217` `handle_close` → `xdp_session_close` →
/// `xdp_dbus_impl_session_call_close_sync`), so by the time `close()` returns, xdph has already run
/// `destroyStream` and logged `Session destroyed`. The output we remove next is one nobody is
/// capturing.
struct StopGuard {
stop: Arc<AtomicBool>,
/// Signalled by the portal thread once it has closed the ScreenCast session.
///
/// `None` on every path where no cast was ever established (a rejected or timed-out handshake):
/// there is nothing to close, and a portal that just failed to answer for 20 s is precisely the
/// one that would burn the whole budget here for nothing.
closed: Option<std::sync::mpsc::Receiver<()>>,
}
impl Drop for StopGuard {
fn drop(&mut self) {
self.0.store(true, Ordering::Relaxed);
self.stop.store(true, Ordering::Relaxed);
let Some(closed) = self.closed.take() else {
return;
};
match closed.recv_timeout(CAST_CLOSE_BUDGET) {
// Closed — xdph has torn the capture down, the output is safe to remove.
Ok(()) => {}
// The thread is gone without confirming (it panicked, or the runtime died). Nothing is
// holding the cast either way, so there is nothing left to wait for.
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {}
// Still going after the budget. Fall through and remove the output anyway — a leaked
// output is worse than a racy one — but say so, because this is the state that wedges
// xdph and the next session will be the one that pays for it.
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => tracing::warn!(
budget_s = CAST_CLOSE_BUDGET.as_secs(),
"the ScreenCast session did not close in time — removing the output underneath it, \
which is what wedges xdph's frame loop; the next cast may find the portal busy"
),
}
}
}
@@ -340,6 +442,12 @@ impl Drop for OutputGuard {
/// stream thread, whose only way to end a session is to return, so one hung query used to wedge the
/// session for good. Generous next to a healthy call (single-digit milliseconds), and every call
/// site already has a failed-query path.
/// Ceiling on the whole ScreenCast handshake (`create_session` → `select_sources` → `start` →
/// `open_pipe_wire_remote`). Deliberately under [`select_and_cast`]'s 20 s wait so a stuck portal is
/// reported by the thread that owns it, with a reason, instead of the caller timing out on it — and,
/// far more importantly, so that thread EXITS. See the note at the handshake itself.
const HANDSHAKE_BUDGET: Duration = Duration::from_secs(15);
const HYPRCTL_BUDGET: Duration = Duration::from_secs(5);
/// Budget for the one-shot xdph restart. `systemctl --user try-restart` waits for the user manager's
@@ -392,19 +500,31 @@ impl Drop for SelectionFile {
/// Point xdph's custom picker at `output` and run the ScreenCast handshake, returning the portal fd
/// + node id and the guard that stops the cast. The caller must hold [`SELECTION_LOCK`].
fn select_and_cast(output: &str, hw_cursor: bool) -> Result<(OwnedFd, u32, StopGuard)> {
fn select_and_cast(
output: &str,
hw_cursor: bool,
) -> Result<(OwnedFd, u32, crate::portal_cursor::Mode, StopGuard)> {
ensure_xdph_config()?;
let sel = selection_file();
std::fs::write(&sel, picker_selection_line(output)).with_context(|| format!("write {sel}"))?;
// Owned from the write on: every arm below (and every `?`) leaves the handshake, which is the
// only thing that reads it.
let _sel_file = SelectionFile(sel);
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<(OwnedFd, u32), String>>();
// The NEGOTIATED cursor mode rides back with the fd and node id: it is decided inside the
// portal thread (only there is the proxy to ask), and nothing downstream can re-derive it —
// `hw_cursor` is the request, not the answer.
let (setup_tx, setup_rx) =
std::sync::mpsc::channel::<Result<(OwnedFd, u32, crate::portal_cursor::Mode), String>>();
// The teardown handshake: the thread signals this once it has closed the ScreenCast session, and
// `StopGuard::drop` waits on it before the output is removed (see `StopGuard`). Kept a SEPARATE
// channel from the setup one above — it fires at the other end of the cast's life, long after
// `setup_rx` has been consumed.
let (closed_tx, closed_rx) = std::sync::mpsc::channel::<()>();
let stop = Arc::new(AtomicBool::new(false));
let stop_thread = stop.clone();
thread::Builder::new()
.name("punktfunk-hypr-cast".into())
.spawn(move || portal_thread(setup_tx, stop_thread, hw_cursor))
.spawn(move || portal_thread(setup_tx, closed_tx, stop_thread, hw_cursor))
.context("spawn hyprland portal thread")?;
// Built BEFORE the wait so EVERY error arm below sets the flag on its way out — as Mutter's
// `create` does. Returning the bare `Arc` and letting the CALLER wrap it left the two failure
@@ -413,9 +533,14 @@ fn select_and_cast(output: &str, hw_cursor: bool) -> Result<(OwnedFd, u32, StopG
// parks forever on `while !stop`, holding a live ScreenCast session, its zbus connection, an
// `OwnedFd` and a 2-worker tokio runtime — one more set per slow-portal connect, for the host's
// lifetime, against an output that no longer exists.
let guard = StopGuard(stop);
let mut guard = StopGuard { stop, closed: None };
match setup_rx.recv_timeout(Duration::from_secs(20)) {
Ok(Ok((fd, node_id))) => Ok((fd, node_id, guard)),
Ok(Ok((fd, node_id, cursor_mode))) => {
// A cast exists now, so teardown has something to close and must wait for it. Only this
// arm arms the wait: see the field note on `StopGuard::closed`.
guard.closed = Some(closed_rx);
Ok((fd, node_id, cursor_mode, guard))
}
Ok(Err(e)) => bail!("ScreenCast portal on {output} failed: {e}"),
Err(_) => bail!("timed out waiting for the ScreenCast portal on {output}"),
}
@@ -431,10 +556,11 @@ pub(crate) fn stream_existing_output(
hw_cursor: bool,
) -> Result<crate::mirror::MirrorStream> {
let _sel = SELECTION_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let (fd, node_id, stop) = select_and_cast(connector, hw_cursor)?;
let (fd, node_id, cursor_mode, stop) = select_and_cast(connector, hw_cursor)?;
Ok(crate::mirror::MirrorStream {
node_id,
remote_fd: Some(fd),
cursor_mode: Some(cursor_mode),
keepalive: Box::new(stop),
})
}
@@ -784,30 +910,24 @@ fn ensure_xdph_config() -> Result<()> {
/// custom picker, no dialog. (Kept separate from wlroots' copy so each wlr-family backend stays
/// self-owned per D1; unify if they ever diverge no further.)
fn portal_thread(
setup_tx: Sender<Result<(OwnedFd, u32), String>>,
setup_tx: Sender<Result<(OwnedFd, u32, crate::portal_cursor::Mode), String>>,
closed_tx: Sender<()>,
stop: Arc<AtomicBool>,
hw_cursor: bool,
) {
// Portal cursor mode per the session's channel negotiation (see the struct doc).
let cursor_mode = if hw_cursor {
CursorMode::Metadata
} else {
CursorMode::Embedded
};
use ashpd::desktop::screencast::{CursorMode, Screencast, SelectSourcesOptions, SourceType};
use ashpd::desktop::screencast::{Screencast, SelectSourcesOptions, SourceType};
use ashpd::desktop::PersistMode;
use ashpd::enumflags2::BitFlags;
// Multi-thread runtime: the zbus background reader must be pumped across the
// create_session → select_sources → start handshake (see capture/linux.rs).
let rt = match tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
{
// 🛑 The SHARED, never-dropped runtime — NOT a per-cast one. ashpd caches its D-Bus connection
// process-globally, and a per-cast runtime takes that connection's background reader down with
// it when the cast ends, leaving every later handshake in this process awaiting a reply nothing
// is alive to read. That is the whole "the first stream works, the rest are black" bug. See
// [`crate::portal_rt`] for the measurement.
let rt = match crate::portal_rt::portal_runtime() {
Ok(rt) => rt,
Err(e) => {
let _ = setup_tx.send(Err(format!("build tokio runtime: {e}")));
let _ = setup_tx.send(Err(e));
return;
}
};
@@ -815,54 +935,122 @@ fn portal_thread(
rt.block_on(async move {
let result: Result<()> = async {
let proxy = Screencast::new().await.context(
"connect ScreenCast portal (is xdg-desktop-portal running with the hyprland backend/xdph?)",
)?;
let session = proxy
.create_session(Default::default())
.await
.context("create_session")?;
proxy
.select_sources(
&session,
SelectSourcesOptions::default()
.set_cursor_mode(cursor_mode)
// xdph offers MONITOR; the custom picker selects our output.
.set_sources(BitFlags::from_flag(SourceType::Monitor))
.set_multiple(false)
.set_persist_mode(PersistMode::DoNot),
// Inside the bound below, deliberately: when the cached connection was orphaned this is
// where the thread hung — `Screencast::new()` itself, before a single handshake call —
// and a bound that started after it reported the caller's generic timeout instead.
let connect = async {
Screencast::new().await.context(
"connect ScreenCast portal (is xdg-desktop-portal running with the hyprland backend/xdph?)",
)
.await
.context("select_sources")?
.response()
.context("select_sources rejected")?;
let streams = proxy
.start(&session, None, Default::default())
.await
.context("start cast")?
.response()
.context("start response (custom picker declined? check the xdph config/shim/selection file)")?;
let stream = streams
.streams()
.first()
.context("portal returned no streams")?
.clone();
let node_id = stream.pipe_wire_node_id();
let fd = proxy
.open_pipe_wire_remote(&session, Default::default())
.await
.context("open_pipe_wire_remote")?;
};
let proxy = match tokio::time::timeout(HANDSHAKE_BUDGET, connect).await {
Ok(v) => v?,
Err(_) => bail!(
"connecting to the ScreenCast portal did not return within {}s",
HANDSHAKE_BUDGET.as_secs()
),
};
// NEGOTIATED against what xdph advertises, never asserted from `hw_cursor` alone: a
// cursor mode the backend does not offer does not degrade — xdg-desktop-portal's
// FRONTEND fails the call ("Unavailable cursor mode %x") before xdph sees it.
// MEASURED on .21 2026-08-14, Hyprland 0.56.2 + xdph 1.4.1 (both current):
// `AvailableCursorModes` = 3 (Hidden|Embedded) — metadata is NOT offered. So the old
// hardcode killed EVERY cursor-forward session here, on today's packages, not just on
// old installs: `unavailable cursor mode 4`, "pipeline build failed", black client.
let cursor_mode = crate::portal_cursor::negotiate(&proxy, hw_cursor, "xdph").await;
// 🛑 BOUNDED, and that bound is load-bearing. `select_sources`/`start` await a D-Bus
// reply a wedged portal never sends, and an await that never returns CANNOT be cancelled
// by the `stop` flag — the thread never reaches the park loop that reads it. That is how
// one host accumulated NINE live cast threads (28 tokio workers) on 2026-08-14: each
// timed-out attempt left one behind holding a half-created portal session on this
// process's shared D-Bus connection, and from the first hang onwards EVERY later request
// from this process hung too — while a freshly-spawned process talking to the very same
// portal completed the identical handshake fine. Shorter than the caller's 20 s wait, so
// the failure is reported HERE with a reason instead of surfacing as a bare timeout.
let handshake = async {
let session = proxy
.create_session(Default::default())
.await
.context("create_session")?;
proxy
.select_sources(
&session,
SelectSourcesOptions::default()
.set_cursor_mode(cursor_mode.to_ashpd())
// xdph offers MONITOR; the custom picker selects our output.
.set_sources(BitFlags::from_flag(SourceType::Monitor))
.set_multiple(false)
.set_persist_mode(PersistMode::DoNot),
)
.await
.context("select_sources")?
.response()
.context("select_sources rejected")?;
let streams = proxy
.start(&session, None, Default::default())
.await
.context("start cast")?
.response()
.context("start response (custom picker declined? check the xdph config/shim/selection file)")?;
let stream = streams
.streams()
.first()
.context("portal returned no streams")?
.clone();
let node_id = stream.pipe_wire_node_id();
let fd = proxy
.open_pipe_wire_remote(&session, Default::default())
.await
.context("open_pipe_wire_remote")?;
Ok::<_, anyhow::Error>((session, fd, node_id))
};
let (session, fd, node_id) =
match tokio::time::timeout(HANDSHAKE_BUDGET, handshake).await {
Ok(v) => v?,
Err(_) => bail!(
"the ScreenCast portal did not complete the handshake within {}s — \
abandoning it instead of parking this thread on it forever (a hung \
request poisons every later one from this process)",
HANDSHAKE_BUDGET.as_secs()
),
};
setup_tx
.send(Ok((fd, node_id)))
.send(Ok((fd, node_id, cursor_mode)))
.map_err(|_| anyhow!("virtual-output opener went away"))?;
// Park, keeping `proxy` + `session` (the zbus connection) alive until stopped — the cast
// is torn down when the connection drops.
// Park, keeping `proxy` + `session` alive until stopped. Polled at 20 ms rather than the
// 200 ms this used to use, because the teardown now WAITS on what follows — every
// millisecond here is a millisecond of stream teardown.
let _keep_alive = (&proxy, &session);
while !stop.load(Ordering::Relaxed) {
tokio::time::sleep(Duration::from_millis(200)).await;
tokio::time::sleep(Duration::from_millis(20)).await;
}
// 🛑 CLOSE THE SESSION, AND CLOSE IT *BEFORE* THE OUTPUT GOES AWAY. Dropping the
// connection and trusting the peer to notice is what this used to do, and it is not the
// contract: xdph destroys a session only on an explicit
// `org.freedesktop.impl.portal.Session.Close` (`Session.cpp:37`). The caller is blocked
// in `StopGuard::drop` waiting for the signal below, and only removes the compositor
// output afterwards — that ordering is the whole fix; see `StopGuard`.
//
// Bounded: `close()` goes through xdg-desktop-portal to xdph, and an already-wedged xdph
// never answers. Timing out here still signals, so teardown pays the budget once and
// moves on rather than hanging on a portal that is already gone.
match tokio::time::timeout(CAST_CLOSE_BUDGET, session.close()).await {
Ok(Ok(())) => {}
Ok(Err(e)) => tracing::warn!(
error = %e,
"closing the ScreenCast session failed — the next cast may find xdph busy"
),
Err(_) => tracing::warn!(
budget_s = CAST_CLOSE_BUDGET.as_secs(),
"the ScreenCast portal did not answer Session.Close in time — it is probably \
already wedged"
),
}
// Release the teardown. Best-effort: the receiver is gone if the caller already gave up.
let _ = closed_tx.send(());
Ok(())
}
.await;
@@ -918,9 +1106,10 @@ mod tests {
}
}
/// The backend hands the picker exactly what [`crate::portal_picker`] says — that module owns the
/// format and its xdph-parser tests, which run on every platform rather than only this leg.
#[test]
fn picker_line_carries_the_selection_marker() {
// xdph requires the `[SELECTION]` prefix; a bare `screen:NAME` is rejected as strange output.
assert_eq!(picker_selection_line("PF-1"), "[SELECTION]screen:PF-1\n");
fn picker_line_is_the_shared_selection_format() {
assert_eq!(picker_selection_line("PF-1"), "[SELECTION]/screen:PF-1\n");
}
}
@@ -704,7 +704,7 @@ fn kscreen_ok(args: &[String]) -> bool {
/// before exiting, so a slow-but-working KWin gives us a kill on a request that already landed;
/// any caller that treats `None` as "it failed" is asserting something it does not know, and for
/// the restore path that assertion costs a monitor its refresh rate.
fn kscreen_verdict(args: &[String]) -> Option<bool> {
pub(crate) fn kscreen_verdict(args: &[String]) -> Option<bool> {
match crate::proc::status_within(
std::process::Command::new("kscreen-doctor").args(args),
KSCREEN_BUDGET,
@@ -1467,6 +1467,9 @@ pub(crate) fn stream_existing_output(
node_id,
// KWin publishes on the user's own PipeWire daemon — no portal remote to carry.
remote_fd: None,
// Not an xdg-portal session either: the `zkde_screencast` pointer mode was asked of KWin
// directly and KWin honours it, so the request IS the answer.
cursor_mode: None,
keepalive: Box::new(StopOnDrop(stop)),
})
}
@@ -0,0 +1,675 @@
//! DPMS control of the box's live KDE desktop (`org_kde_kwin_dpms`) — how a bare-spawn gamescope
//! session honors [`Topology::Exclusive`](crate::policy::Topology::Exclusive).
//!
//! A bare spawn is its OWN headless compositor: nothing on that route touches the desktop the box
//! is showing, so on a KDE machine the physical panel keeps displaying the (idle) desktop for the
//! whole stream — while the same `exclusive` policy on the KWin route turns the physicals off
//! outright. The KWin route's mechanism is closed to us here: KWin refuses an output configuration
//! with ZERO enabled outputs, and a gamescope session has no KWin output of its own to leave
//! enabled. DPMS is the honest translation of `exclusive` for this route — the desktop stays
//! exactly where it is (no topology churn, no window re-homing), the panels go dark, and any
//! LOCAL input wakes them, which is the right answer for a desktop someone can walk up to.
//! Stream input never wakes them: it is injected into the nested gamescope's own EIS socket and
//! does not pass through KWin.
//!
//! Driven in-process over the compositor's own Wayland (`Connection::connect_to_env`, the same
//! stack as [`crate::kwin_output_mgmt`] and for the same reason: `kscreen-doctor` rides a separate
//! libkscreen/KDED layer that can be wedged while KWin itself answers fine), with a
//! `kscreen-doctor --dpms` shell-out fallback. Best-effort everywhere — a box with no Wayland
//! session, or a non-KDE desktop, declines quietly and the stream proceeds with the panel lit,
//! exactly as before this module existed.
//!
//! **The hold is refcounted here, NOT floated through the registry's per-group restore.** Every
//! gamescope spawn is its own display group (`registry::group_key` — deliberately, they are
//! independent nested sessions), so the §6.1 group machinery alone would run the FIRST session's
//! restore at that session's teardown and re-light the panel under a second, still-streaming
//! session. Instead each exclusive spawn takes one [`acquire_stream_darken`] hold (the 0→1 edge
//! darkens) and registers [`release_stream_darken`] as its per-display topology restore (the 1→0
//! edge re-lights) — the same shape as `sleep_inhibit`'s refcount, riding the registry only for
//! the *timing* of each release.
//!
//! Crash safety comes free: DPMS is non-persistent, so a host that dies holding the panel dark
//! leaves nothing to journal — the screen re-lights on the next local input or compositor
//! restart. (Contrast the Windows `pnp_disable_monitors` path, which needs a recovery journal
//! precisely because its disable survives everything.)
use std::collections::HashMap;
use std::os::fd::{AsFd, AsRawFd};
use std::sync::Mutex;
use std::time::{Duration, Instant};
use wayland_client::protocol::wl_callback::{self, WlCallback};
use wayland_client::protocol::wl_output::{self, WlOutput};
use wayland_client::protocol::wl_registry::{self, WlRegistry};
use wayland_client::{Connection, Dispatch, Proxy, QueueHandle};
// Client bindings for the vendored KDE dpms protocol (`protocols/dpms.xml`), generated inline like
// the two in `kwin_output_mgmt`. Self-contained: its only foreign object type is the core
// `wl_output`, which `wayland_client::protocol` already provides.
#[allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)]
pub mod protocol {
use wayland_client;
use wayland_client::protocol::*;
pub mod __interfaces {
use wayland_client::protocol::__interfaces::*;
wayland_scanner::generate_interfaces!("protocols/dpms.xml");
}
use self::__interfaces::*;
wayland_scanner::generate_client_code!("protocols/dpms.xml");
}
use protocol::org_kde_kwin_dpms::{Event as DpmsEvent, OrgKdeKwinDpms as Dpms};
use protocol::org_kde_kwin_dpms_manager::OrgKdeKwinDpmsManager as DpmsManager;
// The wire enum `org_kde_kwin_dpms.mode`. The XML types the `mode` request/event args as plain
// `uint` (no `enum=` attribute), so the generated signatures take/deliver `u32` — these constants
// are the protocol's values, kept in sync with the vendored `dpms.xml`.
const DPMS_MODE_ON: u32 = 0;
const DPMS_MODE_OFF: u32 = 3;
/// `org_kde_kwin_dpms_manager` is a frozen v1 protocol (its own header warns it may change
/// without a version bump, but no v2 has appeared since 2015); bind `min(advertised, 1)`.
const MANAGER_MAX: u32 = 1;
/// `wl_output.name` — the connector name used for logging — arrived in v4. Everything else we do
/// works at v1, so a lower advert just costs the log its names.
const WL_OUTPUT_MAX: u32 = 4;
/// Overall budget for one darken/re-light operation (mirrors `kwin_output_mgmt::OP_BUDGET`):
/// generous next to a healthy roundtrip, and only there so a wedged compositor can't pin the
/// session-create (or group-teardown) thread.
const OP_BUDGET: Duration = Duration::from_secs(3);
/// Poll slice while waiting on the Wayland fd (matches `kwin_output_mgmt`).
const POLL_MS: i32 = 100;
/// One output's accumulated state on this connection, keyed by its `wl_output` global name.
#[derive(Default)]
struct OutputState {
proxy: Option<WlOutput>,
/// Connector name (`DP-1`) from `wl_output.name` (v4) — logging only; the global number is
/// the address everything operates on.
connector: Option<String>,
dpms: Option<Dpms>,
/// `org_kde_kwin_dpms.supported` — `None` until the bind burst arrives.
supported: Option<bool>,
/// The last `org_kde_kwin_dpms.mode` seen — kept current, so the post-`set` wait can watch it
/// flip.
mode: Option<u32>,
}
/// Everything one connection's queue accumulates.
#[derive(Default)]
struct State {
manager: Option<DpmsManager>,
/// Keyed by the `wl_output` GLOBAL NAME — a stable address for the compositor's lifetime, and
/// the identity the darken records so the re-light (a separate, later connection) can find the
/// same outputs again.
outputs: HashMap<u32, OutputState>,
/// Highest `wl_callback` serial whose `done` has arrived — the barrier the pump waits on.
sync_done: u32,
}
impl Dispatch<WlRegistry, ()> for State {
fn event(
state: &mut Self,
registry: &WlRegistry,
event: wl_registry::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
match event {
wl_registry::Event::Global {
name,
interface,
version,
} => {
if interface == DpmsManager::interface().name {
let v = version.min(MANAGER_MAX);
state.manager = Some(registry.bind::<DpmsManager, _, _>(name, v, qh, ()));
} else if interface == WlOutput::interface().name {
let v = version.min(WL_OUTPUT_MAX);
// The global name rides in the UserData so the output's own events (and the
// dpms object's, which gets the same stamp) can find this entry.
let out = registry.bind::<WlOutput, _, _>(name, v, qh, name);
state.outputs.entry(name).or_default().proxy = Some(out);
}
}
// An output unplugged mid-operation: drop the entry so we never `set` on its corpse.
wl_registry::Event::GlobalRemove { name } => {
state.outputs.remove(&name);
}
_ => {}
}
}
}
impl Dispatch<WlOutput, u32> for State {
fn event(
state: &mut Self,
_: &WlOutput,
event: wl_output::Event,
global: &u32,
_: &Connection,
_: &QueueHandle<Self>,
) {
if let wl_output::Event::Name { name } = event {
if let Some(o) = state.outputs.get_mut(global) {
o.connector = Some(name);
}
}
}
}
impl Dispatch<Dpms, u32> for State {
fn event(
state: &mut Self,
_: &Dpms,
event: DpmsEvent,
global: &u32,
_: &Connection,
_: &QueueHandle<Self>,
) {
let Some(o) = state.outputs.get_mut(global) else {
return;
};
match event {
DpmsEvent::Supported { supported } => o.supported = Some(supported != 0),
DpmsEvent::Mode { mode } => o.mode = Some(mode),
DpmsEvent::Done => {}
}
}
}
// The manager has no events; the impl exists because `WlRegistry::bind` demands one.
impl Dispatch<DpmsManager, ()> for State {
fn event(
_: &mut Self,
_: &DpmsManager,
_: protocol::org_kde_kwin_dpms_manager::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
impl Dispatch<WlCallback, u32> for State {
fn event(
state: &mut Self,
_: &WlCallback,
event: wl_callback::Event,
serial: &u32,
_: &Connection,
_: &QueueHandle<Self>,
) {
if let wl_callback::Event::Done { .. } = event {
state.sync_done = state.sync_done.max(*serial);
}
}
}
/// Why [`Session::open`] declined — the same honest-decline discipline as
/// `kwin_output_mgmt::OpenFailure`: which rung said no decides both the log level and whether the
/// `kscreen-doctor` fallback is worth attempting.
enum OpenFailure {
/// No Wayland connection at all (`WAYLAND_DISPLAY` unset/stale). The common case for the bare
/// spawn's natural habitat — a headless plain-distro box with no desktop to darken.
Connect(String),
/// The compositor accepted the connection but did not answer the registry barrier in budget:
/// a live but wedged session — the case the shell-out fallback exists for.
RegistryBarrier,
/// Connected and answering, but `org_kde_kwin_dpms_manager` is not advertised — not KWin. A
/// definitive answer: no fallback can succeed here either (`kscreen-doctor` drives the same
/// KDE-only machinery), so this rung declines without one.
NoDpmsGlobal,
/// The manager is there but the per-output DPMS state bursts never completed in budget.
StateBarrier,
}
impl std::fmt::Display for OpenFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
OpenFailure::Connect(e) => write!(f, "no Wayland connection ({e})"),
OpenFailure::RegistryBarrier => {
write!(
f,
"the compositor did not answer the registry roundtrip in budget"
)
}
OpenFailure::NoDpmsGlobal => {
write!(f, "org_kde_kwin_dpms_manager is not advertised (not KWin)")
}
OpenFailure::StateBarrier => {
write!(
f,
"the outputs' DPMS state never finished announcing in budget"
)
}
}
}
}
/// A connected session with the manager bound and every output's DPMS state read.
struct Session {
conn: Connection,
queue: wayland_client::EventQueue<State>,
state: State,
next_sync: u32,
}
impl Session {
/// [`Session::connect`] for the operation named by `op`, logging the decline at a level that
/// matches what it means: `Connect`/`NoDpmsGlobal` are the everyday non-KDE answers (most
/// bare-spawn boxes have no desktop at all) and log at debug; the two barrier failures mean a
/// LIVE session stopped answering — on a KDE box that is a panel left lit, so they warn.
fn open(op: &'static str) -> Result<Session, OpenFailure> {
let opened = Session::connect();
if let Err(reason) = &opened {
match reason {
OpenFailure::Connect(_) | OpenFailure::NoDpmsGlobal => {
tracing::debug!(op, %reason, "KWin DPMS unavailable");
}
OpenFailure::RegistryBarrier | OpenFailure::StateBarrier => {
tracing::warn!(
op,
%reason,
"KWin DPMS: in-process path unavailable — falling back to kscreen-doctor"
);
}
}
}
opened
}
/// Connect to the desktop's Wayland socket, bind the dpms manager + every `wl_output`, create
/// a dpms status object per output and drain their state bursts — all bounded by [`OP_BUDGET`].
fn connect() -> Result<Session, OpenFailure> {
let conn = Connection::connect_to_env().map_err(|e| OpenFailure::Connect(e.to_string()))?;
let queue = conn.new_event_queue();
let qh = queue.handle();
let _registry = conn.display().get_registry(&qh, ());
let mut s = Session {
conn,
queue,
state: State::default(),
next_sync: 0,
};
let deadline = Instant::now() + OP_BUDGET;
// Phase 1: process the registry globals (binds the manager + every wl_output).
if !s.sync_barrier(deadline) {
return Err(OpenFailure::RegistryBarrier);
}
let Some(mgr) = s.state.manager.clone() else {
return Err(OpenFailure::NoDpmsGlobal);
};
// Phase 2: one dpms status object per output (stamped with the output's global name so its
// events land on the right entry), then a barrier that drains both the outputs' `name`
// events and the dpms objects' supported/mode/done bursts.
let qh = s.queue.handle();
let bound: Vec<(u32, WlOutput)> = s
.state
.outputs
.iter()
.filter_map(|(g, o)| o.proxy.clone().map(|p| (*g, p)))
.collect();
for (global, out) in bound {
let d = mgr.get(&out, &qh, global);
if let Some(o) = s.state.outputs.get_mut(&global) {
o.dpms = Some(d);
}
}
if !s.sync_barrier(deadline) {
return Err(OpenFailure::StateBarrier);
}
Ok(s)
}
/// Send a `wl_display.sync` and pump the queue until its `done` arrives or `deadline` passes.
fn sync_barrier(&mut self, deadline: Instant) -> bool {
self.next_sync += 1;
let serial = self.next_sync;
let qh = self.queue.handle();
let _cb = self.conn.display().sync(&qh, serial);
self.pump_until(deadline, |st| st.sync_done >= serial)
}
/// Bounded manual event loop — flush, dispatch, poll the fd. Mirrors
/// `kwin_output_mgmt::Session::pump_until` (same rationale: `blocking_dispatch` can't be
/// interrupted, so the fd is polled in [`POLL_MS`] slices against `deadline`).
fn pump_until(&mut self, deadline: Instant, done: impl Fn(&State) -> bool) -> bool {
loop {
if done(&self.state) {
return true;
}
if self.queue.dispatch_pending(&mut self.state).is_err() {
return false;
}
if done(&self.state) {
return true;
}
if Instant::now() >= deadline {
return false;
}
if self.conn.flush().is_err() {
return false;
}
let Some(guard) = self.conn.prepare_read() else {
continue; // events already queued — loop dispatches them
};
let mut pfd = libc::pollfd {
fd: self.conn.as_fd().as_raw_fd(),
events: libc::POLLIN,
revents: 0,
};
let remaining = deadline.saturating_duration_since(Instant::now());
let timeout = (remaining.as_millis() as i32).clamp(0, POLL_MS);
// SAFETY: `&mut pfd` points at one live, fully-initialized `libc::pollfd` on the stack
// and the count `1` matches that single element, so `poll` reads `fd`/`events` and
// writes `revents` strictly within `pfd`. `pfd.fd` is the Wayland connection's fd,
// valid because `self.conn` (and the `prepare_read` guard) outlive the call. `poll`
// blocks up to `timeout` ms and writes only `revents`; `pfd` is a fresh local that
// aliases nothing.
let r = unsafe { libc::poll(&mut pfd, 1, timeout) };
if r > 0 && (pfd.revents & libc::POLLIN) != 0 {
let _ = guard.read();
} // else: timeout/signal — drop the guard, re-check the deadline
}
}
/// Request `target` on every DPMS-supporting output not already there — restricted to the
/// globals in `only` when given (the re-light path, which must touch ONLY what the darken
/// touched: a panel the USER had put to sleep before the stream is theirs to keep dark).
/// Returns the outputs actually asked to change, `(global, connector)`, then waits (within
/// budget) for each one's `mode` event to confirm — the protocol is explicit that `set` is a
/// request the compositor may decline, so the confirmation is watched and its absence logged
/// rather than assumed.
fn set_mode(&mut self, target: u32, only: Option<&[u32]>) -> Vec<(u32, Option<String>)> {
let deadline = Instant::now() + OP_BUDGET;
let mut touched: Vec<(u32, Option<String>)> = Vec::new();
for (global, o) in &self.state.outputs {
if only.is_some_and(|list| !list.contains(global)) {
continue;
}
if o.supported != Some(true) || o.mode == Some(target) {
continue;
}
if let Some(dpms) = &o.dpms {
dpms.set(target);
touched.push((*global, o.connector.clone()));
}
}
if touched.is_empty() {
return touched;
}
let want: Vec<u32> = touched.iter().map(|(g, _)| *g).collect();
// An output that vanished mid-wait (GlobalRemove pruned it) counts as settled — there is
// nothing left to flip.
let confirmed = self.pump_until(deadline, |st| {
want.iter()
.all(|g| st.outputs.get(g).is_none_or(|o| o.mode == Some(target)))
});
if !confirmed {
tracing::warn!(
outputs = ?touched,
target,
"KWin DPMS: the compositor did not confirm the mode change in budget (the \
requests are flushed; it may still land, or KWin may have declined)"
);
}
touched
}
}
/// What the 0→1 darken actually achieved — the record the 1→0 re-light undoes. Which arm did the
/// work matters: the two are undone through different doors.
enum Darkened {
/// The in-process path turned these outputs off — `(wl_output global, connector)`. Global
/// names are stable for the compositor's lifetime, so a later connection re-lights exactly
/// these. If KWin restarted in between the names match nothing — and that is the CORRECT
/// no-op, because a fresh KWin brings its outputs up lit anyway.
Wayland(Vec<(u32, Option<String>)>),
/// The `kscreen-doctor --dpms off` fallback ran (it takes no per-output address, so the
/// re-light is the symmetric `--dpms on`).
Kscreen,
}
/// The host-wide darken hold — refcounted like `sleep_inhibit`: the 0→1 edge darkens, the 1→0
/// edge re-lights, and everything between is bookkeeping. See the module docs for why the
/// registry's per-group restore float can't provide this (every gamescope spawn is its own group).
struct Holds {
count: u32,
/// What the 0→1 darken achieved, held until the 1→0 release undoes it. `None` while count > 0
/// means the darken found nothing to do (no KDE, panels already dark) — the release then has
/// nothing to undo, which is exactly right.
darkened: Option<Darkened>,
}
impl Holds {
/// Take a hold; `true` on the 0→1 edge — the caller darkens and [`record`](Self::record)s.
fn acquire_edge(&mut self) -> bool {
self.count += 1;
self.count == 1
}
/// Store the 0→1 darken's outcome.
fn record(&mut self, d: Option<Darkened>) {
self.darkened = d;
}
/// Drop a hold; `Some` on the 1→0 edge hands the caller the record to undo. A release with no
/// hold outstanding is a caller bug (an unbalanced restore) — logged, never underflowed.
fn release_edge(&mut self) -> Option<Darkened> {
if self.count == 0 {
tracing::warn!("KWin DPMS: release without a matching acquire (unbalanced restore)");
return None;
}
self.count -= 1;
if self.count == 0 {
self.darkened.take()
} else {
None
}
}
}
static HOLDS: Mutex<Holds> = Mutex::new(Holds {
count: 0,
darkened: None,
});
/// Take one darken hold for an exclusive-topology stream. The first hold turns the live KDE
/// desktop's panels off (best-effort, bounded); later holds just count. Callers MUST balance each
/// call with [`release_stream_darken`] — the gamescope backend does it by registering the release
/// as the display's topology restore, so the registry runs it exactly once per display at
/// teardown (§6.1).
///
/// The lock is deliberately held across the darken itself: a racing second acquire must queue
/// behind it (and then see the recorded outcome), not observe a count of 2 with nothing darkened.
/// Same discipline on the release side, which keeps a teardown-overlapping-connect sequence
/// strictly ordered: re-light completes, then the new stream's darken runs.
pub fn acquire_stream_darken() {
let mut h = HOLDS.lock().unwrap_or_else(|e| e.into_inner());
if h.acquire_edge() {
let d = darken();
h.record(d);
}
}
/// Drop one darken hold; the last one out re-lights whatever the first hold's darken achieved.
pub fn release_stream_darken() {
let mut h = HOLDS.lock().unwrap_or_else(|e| e.into_inner());
if let Some(d) = h.release_edge() {
relight(d);
}
}
/// The 0→1 darken: in-process over `org_kde_kwin_dpms` first, `kscreen-doctor --dpms off` as the
/// wedged-compositor fallback. `None` = nothing was darkened (no desktop, not KDE, panels already
/// off, or every arm declined) — and therefore nothing to restore.
fn darken() -> Option<Darkened> {
match Session::open("darken") {
Ok(mut s) => {
let touched = s.set_mode(DPMS_MODE_OFF, None);
if touched.is_empty() {
tracing::debug!(
"KWin DPMS: no output to darken (none supported, or all already off)"
);
None
} else {
tracing::info!(
outputs = ?touched,
"KWin DPMS: desktop outputs off for the exclusive gamescope stream"
);
Some(Darkened::Wayland(touched))
}
}
// Definitive "not KDE" / "no desktop": no fallback can do better (kscreen-doctor drives
// the same KDE-only machinery), so decline quietly — already logged by `open`.
Err(OpenFailure::NoDpmsGlobal) | Err(OpenFailure::Connect(_)) => None,
// A live session that stopped answering: the standalone tool rides a different stack
// (libkscreen/KDED) and may still get through — the same rationale as `kwin.rs`'s
// kscreen fallbacks, honest-verdict discipline included.
Err(_) => match kscreen_dpms("off") {
Some(true) => {
tracing::info!(
"KWin DPMS: desktop outputs off for the exclusive gamescope stream \
(kscreen-doctor fallback)"
);
Some(Darkened::Kscreen)
}
// Killed at its budget — NOT a refusal: kscreen-doctor applies first and then waits
// on the compositor, so a loaded KWin routinely lands the change and still gets
// killed. Record the darken so the teardown re-light runs either way; a `--dpms on`
// against a lit panel is a no-op.
None => Some(Darkened::Kscreen),
Some(false) => {
tracing::warn!(
"KWin DPMS: could not darken the desktop outputs for the exclusive topology \
(in-process path and kscreen-doctor both declined) the panel stays lit"
);
None
}
},
}
}
/// The 1→0 re-light. **This is the last line of defence for a dark monitor**, so every arm that
/// gives up says so loudly (the same discipline as `kwin.rs::reenable_outputs_kscreen`) — a dark
/// panel with no line in the log is the failure mode this chain exists to prevent. The worst case
/// stays self-healing regardless: DPMS is non-persistent, and any local input wakes the panel.
fn relight(d: Darkened) {
match d {
Darkened::Wayland(outputs) => {
let globals: Vec<u32> = outputs.iter().map(|(g, _)| *g).collect();
match Session::open("re-light") {
Ok(mut s) => {
s.set_mode(DPMS_MODE_ON, Some(&globals));
tracing::info!(outputs = ?outputs, "KWin DPMS: desktop outputs back on");
}
Err(_) => match kscreen_dpms("on") {
Some(true) | None => {
tracing::info!(
"KWin DPMS: desktop outputs back on (kscreen-doctor fallback)"
);
}
Some(false) => {
tracing::error!(
outputs = ?outputs,
"KWin DPMS: could NOT re-light the desktop outputs (in-process \
restore and kscreen-doctor both declined) the panel stays dark \
until local input wakes it"
);
}
},
}
}
Darkened::Kscreen => {
if kscreen_dpms("on") == Some(false) {
tracing::error!(
"KWin DPMS: could NOT re-light the desktop outputs (kscreen-doctor refused \
the --dpms on it earlier accepted the off for) the panel stays dark until \
local input wakes it"
);
}
}
}
}
/// `kscreen-doctor --dpms <on|off>` for its verdict, on `kwin.rs`'s shared budget and three-state
/// convention (`Some(true)` ran and succeeded, `Some(false)` refused or unrunnable, `None` killed
/// at the budget — which, for a tool that applies first and waits after, usually means it landed).
fn kscreen_dpms(mode: &'static str) -> Option<bool> {
crate::kwin::kscreen_verdict(&["--dpms".to_string(), mode.to_string()])
}
#[cfg(test)]
mod tests {
use super::{Darkened, Holds};
fn fresh() -> Holds {
Holds {
count: 0,
darkened: None,
}
}
#[test]
fn first_acquire_darkens_later_ones_count() {
let mut h = fresh();
assert!(h.acquire_edge(), "0→1 must darken");
h.record(Some(Darkened::Kscreen));
assert!(
!h.acquire_edge(),
"a second concurrent stream must not re-darken"
);
assert!(!h.acquire_edge());
}
#[test]
fn only_the_last_release_relights() {
let mut h = fresh();
assert!(h.acquire_edge());
h.record(Some(Darkened::Wayland(vec![(7, Some("DP-1".into()))])));
assert!(!h.acquire_edge());
// First release: a sibling still streams — the panel must stay dark.
assert!(h.release_edge().is_none());
// Last release hands back the record to undo.
let d = h.release_edge();
assert!(matches!(d, Some(Darkened::Wayland(v)) if v == vec![(7, Some("DP-1".into()))]));
}
#[test]
fn a_darken_that_did_nothing_restores_nothing() {
let mut h = fresh();
assert!(h.acquire_edge());
h.record(None); // no KDE / already dark: nothing was changed
assert!(h.release_edge().is_none(), "nothing to undo");
assert_eq!(h.count, 0);
}
#[test]
fn unbalanced_release_never_underflows() {
let mut h = fresh();
assert!(h.release_edge().is_none());
assert_eq!(h.count, 0, "count must not wrap");
// And the state machine still works afterwards.
assert!(h.acquire_edge());
h.record(Some(Darkened::Kscreen));
assert!(matches!(h.release_edge(), Some(Darkened::Kscreen)));
}
#[test]
fn a_full_cycle_rearms_the_darken() {
let mut h = fresh();
assert!(h.acquire_edge());
h.record(Some(Darkened::Kscreen));
assert!(h.release_edge().is_some());
// A later stream on the same host lifetime darkens again.
assert!(
h.acquire_edge(),
"the 0→1 edge must re-arm after a full cycle"
);
}
}
@@ -602,6 +602,9 @@ pub(crate) fn stream_existing_output(
node_id,
// Mutter's RecordMonitor node lives on the user's PipeWire daemon (like RecordVirtual).
remote_fd: None,
// Not an xdg-portal session: `cursor-mode` was set directly on `RecordMonitor` and Mutter
// honours it, so the request IS the answer and there is nothing to report back.
cursor_mode: None,
keepalive: Box::new(guard),
})
}

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