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 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 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 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 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 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
79 changed files with 3621 additions and 475 deletions
+26 -4
View File
@@ -56,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
@@ -228,7 +242,9 @@ 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
@@ -250,7 +266,9 @@ jobs:
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)
@@ -285,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
@@ -328,7 +348,9 @@ 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: |
+1 -1
View File
@@ -755,7 +755,7 @@ jobs:
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 05-settings 03-pair" \
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
+11 -5
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
+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(
@@ -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,
@@ -255,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)
@@ -50,6 +50,10 @@ class TvScreenshotTest {
@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() }
}
@@ -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 (+ HAT→dpad) sends on wire pad index [pad],
* **on change only**. Holds the previous axis/hat state so an unchanged frame emits nothing. One
@@ -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))
}
}
@@ -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
@@ -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 }
@@ -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
}
}
@@ -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
@@ -604,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 }
@@ -616,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)
}
}
}
@@ -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
@@ -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 \
+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
+4 -2
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,7 +53,8 @@ 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
+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,
};
+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,
}
}
}
@@ -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")))]
+19
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.
@@ -833,6 +837,21 @@ mod portal_config;
#[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;
@@ -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
@@ -525,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
@@ -718,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
@@ -834,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,
}
}
@@ -3595,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(()),
})
}
+257 -79
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
@@ -131,11 +132,18 @@ pub struct HyprlandDisplay {
/// 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,
})
}
}
@@ -201,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();
@@ -224,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 {
@@ -251,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"
),
}
}
}
@@ -349,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
@@ -401,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
@@ -422,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}"),
}
@@ -440,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),
})
}
@@ -793,7 +910,8 @@ 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,
) {
@@ -801,16 +919,15 @@ fn portal_thread(
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;
}
};
@@ -818,9 +935,21 @@ 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?)",
)?;
// 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?)",
)
};
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.
@@ -829,51 +958,99 @@ fn portal_thread(
// 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;
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),
)
.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")?;
// 🛑 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;
@@ -929,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");
}
}
@@ -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)),
})
}
@@ -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),
})
}
@@ -33,8 +33,15 @@
/// A ScreenCast cursor mode, valued as the portal's own wire bits — which is what a backend prints
/// when it rejects one, so `Metadata`'s `4` is literally the number in the field report.
///
/// Public because the NEGOTIATED mode is a per-session fact the consumer needs: the host's stream
/// loop reads it back off the backend ([`VirtualDisplay::last_portal_cursor_mode`]) to know whether
/// `SPA_META_Cursor` can ever arrive on this output. Re-exported as
/// [`crate::PortalCursorMode`](crate::PortalCursorMode).
///
/// [`VirtualDisplay::last_portal_cursor_mode`]: crate::VirtualDisplay::last_portal_cursor_mode
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Mode {
pub enum Mode {
/// No pointer in the cast at all.
Hidden = 1,
/// The compositor paints the pointer into the frames it hands us.
@@ -52,7 +59,7 @@ impl Mode {
}
/// The spelling used in logs and in `PUNKTFUNK_PORTAL_CURSOR_MODE`.
pub(crate) const fn name(self) -> &'static str {
pub const fn name(self) -> &'static str {
match self {
Mode::Hidden => "hidden",
Mode::Embedded => "embedded",
@@ -60,6 +67,20 @@ impl Mode {
}
}
/// Can `SPA_META_Cursor` EVER arrive under this mode? Only under [`Metadata`](Mode::Metadata) —
/// and this is the whole point of surfacing the negotiated mode.
///
/// Under `Embedded` the compositor paints the pointer into the frames and sends no cursor
/// metadata **regardless of where the pointer is**, so on such a session the absence of a cursor
/// overlay carries NO information: not about the pointer's position, not about whether the
/// capture is healthy. Consumers that treat "no overlay" as a symptom (the host's seat-pointer
/// park schedule, which reads it as "the pointer has not reached the streamed output" — true on
/// Mutter, which suppresses metadata while the pointer is off the recorded view) must ask this
/// first. Under `Hidden` there is no pointer at all, so the same holds.
pub const fn delivers_metadata(self) -> bool {
matches!(self, Mode::Metadata)
}
/// What to ask for instead, best first, when this mode is not advertised.
const fn fallbacks(self) -> [Mode; 2] {
match self {
@@ -185,7 +206,7 @@ pub(crate) fn want(hw_cursor: bool, backend: &str) -> Mode {
#[cfg(target_os = "linux")]
impl Mode {
fn to_ashpd(self) -> ashpd::desktop::screencast::CursorMode {
pub(crate) fn to_ashpd(self) -> ashpd::desktop::screencast::CursorMode {
use ashpd::desktop::screencast::CursorMode;
match self {
Mode::Hidden => CursorMode::Hidden,
@@ -198,12 +219,17 @@ impl Mode {
/// Ask the portal what it supports, run the ladder, and hand back the mode to put in
/// `SelectSources`. Infallible by construction: a backend we cannot interrogate gets `Embedded`,
/// the mode that predates the property and that every implementation has always had.
///
/// Returns OUR [`Mode`], not ashpd's — the caller converts with [`Mode::to_ashpd`] for the request
/// and carries the value out of the portal thread, because what was negotiated (as opposed to
/// asked for) governs how the session's cursor behaves for its whole life. See
/// [`Mode::delivers_metadata`].
#[cfg(target_os = "linux")]
pub(crate) async fn negotiate(
proxy: &ashpd::desktop::screencast::Screencast,
hw_cursor: bool,
backend: &str,
) -> ashpd::desktop::screencast::CursorMode {
) -> Mode {
let want = want(hw_cursor, backend);
let advertised = match proxy.available_cursor_modes().await {
Ok(avail) => avail.bits(),
@@ -216,7 +242,7 @@ pub(crate) async fn negotiate(
error = %e,
"ScreenCast: AvailableCursorModes query failed — requesting Embedded cursor"
);
return Mode::Embedded.to_ashpd();
return Mode::Embedded;
}
};
let choice = pick(advertised, want);
@@ -238,7 +264,7 @@ pub(crate) async fn negotiate(
(requesting it anyway would close the session)"
),
}
choice.mode.to_ashpd()
choice.mode
}
#[cfg(test)]
@@ -286,6 +312,21 @@ mod tests {
assert_eq!(c.wanted, Some(Mode::Metadata));
}
/// The consumer-facing half of the same incident: xdph negotiates `3` down to `Embedded`, and
/// under Embedded no `SPA_META_Cursor` ever arrives — so a host that reads "no cursor overlay"
/// as "the pointer has not reached the streamed output" (true on Mutter, which suppresses
/// metadata off-view) re-centres the user's pointer forever. Field report 2026-08-14: the seat
/// pointer warped to centre once a second for the full park cap on a working Hyprland stream.
#[test]
fn only_metadata_can_deliver_a_cursor_overlay() {
assert!(Mode::Metadata.delivers_metadata());
assert!(!Mode::Embedded.delivers_metadata());
assert!(!Mode::Hidden.delivers_metadata());
// The negotiated mode is what governs, not the wanted one: this is the exact ladder result
// on xdph/xdpw, and it says "no overlay is ever coming" even though metadata was requested.
assert!(!pick(3, Mode::Metadata).mode.delivers_metadata());
}
/// The same portal, a session with no cursor channel: already asking for what exists, so the
/// fix must not perturb it.
#[test]
@@ -0,0 +1,153 @@
//! The line we feed xdg-desktop-portal-hyprland's **custom picker** to select an output headlessly.
//!
//! xdph has no headless source-selection API: it runs `screencopy:custom_picker_binary` and parses
//! one line from its stdout. The Hyprland backend points that at a shim which cats a per-session
//! file, and this module is the format of what goes in the file — a wire format with no schema, no
//! validation and no error report, whose only observable failure is a line in the portal's log.
//!
//! Declared unconditionally although only `hyprland.rs` calls it, for the reason `portal_config` and
//! `portal_cursor` give: this is pure string handling whose tests are the only place its behaviour
//! is checkable without a compositor in front of you, so they run on every platform's CI rather than
//! only on the leg that compiles `mod hyprland`. That is not hypothetical here — the bug below
//! shipped, and the one test that existed for it passed the whole time.
/// The picker line selecting monitor `name`: `[SELECTION]<flags>/<selection>`, with **empty flags**.
///
/// 🛑 THE `/` IS MANDATORY AND WE USED TO OMIT IT. xdph splits the line on the FIRST `/` into flags
/// and selection ([xdph 1.3.12] `src/shared/ScreencopyShared.cpp:86-87`):
///
/// ```text
/// const auto FLAGS = SELECTION.substr(0, SELECTION.find_first_of('/'));
/// const auto SEL = SELECTION.substr(SELECTION.find_first_of('/') + 1);
/// ```
///
/// With no `/` anywhere, `find_first_of` returns `npos`, so `FLAGS` becomes the WHOLE payload — and
/// `SEL` becomes the whole payload too, purely because `npos + 1` wraps to `0`. The output name
/// therefore still parsed correctly, which is exactly why this survived: the only thing it broke was
/// the flag loop (`:89-94`), which then walked `screen:<name>` one character at a time —
///
/// ```text
/// [screencopy] unknown flag from share-picker: s
/// [screencopy] unknown flag from share-picker: c
/// [screencopy] unknown flag from share-picker: e … one line per character
/// ```
///
/// — and, because `sc*r*een` contains an `r`, which is xdph's "allow restore token" flag, set
/// `data.allowToken = true`. xdph then answered every `Start` with a `restore_data` +
/// `persist_mode: 2` we never asked for (we request `PersistMode::DoNot`), which is the
/// `[screencopy] Sent restore token to …` on every single session in the field log.
///
/// The reference picker prints the separator unconditionally
/// (`hyprland-share-picker/main.cpp:133-136`):
///
/// ```text
/// std::cout << "[SELECTION]";
/// std::cout << (ALLOWTOKENBUTTON->isChecked() ? "r" : "");
/// std::cout << "/";
/// std::cout << "screen:" << outputName.toStdString() << "\n";
/// ```
///
/// so empty flags are spelled as a bare leading `/`, not as nothing at all.
///
/// ⚠️ This was NOT the cause of the "only the first stream works" stall — see `hyprland.rs`'s
/// `StopGuard` for that. The sessions that streamed fine logged the identical flag spam and the
/// identical restore token, so it never discriminated. It is a real bug on its own terms and nothing
/// more.
///
/// The trailing newline is equally load-bearing: xdph does `data.output.pop_back()` unconditionally
/// after `SEL.substr(7)` (`:96-100`), so without it the last character of the output name is eaten.
///
/// [xdph 1.3.12]: https://github.com/hyprwm/xdg-desktop-portal-hyprland/blob/v1.3.12/src/shared/ScreencopyShared.cpp
pub(crate) fn selection_line(name: &str) -> String {
format!("[SELECTION]/screen:{name}\n")
}
#[cfg(test)]
mod tests {
use super::*;
/// xdph 1.3.12's parser (`ScreencopyShared.cpp:82-100`), transcribed — including the `npos`
/// arithmetic, which is the entire subtlety. Returns `(flags, output)`, or `None` where xdph
/// would fall through to its interactive picker.
///
/// Transcribed rather than asserted on the string, because the bug this catches is invisible in
/// the string: the old line yielded the RIGHT OUTPUT NAME while handing xdph the whole selection
/// as a FLAG STRING. Only running its parser tells the two apart.
fn xdph_parse(picker_stdout: &str) -> Option<(String, String)> {
// `if (!RETVAL.contains("[SELECTION]")) return data;` — a default `SSelectionData`, i.e.
// TYPE_INVALID, which makes `SelectSources` fail.
let marker = picker_stdout.find("[SELECTION]")?;
let selection = &picker_stdout[marker + "[SELECTION]".len()..];
// `substr(0, npos)` is the whole string, and `substr(npos + 1)` is `substr(0)` — also the
// whole string. Unsigned wraparound, not a special case in xdph.
let (flags, sel) = match selection.find('/') {
Some(i) => (&selection[..i], &selection[i + 1..]),
None => (selection, selection),
};
let name = sel.strip_prefix("screen:")?;
// `data.output.pop_back()` — unconditional, hence the mandatory trailing newline.
let mut output = name.to_string();
output.pop();
Some((flags.to_string(), output))
}
/// The three load-bearing parts of the line, pinned as bytes.
#[test]
fn the_line_carries_marker_empty_flags_separator_and_newline() {
assert_eq!(selection_line("PF-1"), "[SELECTION]/screen:PF-1\n");
}
/// What xdph actually makes of our line: the exact output, and NO flags.
#[test]
fn xdph_reads_our_line_as_an_output_with_no_flags() {
for name in ["PF-1", "PF-1620-1", "HDMI-A-1", "DP-2"] {
let (flags, output) = xdph_parse(&selection_line(name)).expect("xdph parses our line");
assert_eq!(output, name, "xdph must recover the exact output name");
assert_eq!(flags, "", "we ask for no flags at all");
assert!(
!flags.contains('r'),
"an `r` in the flags makes xdph hand back restore_data + persist_mode=2 we never \
requested (Screencopy.cpp:261-267)"
);
}
}
/// The regression itself, so it cannot come back by "simplifying" the leading `/` away: the line
/// we used to send parsed the whole selection as flags, `r` included.
#[test]
fn without_the_separator_the_whole_selection_becomes_flags() {
let (flags, output) = xdph_parse("[SELECTION]screen:PF-1620-1\n").expect("still parses");
assert_eq!(
output, "PF-1620-1",
"the output name did survive — which is precisely why this hid for so long"
);
assert_eq!(
flags, "screen:PF-1620-1\n",
"…while the entire selection was handed to the flag loop"
);
assert!(
flags.contains('r'),
"the `r` of `sc*r*een` is xdph's allow-restore-token flag"
);
}
/// Without the trailing newline xdph's unconditional `pop_back()` eats a character of the name —
/// a silently wrong output, not an error.
#[test]
fn the_trailing_newline_is_what_pop_back_consumes() {
assert!(selection_line("PF-1620-1").ends_with('\n'));
let (_, truncated) = xdph_parse("[SELECTION]/screen:PF-1620-1").expect("parses");
assert_eq!(
truncated, "PF-1620-",
"pop_back() takes the last real character"
);
}
/// A line with no marker at all is xdph's documented empty-read fallback: it prompts instead. The
/// shim relies on this when no session has written the selection file.
#[test]
fn an_empty_read_is_not_a_selection() {
assert!(xdph_parse("").is_none());
assert!(xdph_parse("screen:PF-1\n").is_none());
}
}
@@ -0,0 +1,50 @@
//! The ONE tokio runtime every portal handshake runs on, for the life of the process.
//!
//! 🛑🛑🛑 This exists because of a lifetime bug that cost a full day of misdiagnosis, so the reason
//! is written down rather than left to be rediscovered.
//!
//! ashpd caches its D-Bus connection **process-globally** — `static SESSION: OnceLock<Connection>`
//! (ashpd 0.13.13, `src/proxy.rs:27`). The first `Screencast::new()` in the process creates that
//! connection, and zbus spawns the connection's background reader as a task **on whichever tokio
//! runtime happens to be current at that moment**.
//!
//! Each backend used to build its own multi-thread runtime per cast and drop it at teardown. So the
//! FIRST cast of a host process created the cached connection on a runtime that was then destroyed
//! when that cast ended — and the `OnceLock` went on handing the same, now-executor-less connection
//! to every later `Screencast::new()`, which then awaited a reply nothing was left alive to read.
//!
//! MEASURED 2026-08-14 (Hyprland 0.55.4 + xdph 1.3.12): the first cast of a host process streamed;
//! every cast after it hung, in a process whose surviving cast thread sat in `futex_do_wait` inside
//! runtime shutdown. The discriminator that pins it on us rather than on the compositor stack: a
//! freshly spawned process completed the identical handshake against the identical xdph, repeatedly,
//! while the long-lived host could complete none — and xdph itself was idle (28 ms of CPU).
//!
//! ⚠ Therefore: **never build a per-cast runtime, and never drop this one.** A `OnceLock` that is
//! only ever read keeps the connection's reader alive for the process lifetime, which is exactly as
//! long as the cached connection itself lives. `block_on` takes `&self`, so every cast thread can
//! park on this one runtime concurrently.
use std::sync::OnceLock;
use tokio::runtime::Runtime;
/// Build failures are reported to the caller rather than panicking: a host that cannot build a
/// runtime should fail the cast with a reason, not abort the process.
static PORTAL_RT: OnceLock<std::io::Result<Runtime>> = OnceLock::new();
/// The shared portal runtime, or the error from trying to build it.
///
/// Multi-thread with 2 workers: the zbus background reader must be pumped *across* the
/// `create_session` → `select_sources` → `start` handshake while a cast thread blocks on it, which a
/// current-thread runtime cannot do.
pub(crate) fn portal_runtime() -> Result<&'static Runtime, String> {
match PORTAL_RT.get_or_init(|| {
tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.thread_name("punktfunk-portal-rt")
.enable_all()
.build()
}) {
Ok(rt) => Ok(rt),
Err(e) => Err(format!("build the shared portal runtime: {e}")),
}
}
+201 -64
View File
@@ -11,8 +11,10 @@
//! (`~/.config/xdg-desktop-portal-wlr/config`, written once + portal restarted on change)
//! sets `chooser_type=simple` with a `chooser_cmd` that cats the chooser file, which we
//! write per session (`Monitor: <NAME>` — xdpw 0.8 parses that prefix strictly).
//! 4. Teardown is RAII: drop stops the portal thread (its zbus connection ends the cast) and
//! runs `swaymsg output <NAME> unplug` (headless outputs support unplug since sway 1.8).
//! 4. Teardown is RAII **and ordered**: drop closes the ScreenCast session and WAITS for the portal
//! to confirm it, and only then runs `swaymsg output <NAME> unplug` (headless outputs support
//! unplug since sway 1.8). See [`StopGuard`] — and the long root-cause note on `hyprland.rs`'s
//! copy, which is where this was measured.
//!
//! Requirements: the host runs inside the sway session's environment (`SWAYSOCK` for swaymsg,
//! and the portal activation env — `WAYLAND_DISPLAY`/`XDG_CURRENT_DESKTOP=sway` imported into
@@ -67,11 +69,18 @@ pub struct WlrootsDisplay {
/// never be served out-of-band: it now degrades to `Embedded` and streams, where it used to
/// cancel the cast and hand the client a black screen.
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 WlrootsDisplay {
pub fn new() -> Result<Self> {
Ok(WlrootsDisplay { hw_cursor: false })
Ok(WlrootsDisplay {
hw_cursor: false,
last_cursor_mode: None,
})
}
}
@@ -100,6 +109,10 @@ impl VirtualDisplay for WlrootsDisplay {
self.hw_cursor
}
fn last_portal_cursor_mode(&self) -> Option<crate::PortalCursorMode> {
self.last_cursor_mode
}
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
warn_topology_is_extend_only();
// Snapshot → create → identify, all under CREATE_LOCK. sway names the headless output
@@ -146,16 +159,21 @@ impl VirtualDisplay for WlrootsDisplay {
// its own thread (it parks to keep the cast alive, like the other backends). Serialized:
// the chooser is one per-user file, so a concurrent session's write between ours and xdpw'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`: xdpw refuses metadata by construction, so 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(),
"sway headless output ready"
);
Ok(VirtualOutput {
@@ -173,24 +191,75 @@ impl VirtualDisplay for WlrootsDisplay {
reused_gen: None,
pool_gen: None,
expect_exact_dims: false,
// Same EXTEND problem as Hyprland: on a sway session with real heads this `HEADLESS-N`
// sits beside them, and absolute input must be aimed at it by name. `swaymsg`'s output
// 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 unplug 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`] unplug the output (fields drop in
/// declaration order). This used to unplug first — see [`StopGuard`].
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 unplugging the output anyway. See `hyprland.rs`'s twin.
const CAST_CLOSE_BUDGET: Duration = Duration::from_secs(3);
/// Ceiling on the whole ScreenCast handshake, under the caller's 20 s wait — see the note at the
/// handshake, and the longer one on `hyprland.rs`'s copy.
const HANDSHAKE_BUDGET: Duration = Duration::from_secs(15);
/// Ends the cast: signals the portal thread, then **waits for it to have closed the ScreenCast
/// session**, so the caller may safely unplug the output afterwards.
///
/// 🛑 THE WAIT IS THE POINT. Root-caused on the Hyprland leg (see the long note on `hyprland.rs`'s
/// `StopGuard`, which carries the measurements); the defect is the same here, and this is NOT an
/// assumption of symmetry — xdpw was read to confirm it, against `emersion/xdg-desktop-portal-wlr`:
///
/// * **Only `Close` tears a session down.** `src/core/session.c` gives the session object exactly
/// one method — `SD_BUS_METHOD("Close", …, method_close, …)` — and nothing else calls
/// `xdpw_session_destroy` for a live cast. Like xdph, xdpw has no peer-vanished watcher of its own
/// and depends entirely on xdg-desktop-portal's `peer_died_cb` calling `Close` for us, which
/// happens only after our bus name goes away, asynchronously, and therefore after the old
/// `StopGuard` had already let `OutputGuard` unplug the output.
/// * **The same unbounded busy-wait is waiting for it.** `src/screencast/screencast.c:599-605`:
/// `while (cast->node_id == SPA_ID_INVALID) { pw_loop_iterate(state->pw_loop, 0); }` — timeout 0,
/// i.e. non-blocking, i.e. a hot spin on the portal's only loop with no escape if the stream never
/// gets a node id. xdph's copy (`Screencopy.cpp:307-313`) is this code; that is the one measured
/// pinning a core solid until it was restarted.
///
/// So sway's `output unplug` yanks a captured output out from under a live session exactly the way
/// Hyprland's `output remove` did. Whether xdpw wedges *identically* has not been observed on glass
/// — no sway box was available — but the two preconditions are present in its source, and closing
/// the session before unplugging is the correct order regardless of what the backend does with it.
struct StopGuard {
stop: Arc<AtomicBool>,
/// Signalled by the portal thread once it has closed the ScreenCast session. `None` when no cast
/// was ever established — nothing to close, and nothing worth spending the budget on.
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, or the thread is gone without confirming — either way nothing holds the cast.
Ok(()) | Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => tracing::warn!(
budget_s = CAST_CLOSE_BUDGET.as_secs(),
"the ScreenCast session did not close in time — unplugging the output underneath \
it; the next cast may find the portal busy"
),
}
}
}
@@ -346,7 +415,10 @@ impl Drop for ChooserFile {
/// Point xdpw's chooser 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_xdpw_config()?;
let chooser = chooser_file();
std::fs::write(&chooser, format!("Monitor: {output}\n"))
@@ -354,12 +426,21 @@ fn select_and_cast(output: &str, hw_cursor: bool) -> Result<(OwnedFd, u32, StopG
// Owned from the write on: every arm below (and every `?`) leaves the handshake, which is the
// only thing that reads it.
let _chooser = ChooserFile(chooser);
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 unplugged (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-wlr-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 wlroots 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
@@ -368,9 +449,13 @@ 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.
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}"),
}
@@ -387,10 +472,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),
})
}
@@ -513,7 +599,8 @@ fn ensure_xdpw_config() -> Result<()> {
/// reports the fd + node id and parks until stopped — the zbus connection is the cast's
/// lifetime). xdpw answers the source selection via the chooser, no dialog.
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,
) {
@@ -523,14 +610,13 @@ fn portal_thread(
// 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 — see [`crate::portal_rt`] and the long note on
// `hyprland.rs`'s copy: a per-cast runtime kills ashpd's process-global cached connection when
// the cast ends, and every later handshake in the process then hangs.
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;
}
};
@@ -538,9 +624,20 @@ 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 wlr backend?)",
)?;
// Bounded, like `hyprland.rs`'s copy: an orphaned cached connection hangs HERE, before
// any handshake call, so a bound that starts later never fires.
let connect = async {
Screencast::new().await.context(
"connect ScreenCast portal (is xdg-desktop-portal running with the wlr backend?)",
)
};
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 xdpw advertises, never asserted from `hw_cursor` alone — see
// the xdph copy in `hyprland.rs` for the incident. xdpw is the sharper case: its
// screencast.c refuses the mode outright —
@@ -549,51 +646,91 @@ fn portal_thread(
// — so EVERY cursor-forward session on this backend asked for a mode that cancelled the
// cast. Different wording from xdph's "unavailable cursor mode 4", same dead session.
let cursor_mode = crate::portal_cursor::negotiate(&proxy, hw_cursor, "xdpw").await;
let session = proxy
.create_session(Default::default())
.await
.context("create_session")?;
proxy
.select_sources(
&session,
SelectSourcesOptions::default()
.set_cursor_mode(cursor_mode)
// xdpw offers MONITOR only; the chooser picks 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 (chooser declined? check the xdpw config/chooser 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")?;
// Bounded for the same reason as `hyprland.rs`'s copy (the long note lives there): an
// await on a wedged portal never returns, the `stop` flag is only read by the park loop
// further down, so the thread leaks — and a leaked half-handshake poisons every later
// portal request from this process. xdpw has the identical unbounded node-id spin as
// xdph (`screencast.c`), so it can wedge the same way.
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())
// xdpw offers MONITOR only; the chooser picks 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 (chooser declined? check the xdpw config/chooser 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 teardown now WAITS on what follows.
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 IS UNPLUGGED. `Session.Close` is
// the only thing that ends an xdpw session (`src/core/session.c`); dropping the
// connection and trusting the peer to notice is not the contract. The caller is blocked
// in `StopGuard::drop` on the signal below — see `StopGuard`. Bounded, so an
// already-wedged portal cannot hang teardown with it.
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 the portal 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;
+23 -1
View File
@@ -31,6 +31,12 @@ use anyhow::{bail, Context, Result};
pub(crate) struct MirrorStream {
pub node_id: u32,
pub remote_fd: Option<std::os::fd::OwnedFd>,
/// The cursor mode the xdg ScreenCast portal NEGOTIATED for this recording, for the two
/// portal-based backends; `None` for the compositor-protocol ones (KWin/Mutter/gamescope),
/// which get what they ask for. Reported on to the host as
/// [`VirtualDisplay::last_portal_cursor_mode`] — same split as `remote_fd` above, and for the
/// same reason: only the portal path has an answer that can differ from the request.
pub cursor_mode: Option<crate::portal_cursor::Mode>,
/// Dropping this ends the recording. It never owns the monitor — we did not create it.
pub keepalive: Box<dyn Send>,
}
@@ -40,6 +46,9 @@ pub struct MirrorDisplay {
compositor: Compositor,
connector: String,
hw_cursor: bool,
/// What the portal gave the most recent [`create`](VirtualDisplay::create), when this mirror
/// delegated to a portal-based backend. See [`VirtualDisplay::last_portal_cursor_mode`].
last_cursor_mode: Option<crate::portal_cursor::Mode>,
}
impl MirrorDisplay {
@@ -48,6 +57,7 @@ impl MirrorDisplay {
compositor,
connector,
hw_cursor: false,
last_cursor_mode: None,
})
}
}
@@ -65,6 +75,10 @@ impl VirtualDisplay for MirrorDisplay {
self.hw_cursor
}
fn last_portal_cursor_mode(&self) -> Option<crate::PortalCursorMode> {
self.last_cursor_mode
}
fn poolable_now(&self) -> bool {
// Never. `create` below always reports `DisplayOwnership::External` — we did not make this
// head and must not keep it — so the registry never pools a mirror, and the trait's `true`
@@ -125,10 +139,15 @@ impl VirtualDisplay for MirrorDisplay {
),
};
// Latched for `last_portal_cursor_mode` — the delegate's verdict is this mirror's verdict.
self.last_cursor_mode = stream.cursor_mode;
// NOTE: aiming absolute input at this head is the HOST's job, not ours — this crate must
// not depend on pf-inject (see the crate doc: "never on capture/inject"). The host sets the
// anchor from the same pin at startup; §7.2 of the design doc explains why it is host-level
// rather than set here per session.
// rather than set here per session. We only CARRY the head's name out (`output_name`
// below), which is what the wlr injector needs to bind its virtual pointer to this head —
// the libei anchor above cannot serve it, because that backend selects by region.
tracing::info!(
connector = %target.connector,
mode = %target.mode_label(),
@@ -145,6 +164,9 @@ impl VirtualDisplay for MirrorDisplay {
out.remote_fd = stream.remote_fd;
// Never pooled, never lingered, never made primary/exclusive: we don't own this head.
out.ownership = DisplayOwnership::External;
// The head absolute input maps into is the one we mirror — its connector IS its
// `wl_output.name` on the wlroots/Hyprland backends, where the injector matches on it.
out.output_name = Some(target.connector.clone());
Ok(out)
}
}
+24 -1
View File
@@ -302,6 +302,14 @@ mod pool {
pub(super) keepalive: Box<dyn Send>,
pub(super) node_id: u32,
pub(super) preferred_mode: Option<(u32, u32, u32)>,
/// The compositor's name for this output ([`VirtualOutput::output_name`]) — the identity the
/// host aims absolute input with. Kept across a keep-alive reuse for the same reason
/// `preferred_mode` is: the reused display IS the same head, so the output the caller is
/// handed must answer with the same name a fresh create would. No poolable backend sets it
/// today — the ones that do are all passed through unpooled (Hyprland/sway carry a portal
/// fd, a mirror is `External`) — so this only exists so that stops being a silent trap the
/// day one does.
pub(super) output_name: Option<String>,
pub(super) mode: Mode,
pub(super) backend: &'static str,
/// The identity slot the backend resolved for this display (KWin per-slot naming; `None` for
@@ -601,6 +609,7 @@ mod pool {
keepalive: Box::new(()),
node_id: 0,
preferred_mode: None,
output_name: None,
mode: Mode {
width: 1920,
height: 1080,
@@ -1083,6 +1092,7 @@ mod linux {
fn output_for(
node_id: u32,
preferred_mode: Option<(u32, u32, u32)>,
output_name: Option<String>,
generation: u64,
quit: Arc<AtomicBool>,
reused: bool,
@@ -1093,6 +1103,8 @@ mod linux {
preferred_mode,
Box::new(DisplayLease { generation, quit }),
);
// The head is the same one the entry was created for, so it answers with the same name.
out.output_name = output_name;
// A2: tell the pipeline builder this was a REUSED kept display, so a first-frame failure can
// `mark_failed(generation)` (tear the corpse down) rather than re-wedge the retry loop on the same node.
out.reused_gen = reused.then_some(generation);
@@ -1176,6 +1188,7 @@ mod linux {
let generation = r.generation.fetch_add(1, Ordering::Relaxed);
es[idx].generation = generation;
let preferred_mode = es[idx].preferred_mode;
let output_name = es[idx].output_name.clone();
tracing::info!(
backend,
node_id,
@@ -1184,6 +1197,7 @@ mod linux {
ReuseOutcome::Reused(output_for(
node_id,
preferred_mode,
output_name,
generation,
quit.clone(),
true,
@@ -1279,6 +1293,7 @@ mod linux {
let node_id = real.node_id;
let preferred_mode = real.preferred_mode;
let output_name = real.output_name.clone();
// Fresh creates only: the backend may have birthed the output at a sacrificial mode whose
// stream must renegotiate before frames count (KWin >60 Hz — see backend.rs). A REUSED kept
// display already renegotiated in its prior session (the producer's rebuilt offer persists
@@ -1295,6 +1310,7 @@ mod linux {
keepalive: real.keepalive,
node_id,
preferred_mode,
output_name: output_name.clone(),
mode,
backend,
identity_slot,
@@ -1349,7 +1365,14 @@ mod linux {
if (position.x, position.y) != (0, 0) {
vd.apply_position(position.x, position.y);
}
let mut out = output_for(node_id, preferred_mode, generation, quit, false);
let mut out = output_for(
node_id,
preferred_mode,
output_name,
generation,
quit,
false,
);
out.expect_exact_dims = expect_exact_dims;
Ok(out)
}
+134 -7
View File
@@ -1811,6 +1811,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex7(
observed_sha256_out,
client_cert_pem,
client_key_pem,
std::ptr::null(), // pre-v21 variant: no device name, so the OS default stands
timeout_ms,
std::ptr::null_mut(),
)
@@ -1873,6 +1874,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex8(
observed_sha256_out,
client_cert_pem,
client_key_pem,
std::ptr::null(), // pre-v21 variant: no device name, so the OS default stands
timeout_ms,
status_out,
)
@@ -1935,6 +1937,79 @@ pub unsafe extern "C" fn punktfunk_connect_ex9(
observed_sha256_out,
client_cert_pem,
client_key_pem,
std::ptr::null(), // pre-v21 variant: no device name, so the OS default stands
timeout_ms,
status_out,
)
}
}
/// Like [`punktfunk_connect_ex9`], plus `device_name` (ABI v21): the human-readable label this
/// device knocks with — what the host's **pending-approval** list (and the web console's
/// outstanding-pairings view and its approve dialog) shows for an unpaired client, and what the
/// trust store files it under once approved. Pass the name the user already recognises this
/// device by: `Host.current().localizedName` on macOS, `UIDevice.current.name` on iOS/tvOS,
/// `Settings.Global.DEVICE_NAME` on Android.
///
/// NULL / empty = the [`crate::client::device_name`] default, exactly as every earlier variant.
/// That default is an OS hostname, which no Apple GUI process could reach until v21 — every one
/// of them knocked as the literal "This device", so a console with three of them pending showed
/// three identical rows. Longer than [`crate::quic::HELLO_NAME_MAX`] bytes of UTF-8 is truncated
/// (on a character boundary) rather than rejected: a too-long label is a cosmetic problem, and
/// failing a connect over it would be a much worse one.
///
/// # Safety
/// Same as [`punktfunk_connect_ex9`]; `device_name`, when non-null, must be a NUL-terminated C
/// string that stays valid for the duration of the call.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
#[allow(clippy::too_many_arguments)]
pub unsafe extern "C" fn punktfunk_connect_ex10(
host: *const std::os::raw::c_char,
port: u16,
width: u32,
height: u32,
refresh_hz: u32,
compositor: u32,
gamepad: u32,
bitrate_kbps: u32,
video_caps: u8,
audio_channels: u8,
video_codecs: u8,
preferred_codec: u8,
client_caps: u8,
launch_id: *const std::os::raw::c_char,
pin_sha256: *const u8,
observed_sha256_out: *mut u8,
client_cert_pem: *const std::os::raw::c_char,
client_key_pem: *const std::os::raw::c_char,
device_name: *const std::os::raw::c_char,
timeout_ms: u32,
status_out: *mut i32,
) -> *mut PunktfunkConnection {
// SAFETY: the pointer arguments are forwarded UNCHANGED to the versioned entry point, which
// applies the same ABI contract to them; this shim dereferences nothing itself.
unsafe {
connect_ex_impl(
host,
port,
client_caps,
width,
height,
refresh_hz,
compositor,
gamepad,
bitrate_kbps,
video_caps,
audio_channels,
video_codecs,
preferred_codec,
launch_id,
pin_sha256,
observed_sha256_out,
client_cert_pem,
client_key_pem,
device_name,
timeout_ms,
status_out,
)
@@ -1958,9 +2033,27 @@ pub const PUNKTFUNK_CLIENT_CAP_PHASE_LOCK: u8 = 0x02;
/// with [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`]. (Mirrors `quic::CLIENT_CAP_PAD_AUDIO`.)
pub const PUNKTFUNK_CLIENT_CAP_PAD_AUDIO: u8 = 0x08;
/// A [`punktfunk_connect_ex10`] device name cut to what a [`crate::quic::Hello`] carries.
/// [`crate::quic::HELLO_NAME_MAX`] is a BYTE cap while the cut must land on a character
/// boundary — "Wohnzimmer-Fernseher überm Sofa" is 33 characters and 34 bytes, and slicing a
/// name mid-scalar panics. Too long is truncated rather than rejected: the wire encoder would
/// truncate it anyway, and failing a connect over a cosmetic label would be far worse than
/// showing a shortened one.
#[cfg(feature = "quic")]
fn clamp_device_name(s: &str) -> String {
let end = s
.char_indices()
.map(|(i, c)| i + c.len_utf8())
.take_while(|&i| i <= crate::quic::HELLO_NAME_MAX)
.last()
.unwrap_or(0);
s[..end].to_string()
}
/// Shared body of [`punktfunk_connect_ex7`] / [`punktfunk_connect_ex8`]: `status_out`
/// (nullable) is written on EVERY path — `Ok`, the mapped [`PunktfunkError`],
/// `InvalidArg` for bad arguments, `Panic` if the connect panicked.
/// `InvalidArg` for bad arguments, `Panic` if the connect panicked. `device_name` (nullable,
/// [`punktfunk_connect_ex10`]) is the label this device knocks with; null = the OS default.
#[cfg(feature = "quic")]
#[allow(clippy::too_many_arguments)]
unsafe fn connect_ex_impl(
@@ -1982,6 +2075,7 @@ unsafe fn connect_ex_impl(
observed_sha256_out: *mut u8,
client_cert_pem: *const std::os::raw::c_char,
client_key_pem: *const std::os::raw::c_char,
device_name: *const std::os::raw::c_char,
timeout_ms: u32,
status_out: *mut i32,
) -> *mut PunktfunkConnection {
@@ -2013,6 +2107,16 @@ unsafe fn connect_ex_impl(
Ok(Some(s)) if !s.is_empty() => Some(s.to_string()),
_ => None,
};
// The label the host's pending-approval list shows. Same non-fatal treatment as `launch`:
// an absent / empty / bad-UTF-8 name falls back to the OS default rather than failing a
// connect over a cosmetic field. Truncation is on a CHARACTER boundary — `HELLO_NAME_MAX`
// is a byte cap, and slicing a multi-byte name mid-scalar would panic.
// SAFETY: per the ABI contract - a caller-supplied C string, NUL-terminated or null,
// borrowed only for this call.
let name = match unsafe { opt_cstr(device_name) } {
Ok(Some(s)) if !s.trim().is_empty() => clamp_device_name(s.trim()),
_ => crate::client::device_name(),
};
let mode = crate::config::Mode {
width,
height,
@@ -2069,15 +2173,15 @@ unsafe fn connect_ex_impl(
client_caps,
// The C ABI cannot carry slice-progressive parts yet — `PunktfunkFrame` has no
// part/completeness fields, so a part would be indistinguishable from a whole AU.
// An `ex10` variant adds the opt-in together with those fields when an ABI embedder
// An `ex11` variant adds the opt-in together with those fields when an ABI embedder
// (Apple) grows a partial-feed decode path.
false,
launch,
// The C ABI has no device-name parameter (only `punktfunk_pair` takes one), so every
// embedder gets the OS hostname default — this is what the host's pending-approval
// list shows when an unpaired embedder knocks. An `ex10` variant can make it explicit
// if an embedder ever wants a custom label (e.g. the platform's marketing name).
Some(crate::client::device_name()),
// What the host's pending-approval list shows when this embedder knocks unpaired, and
// the trust-store label on approval. [`punktfunk_connect_ex10`]'s `device_name` when
// the embedder supplied one (the name the USER knows the device by — an Apple app has
// it and the OS default cannot reach it), else that OS default.
Some(name),
pin,
identity,
std::time::Duration::from_millis(timeout_ms as u64),
@@ -4940,6 +5044,29 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_is_holding(
mod tests {
use super::*;
/// The `ex10` device name is cut to the Hello's BYTE budget on a CHARACTER boundary — the
/// naive `s[..HELLO_NAME_MAX]` panics on any multi-byte name that straddles it, and an
/// operator naming a device in German or Japanese is not an edge case.
#[test]
fn device_name_truncates_on_a_character_boundary() {
let max = crate::quic::HELLO_NAME_MAX;
assert_eq!(clamp_device_name("Enrico's iPad"), "Enrico's iPad");
// Straddling: 2-byte characters over an odd-length prefix, so the cap lands mid-scalar.
let straddle = format!("{}{}", "x".repeat(max - 1), "ü".repeat(4));
let cut = clamp_device_name(&straddle);
assert!(cut.len() <= max, "{} bytes exceeds the cap", cut.len());
assert_eq!(
cut,
"x".repeat(max - 1),
"must drop the whole ü, not half of it"
);
// A name whose FIRST character already exceeds the cap has nothing to keep — the
// `unwrap_or(0)` path, which must yield "" rather than panicking on an empty iterator.
assert_eq!(clamp_device_name(&"".repeat(max)), "".repeat(max / 3));
}
/// A C embedder writing `ev->kind = 42` must come back as a status code, not UB. The test
/// stages the event in `MaybeUninit` storage so no `&InputEvent` to an invalid value ever
/// exists on the test's own side either.
+37 -2
View File
@@ -479,8 +479,16 @@ fn register_hot_tid(reg: &Mutex<Vec<i32>>) {
/// This machine's name — the default value for [`NativeClient::connect`]'s `name` parameter
/// (what a host shows in its pending-approval list and files this client under when approved).
/// `/etc/hostname` first (the answer on any Linux box, and available in a minimal build with no
/// desktop toolkit to ask), then the usual environment fallbacks. Lives here (not in a client
/// shell crate) so the C ABI's `punktfunk_connect` can share the same default.
/// desktop toolkit to ask), then the usual environment fallbacks, then the OS hostname itself.
/// Lives here (not in a client shell crate) so the C ABI's `punktfunk_connect` can share the
/// same default.
///
/// The `gethostname` step is what saves the GUI clients: **no** Apple app has `COMPUTERNAME`
/// (Windows-only) or `HOSTNAME` (a shell variable — never exported into a `launchd`-started
/// process) in its environment, so before it every Mac, iPad, iPhone and Apple TV knocked as
/// the literal "This device" and the console's pending list could not tell them apart. An
/// embedder that knows a better, user-facing name should pass it explicitly instead
/// ([`crate::abi::punktfunk_connect_ex10`]'s `device_name`) — this is only the floor.
pub fn device_name() -> String {
#[cfg(target_os = "linux")]
if let Ok(s) = std::fs::read_to_string("/etc/hostname") {
@@ -493,9 +501,36 @@ pub fn device_name() -> String {
.or_else(|_| std::env::var("HOSTNAME"))
.ok()
.filter(|s| !s.trim().is_empty())
.or_else(os_hostname)
.unwrap_or_else(|| "This device".into())
}
/// The OS hostname (`gethostname`), or `None` when it is missing/unset/useless. macOS returns
/// the user's computer name as an mDNS host label ("Enricos-MacBook-Pro.local"), iOS/tvOS the
/// device name — so the `.local` suffix comes off, and the placeholder answers every platform
/// gives when nothing is configured ("localhost") is rejected: it labels nothing.
#[cfg(unix)]
fn os_hostname() -> Option<String> {
let mut buf = [0u8; 256];
// SAFETY: `gethostname` writes at most `len` bytes into the caller's buffer; this one is a
// stack array we own and pass its true length. A truncating write may omit the NUL, which
// the `position` fallback below covers.
if unsafe { libc::gethostname(buf.as_mut_ptr().cast(), buf.len()) } != 0 {
return None;
}
let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
let s = std::str::from_utf8(&buf[..end]).ok()?.trim();
let s = s.strip_suffix(".local").unwrap_or(s);
(!s.is_empty() && !s.eq_ignore_ascii_case("localhost")).then(|| s.to_string())
}
/// Windows has no `gethostname` without linking winsock (and `COMPUTERNAME` is always set there
/// anyway, so the env step above never falls through to this).
#[cfg(not(unix))]
fn os_hostname() -> Option<String> {
None
}
impl NativeClient {
/// Connect to a `punktfunk/1` host and start the session at (up to) `mode`. Blocks until the
/// handshake completes or `timeout` elapses.
+11 -1
View File
@@ -185,7 +185,17 @@ pub use stats::Stats;
/// every existing function keeps its signature and behaviour, and an embedder that never calls it
/// is unchanged. The `Welcome` grew a trailing field, which older peers skip in both directions
/// (see `Welcome::encode`), so [`WIRE_VERSION`] is unchanged.
pub const ABI_VERSION: u32 = 20;
/// v21: added `punktfunk_connect_ex10` — `connect_ex9` plus `device_name`, the label an unpaired
/// client knocks with: what the host's pending-approval list (and the web console's
/// outstanding-pairings view and approve dialog) shows, and the trust-store name on approval. The
/// C ABI had no such parameter, so every embedder took [`client::device_name`]'s OS default —
/// which resolves through `COMPUTERNAME`/`HOSTNAME`, neither of which exists in an Apple GUI
/// process, leaving every Mac, iPad, iPhone and Apple TV knocking as the literal "This device"
/// (a console with three of them pending showed three identical rows). A NEW symbol, not a
/// widened one: `ex9` keeps its parameter list AND its behaviour — it passes a null name, which
/// selects that same default. Additive and client-local: the name rides the `Hello::name` field
/// hosts have read since the pending list existed, so [`WIRE_VERSION`] is unchanged.
pub const ABI_VERSION: u32 = 21;
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
+11
View File
@@ -135,6 +135,17 @@ pub fn capture_virtual_output(
// handshake already resolved that through [`capturer_supports_hdr_for`] before the Welcome,
// so passing it through here is the whole of this arm's HDR logic. It used to be dropped on
// the floor, which is what kept the Linux native plane at 8 bits.
//
// Aim the wlr injector's absolute mapping (abs-mouse, and `park_pointer`'s opening warp) at
// THIS head — the Linux counterpart of the `set_stream_target` call in the Windows arm below.
// The wlroots virtual pointer maps `motion_absolute` onto the `wl_output` it was created with,
// and on the EXTEND backends (Hyprland, sway) the streamed head sits BESIDE the operator's, so
// without this every absolute sample landed on their screen and the cursor never entered the
// stream at all. `None` (KWin/Mutter/gamescope, none of which inject through that backend)
// CLEARS the slot rather than leaving a stale name: one compositor serves the whole host, so a
// `None` here means no session on this host wants a named binding — e.g. a Game-Mode switch
// from a Hyprland desktop to gamescope, after which the old `PF-…` name means nothing.
crate::inject::set_stream_output(vout.output_name.clone());
pf_capture::open_virtual_output(
vout.remote_fd,
vout.node_id,
+200 -22
View File
@@ -1347,8 +1347,19 @@ pub(super) struct SessionContext {
/// its embedded mode paints nothing either) the stream has NO cursor at all, in both the
/// embedded and the cursor-channel composite models. Parking once per (re)built display — and
/// again on the mid-stream flip to the capture model, which heals a pointer that drifted off the
/// output's edge — pins the pointer to the surface the client actually sees. A desktop-model
/// client overrides it with its first absolute move, so the jump is invisible in practice.
/// output's edge — pins the pointer to the surface the client actually sees.
///
/// **Retried only for a relative-only client.** The schedule used to repeat this for every session,
/// on the theory that "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. A desktop-model
/// client sends absolute positions — the very same [`MouseMoveAbs`] this synthesizes, only aimed
/// where the user is actually pointing — so once those are flowing the park has nothing left to add
/// and every later attempt is a visible yank to centre that fights them (field report 2026-08-14).
/// The schedule below therefore keeps the single bring-up park for such a client (so its first
/// click cannot land on the monitor the seat pointer was left on) and drops the retry; the
/// capture-model flip re-arms the full schedule.
///
/// [`MouseMoveAbs`]: punktfunk_core::input::InputKind::MouseMoveAbs
#[cfg(target_os = "linux")]
fn park_pointer(input_tx: &std::sync::mpsc::SyncSender<super::input::ClientInput>, w: u32, h: u32) {
let ev = punktfunk_core::input::InputEvent {
@@ -1375,6 +1386,65 @@ fn park_pointer(input_tx: &std::sync::mpsc::SyncSender<super::input::ClientInput
}
}
/// Settle this session's cursor plan against the cursor mode the display's portal ACTUALLY
/// negotiated — THE rule, shared by bring-up and the capture-loss rebuild so the two cannot drift.
///
/// Returns whether "the capture has no cursor overlay" still MEANS anything on this display, and
/// clears `metadata_composite` when the negotiated mode makes it a fiction. Both answers come from
/// the same fact, which is why they are settled together.
///
/// The fact: [`set_hw_cursor`] is a *request*. On the whole wlr family (xdph, xdpw) the portal
/// advertises `Hidden|Embedded` — measured, `AvailableCursorModes = 3`, on current packages — 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. That breaks two
/// downstream beliefs at once:
///
/// * the host planned a metadata composite it can never feed (the stream logs "host-composite
/// active but the capture has no live cursor overlay yet" forever, and shows no host pointer —
/// the compositor's own burnt-in one is the real cursor there), and
/// * the seat-pointer park schedule reads "no overlay" as "the pointer has not reached the streamed
/// output". That inference is sound on **Mutter**, which suppresses cursor metadata while the
/// pointer is off the recorded view (`should_cursor_metadata_be_set`) — it is the signal
/// [`park_pointer`] was built on — and it is pure noise under Embedded, where the host then
/// re-centres the user's pointer once a second for the whole cap, fighting every mouse movement
/// (field report 2026-08-14, Hyprland).
///
/// A backend that reports no negotiated mode (`None` — KWin, Mutter, gamescope, Windows) is served
/// the cursor mode it asked for through its own protocol, so nothing here applies and both answers
/// stay exactly as they were.
///
/// What this does NOT undo is [`SessionPlan::cursor_blend`]: the blend capability is resolved
/// before any display exists, and the negotiation only happens inside `create`, so a session that
/// lands on Embedded still captures RGB for a blend stage it will never be handed an overlay for.
/// That costs a colour conversion, not correctness — and pre-judging it would mean re-asserting
/// what the wlr portals advertise, which is exactly the hardcode `pf_vdisplay::portal_cursor`
/// exists to have deleted.
///
/// [`set_hw_cursor`]: pf_vdisplay::VirtualDisplay::set_hw_cursor
/// [`SessionPlan::cursor_blend`]: crate::session_plan::SessionPlan::cursor_blend
#[cfg(target_os = "linux")]
fn settle_portal_cursor(
vd: &dyn crate::vdisplay::VirtualDisplay,
metadata_composite: &mut bool,
) -> bool {
let Some(negotiated) = vd.last_portal_cursor_mode() else {
return true;
};
if negotiated.delivers_metadata() {
return true;
}
if *metadata_composite {
*metadata_composite = false;
tracing::info!(
negotiated = negotiated.name(),
"the portal negotiated a cursor mode that carries no cursor metadata — dropping the \
host composite; the pointer in this stream is the compositor's own, burnt into the \
frames"
);
}
false
}
pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDisplay>) -> Result<()> {
// This thread runs the capture+encode loop (single-process — the only topology: Linux portal /
// synthetic, Windows in-process IDD-push). Elevate it so a CPU-heavy game can't deschedule our GPU
@@ -1705,6 +1775,11 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
mut cur_display_gen,
built_bitrate,
) = pipe;
// The display exists now, so the portal has answered: settle the cursor plan against what it
// actually negotiated rather than what this session asked for (see `settle_portal_cursor`).
// `mut`: every capture-loss rebuild re-runs `create`, hence re-negotiates.
#[cfg(target_os = "linux")]
let mut no_overlay_means_off_output = settle_portal_cursor(&*vd, &mut metadata_composite);
// The encoder may have opened at a re-resolved rate (a mirrored head delivering a size this
// session never negotiated). Adopt it before anything downstream reads `bitrate_kbps`.
adopt_built_bitrate(
@@ -2064,12 +2139,15 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// compositing), NOT an encoder problem. Logged every 2 s when `PUNKTFUNK_PERF`.
let (mut diag_new, mut diag_repeat) = (0u64, 0u64);
// Seat-pointer park schedule (see `park_pointer`): per (re)built display, and re-armed by
// the capture-model flip. More than one attempt because the first park of a session can
// land on a still-cold EIS connection (devices not yet resumed → the injector DROPS it) —
// observed on-glass; the retry a second later goes through. While the session is in the
// the capture-model flip. More than one attempt for a RELATIVE-ONLY session, because the
// first park can land on a still-cold EIS connection (devices not yet resumed → the injector
// DROPS it) — observed on-glass; the retry a second later goes through. A client that steers
// the pointer itself gets the bring-up park only: its own absolute moves are the retry, and
// a synthetic one on top of them is just a yank to centre. While the session is in the
// capture model with no live cursor overlay, keep trying up to the cap: no overlay there
// means the pointer still isn't on the streamed output, and a relative-only client can
// never fix that itself.
// never fix that itself — but only where an absent overlay is evidence of anything at all
// (`no_overlay_means_off_output`, settled per display by `settle_portal_cursor`).
#[cfg(target_os = "linux")]
let mut parked_display = None;
#[cfg(target_os = "linux")]
@@ -3033,6 +3111,17 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
interval = new_interval;
cur_node_id = new_node_id;
cur_display_gen = new_display_gen;
// The rebuild re-ran `create`, so the portal answered again — possibly a different
// backend's portal (the retarget above), possibly with a different verdict. Settle
// the cursor plan against THIS display, exactly as bring-up did: the retarget arm
// recomputes `metadata_composite` from the compositor alone (it has to — it runs
// BEFORE the rebuild, to set `hw_cursor`), so without this a switch onto a wlr
// backend would re-arm both defects mid-session.
#[cfg(target_os = "linux")]
{
no_overlay_means_off_output =
settle_portal_cursor(&*vd, &mut metadata_composite);
}
// A capture-loss rebuild can land on a different source than it lost (this loop
// re-detects the session every cycle, precisely so it can follow a switch), so the
// delivered size — and with it an Automatic rate — may have changed under us.
@@ -3136,10 +3225,15 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
None => {
if !composite_saw_none {
composite_saw_none = true;
// NOT necessarily "cursorless", which this line used to assert:
// where the portal negotiated Embedded (the whole wlr family) no
// `SPA_META_Cursor` is ever sent and the compositor's own pointer
// is already in the pixels — the host blend has nothing to add.
// `settle_portal_cursor` logs which of the two this session is.
tracing::info!(
"host-composite active but the capture has no live cursor \
overlay yet (no SPA_META_Cursor bitmap) the stream is \
cursorless until one arrives"
overlay (no SPA_META_Cursor bitmap) nothing for the encoder \
blend to draw; the pointer, if any, is the compositor's own"
);
}
}
@@ -3189,14 +3283,32 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
frame.cursor = None;
}
// The seat-pointer park schedule (state + rationale at the declarations above; armed by
// the first frame of every (re)built display and by the capture-model flip). The first
// two attempts run unconditionally — attempt 1 can be swallowed by a cold EIS
// connection. Past those, only a host-composite session that STILL has no live overlay
// keeps trying — a channel session in the capture model, or a no-channel
// metadata-composite session (both relative-only): no overlay there means the pointer
// has not reached the streamed output (the compositor reports cursor metadata only
// while it is over the recorded view), and a relative-only client cannot get it there
// on its own.
// the first frame of every (re)built display and by the capture-model flip).
//
// Two unconditional attempts for a RELATIVE-ONLY session — attempt 1 can be swallowed by
// a cold EIS connection, and nothing else will ever move that pointer onto the output.
// Exactly ONE for a client that steers the seat pointer itself (a channel client in the
// desktop model): the park still runs once at bring-up, so the session's first click
// cannot land on whatever monitor the seat pointer was left on, but the retry a second
// later is dropped — by then the client's own absolute moves are doing this job, better
// (same event, aimed where the user is actually pointing), and the retry is a visible
// yank to centre that fights them (field report 2026-08-14; see `park_pointer`). A cold
// EIS swallows the client's moves too, and those keep coming, so it needs no retry.
//
// Past the unconditional attempts, only a host-composite session that STILL has no live
// overlay keeps trying — a channel session in the capture model, or a no-channel
// metadata-composite session: no overlay there means the pointer has not reached the
// streamed output (the compositor reports cursor metadata only while it is over the
// recorded view), and a relative-only client cannot get it there on its own.
//
// ...but ONLY where that inference holds — `no_overlay_means_off_output`, settled per
// display by `settle_portal_cursor`. Under a portal that negotiated Embedded no cursor
// metadata is EVER sent, so "no overlay" says nothing about where the pointer is, and
// this heuristic re-centred the user's pointer for the full cap on every Hyprland/sway
// session (the same field report). Those sessions still get the two unconditional
// attempts, which are what puts the pointer — and thus the compositor's burnt-in cursor,
// and the client's input — on the streamed output in the first place.
//
// Armed from the loop's first tick — a static desktop may never deliver a fresh frame
// (`parked_display` is only bookkeeping for rebuild re-arming), and the pointer must be
// parked regardless.
@@ -3205,17 +3317,22 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
&& park_attempts < PARK_ATTEMPTS_MAX
&& std::time::Instant::now() >= next_park_at
{
let composite_starved = ((cursor_fwd.is_some()
&& !cursor_client_draws.load(Ordering::Relaxed))
let client_steers = cursor_fwd.is_some() && cursor_client_draws.load(Ordering::Relaxed);
let unconditional = if client_steers { 1 } else { 2 };
// Never true while `client_steers`: the channel term excludes it outright, and
// `metadata_composite` implies no channel at all.
let composite_starved = ((cursor_fwd.is_some() && !client_steers)
|| metadata_composite)
&& capturer.cursor().is_none();
if park_attempts < 2 || composite_starved {
&& capturer.cursor().is_none()
&& no_overlay_means_off_output;
if park_attempts < unconditional || composite_starved {
park_pointer(&input_tx, frame.width, frame.height);
park_attempts += 1;
next_park_at = std::time::Instant::now() + std::time::Duration::from_secs(1);
} else {
// Settled (overlay flowing, or the client draws): stop scheduling until a
// rebuild or a capture-model flip re-arms it.
// Settled (the client steers, the overlay is flowing, or its absence carries no
// information here): stop scheduling until a rebuild or a capture-model flip
// re-arms it.
park_attempts = PARK_ATTEMPTS_MAX;
}
}
@@ -5115,4 +5232,65 @@ mod tests {
"a +2 ms offset must shift the next target by +2 ms mod P, got {shift}"
);
}
/// The 2026-08-14 Hyprland field report, in one function: xdph advertises `Hidden|Embedded`,
/// so a session that asked for cursor metadata is served **Embedded** — no `SPA_META_Cursor`
/// is ever sent, whatever the pointer does. The host must then (a) stop planning a metadata
/// composite it can never feed, and (b) stop reading "no cursor overlay" as "the seat pointer
/// is not on the streamed output" — the inference that re-centred the user's pointer once a
/// second for the whole park cap.
///
/// The `None` case is the regression guard for GNOME: Mutter is served the cursor mode it
/// asks for through its own protocol and DOES suppress metadata while the pointer is off the
/// recorded view, which is the signal `park_pointer` exists for. Nothing here may touch it.
#[cfg(target_os = "linux")]
#[test]
fn an_embedded_portal_voids_both_the_composite_and_the_starvation_signal() {
struct Fake(Option<pf_vdisplay::PortalCursorMode>);
impl crate::vdisplay::VirtualDisplay for Fake {
fn name(&self) -> &'static str {
"fake"
}
fn create(
&mut self,
_mode: pf_vdisplay::Mode,
) -> anyhow::Result<crate::vdisplay::VirtualOutput> {
anyhow::bail!("this test never creates a display")
}
fn last_portal_cursor_mode(&self) -> Option<pf_vdisplay::PortalCursorMode> {
self.0
}
}
// The whole wlr family, today: metadata wanted, Embedded served.
let mut composite = true;
assert!(!settle_portal_cursor(
&Fake(Some(pf_vdisplay::PortalCursorMode::Embedded)),
&mut composite
));
assert!(!composite, "the composite can never be fed — drop it");
// Hidden is the same story from the other end: no pointer, so no overlay, so no signal.
let mut composite = true;
assert!(!settle_portal_cursor(
&Fake(Some(pf_vdisplay::PortalCursorMode::Hidden)),
&mut composite
));
assert!(!composite);
// A portal that really does serve metadata (xdph ≥ #366, or the portal path on
// KWin/Mutter): everything stays exactly as it was.
let mut composite = true;
assert!(settle_portal_cursor(
&Fake(Some(pf_vdisplay::PortalCursorMode::Metadata)),
&mut composite
));
assert!(composite);
// Not portal-negotiated at all — KWin `zkde_screencast`, Mutter `RecordVirtual`,
// gamescope, Windows. THE no-regression case.
let mut composite = true;
assert!(settle_portal_cursor(&Fake(None), &mut composite));
assert!(composite);
}
}
+10 -2
View File
@@ -224,8 +224,8 @@ regular pad). Automatic arms it only where the raw guide press can't reach the h
Gaming Mode, iPhone/iPad, Apple TV — because the gesture has a cost: a Select *tap* arrives a beat
late, and a game that expects a *held* Select would trigger it. Set **On** or **Off** to overrule.
**Capture system shortcuts** — *default: on.* Offered by the Linux and Windows apps and the console
home; Windows spells the row out as *Capture system shortcuts (Alt+Tab, Win, …)*. On a Deck it
**Capture system shortcuts** — *default: on.* Offered by the Linux, Windows and macOS apps and the
console home; Windows spells the row out as *Capture system shortcuts (Alt+Tab, Win, …)*. On a Deck it
matters only for a keyboard you attached yourself, for the reason the paragraph below gives: Gaming
Mode is gamescope, which has nothing to hold back. On, Alt+Tab and the Windows key
(Super on Linux) reach the host while the stream has input captured. Off, they act on this machine
@@ -234,6 +234,14 @@ back the moment you release capture with **Ctrl+Alt+Shift+Q**, the window loses
ends, and [Desktop mouse mode](/docs/input#mouse-modes) never takes them at all. Leaving this on does
mean **Ctrl+Alt+Shift+Q is your way out** of a captured stream, since Alt+Tab no longer is.
On macOS the chords in question are the **⌘** ones — ⌘Q above all, which reaches the host as Super+Q,
one of the most-bound chords on a Linux desktop. On, ⌘Q, ⌘W, ⌘H and the rest go to the host instead
of this app's menu bar while input is captured. Off, they act on the Mac as usual, which means ⌘Q
quits Punktfunk mid-stream. **⌘⎋ always stays local whichever way the toggle is set** — it is what
releases capture, as is ⌃⌥⇧Q, and ⌃⌘F keeps working on the window. A few chords never reach the host
either way, because macOS claims them before any app can see them: ⌘Tab, ⌘Space, and the Mission
Control keys.
On Linux this needs a compositor that supports keyboard-shortcuts-inhibit — KDE Plasma, GNOME and
the wlroots compositors all do, and X11 sessions grab the keyboard directly. Under
[gamescope](/docs/gamescope) there is nothing to inhibit: it hands the session everything already.
+6 -1
View File
@@ -68,6 +68,10 @@ to one readable line.
**⌃⌥⇧Q / M / D / S** — but not the microphone mute. **⌘⎋** also toggles capture,
**⌃⌘F** toggles fullscreen, and **⌃⌥⇧C** starts or stops [clipboard sharing](/docs/clipboard). The
**Stream** menu lists them all except the mouse-mode combo, which works but has no menu item.
Every *other* ⌘ chord goes to the host while input is captured — ⌘Q reaches the host's compositor
rather than quitting the app — unless you turn **Capture system shortcuts** off in
[client settings](/docs/client-settings#input). ⌘⎋ and ⌃⌘F are held back either way, so there is
always a way out.
- **iPhone and iPad** with a hardware keyboard: **⌃⌥⇧Q** releases input while it is captured, and
**⌘⎋** toggles capture in either direction. **⌃⌥⇧D** (disconnect) and **⌃⌥⇧S** (stats) come from
the app's Stream shortcuts rather than from the stream itself; if they don't respond while you're
@@ -154,7 +158,8 @@ There are two, and they are a per-client setting called **Mouse input**:
- **Capture (games)** — the pointer locks to the stream and only relative movement is sent. The only
cursor you see is the host's. This is what mouse-look in a game needs. The session window also
grabs the keyboard here, so Alt+Tab and the Windows key (Super on Linux) reach the host rather than
your own desktop — turn **Capture system shortcuts** off in
your own desktop — on macOS that is the ⌘ chords, ⌘Q included, with ⌘⎋ kept back as the way out.
Turn **Capture system shortcuts** off in
[client settings](/docs/client-settings#input) to keep them local.
- **Desktop (absolute)** — the pointer is not locked. It moves in and out of the stream freely and
its position is sent as an absolute point — what you want for remote desktop work. Your local
+26 -13
View File
@@ -103,29 +103,24 @@ See [packaging/flatpak](https://git.unom.io/unom/punktfunk/src/branch/main/packa
## Windows
The Windows client ships as a **signed MSIX** in the package registry. Builds use a self-signed
certificate, so you import that certificate once before Windows will install the package.
The Windows client ships as a **signed MSIX** in the package registry, signed with a publicly
trusted certificate so there is nothing to import and nothing to trust by hand. Download, install.
1. Download the package and its certificate. Each channel keeps one fixed URL, so these two lines
always fetch the current build — in PowerShell:
1. Download the package. Each channel keeps one fixed URL, so this line always fetches the current
build — in PowerShell:
```powershell
curl.exe -LO https://git.unom.io/api/packages/unom/generic/punktfunk-client-windows/latest/punktfunk-client-windows_x64.msix
curl.exe -LO https://git.unom.io/api/packages/unom/generic/punktfunk-client-windows/latest/punktfunk-client-windows_x64.cer
```
Swap `_x64` for `_arm64` on an Arm device, and `latest` for `canary` to track `main`. The same
two files are attached to every [release](https://git.unom.io/unom/punktfunk/releases), and every
file is attached to every [release](https://git.unom.io/unom/punktfunk/releases), and every
build is also kept under its own version on the
[packages page](https://git.unom.io/unom/-/packages) (generic group, `punktfunk-client-windows`).
2. **Trust the publisher certificate**, then install. The MSIX won't install until the certificate is
trusted — but it's the **same certificate for every release**, so this is genuinely one-time and
later updates need nothing. In an **admin** PowerShell:
2. Install it:
```powershell
# use the _arm64 files instead on an Arm device
Import-Certificate -FilePath .\punktfunk-client-windows_x64.cer `
-CertStoreLocation Cert:\LocalMachine\TrustedPeople
# use the _arm64 file instead on an Arm device
Add-AppxPackage .\punktfunk-client-windows_x64.msix
```
@@ -133,6 +128,10 @@ certificate, so you import that certificate once before Windows will install the
[Windows App Runtime 2.x](https://learn.microsoft.com/windows/apps/windows-app-sdk/downloads)
(the MSIX depends on `Microsoft.WindowsAppRuntime.2`), then re-run `Add-AppxPackage`.
Install from a signed-in desktop session. Over a remote, non-interactive session (SSH, an RMM
tool) `Add-AppxPackage` can fail with `0x80070005` when the Windows App Runtime it depends on is
in use and Windows can't restart the apps holding it.
3. Launch **Punktfunk** from the Start menu and pick your host. The package also adds a second
entry, **Punktfunk Console** — the same client as a controller-driven fullscreen interface for a
TV or HTPC — and the headless `punktfunk` command on your PATH.
@@ -223,7 +222,7 @@ but keeping them close is the least surprising. (Updating the **host** is its ow
| **Linux Flatpak** | `flatpak update --user io.unom.Punktfunk`**without `sudo`** (see the [Flatpak section](#linux-desktop-flatpak)) |
| **Linux apt / dnf / pacman** | your normal `sudo apt upgrade` / `sudo dnf upgrade` / `sudo pacman -Syu`, or the app's own updater below |
| **Fedora Atomic (layered)** | `rpm-ostree upgrade` on its own is not enough — see the note below the table |
| **Windows MSIX** | no self-update — download the newer `.msix` as in [Windows](#windows) and re-run `Add-AppxPackage`. The certificate is the same every release, so you don't import it again |
| **Windows MSIX** | no self-update — download the newer `.msix` as in [Windows](#windows) and re-run `Add-AppxPackage`. Coming from **0.28.1 or earlier**, see the note below the table |
| **macOS `.dmg`** | download the newer `Punktfunk-<version>.dmg` and drag it over the copy in Applications |
| **iOS / iPadOS / tvOS** | TestFlight updates it |
| **Android** | Google Play updates it; if you sideloaded, download the APK again and install over it |
@@ -244,6 +243,20 @@ systemctl reboot
The client's own updater below runs exactly that dance for you, if you'd rather not remember it. (A
layered **host** has the same trap — [Updating](/docs/updating) covers it.)
**Windows, coming from 0.28.1 or earlier — uninstall first.** Those builds were signed with our own
self-signed certificate. The move to a publicly trusted one changes the package's *publisher*, and an
MSIX's identity is its name **plus** its publisher — so Windows treats the new package as a different
app rather than an update, and installing it leaves you with two **Punktfunk** entries. Remove the
old one first, then install the new `.msix` as above:
```powershell
Get-AppxPackage *Punktfunk* | Remove-AppxPackage
```
This is one-time; releases after that upgrade in place. Note that a packaged app's settings live
*inside* its package, so removing the old one also removes this client's identity and its paired
hosts — expect to [pair](/docs/pairing) again once. Nothing on the host side is affected.
### The Linux client can update itself
The native Linux client checks its own channel and can apply the update in place, so you don't have
+14 -14
View File
@@ -70,22 +70,22 @@ everything the installer puts on the machine, its optional tasks, the console pa
This is also the path for **canary** builds, which winget doesn't carry — see
[Release Channels](/docs/channels) for that download.
> **About the Unknown Publisher prompt.** The installer is signed with a self-signed certificate, so
> Windows warns before it runs — accepting the prompt is enough, nothing else is required. The winget
> route is no different: it downloads and runs that same installer. If you'd
> rather silence it, the matching **`punktfunk-host-windows_<ver>.cer`** is published next to the
> installer, and it's the **same certificate for every release**, so this is one-time. A self-signed
> certificate is its own root, so it has to go in both stores. In an **admin** PowerShell:
> **About signing.** The installer is signed with a publicly trusted certificate, so Windows shows
> the publisher by name at the UAC prompt — there is no Unknown Publisher warning and nothing to
> import. The winget route is no different: it downloads and runs that same installer.
>
> ```powershell
> Import-Certificate -FilePath .\punktfunk-host-windows_<ver>.cer `
> -CertStoreLocation Cert:\LocalMachine\Root
> Import-Certificate -FilePath .\punktfunk-host-windows_<ver>.cer `
> -CertStoreLocation Cert:\LocalMachine\TrustedPublisher
> ```
> SmartScreen is a separate mechanism that builds reputation per publisher, so shortly after a new
> signing certificate starts being used it can still show *"Windows protected your PC"* on the first
> downloads — **More info → Run anyway**. It settles as installs accumulate.
>
> This is a different certificate from the one the bundled **drivers** are signed with — the
> installer imports that one for you.
> The bundled **drivers** are a separate matter — they carry their own certificate, and the installer
> imports that one for you. [Windows Host](/docs/windows-host#about-the-signatures) has the detail.
>
> Releases **0.28.1 and earlier** were signed with our own self-signed certificate, and older docs
> told you to import it. Nothing needs it any more: if you imported
> `punktfunk-host-windows_<ver>.cer` back then, you can remove it from `Cert:\LocalMachine\Root` and
> `Cert:\LocalMachine\TrustedPublisher` (look for the certificate issued to **unom**, thumbprint
> `CD1EFDEEEC9743AFC38F56C5AF30C5A3009BE941`).
## NixOS
+6 -3
View File
@@ -227,9 +227,12 @@ Three things are left on purpose:
component other apps may be using, so the Punktfunk uninstaller never touches it. Remove it
with its own uninstaller — `VBCABLE_Setup_x64.exe -u -h` — or the **VB-Audio Virtual Cable**
entry in Installed apps.
- **The publisher certificate**, if you imported it by hand to silence the Unknown Publisher prompt.
Remove it in `certlm.msc` under **Trusted Publishers** and **Trusted Root Certification
Authorities**. (This is *not* the driver certificate above, which the uninstaller does remove.)
- **The old publisher certificate**, if you imported it by hand to silence the Unknown Publisher
prompt on 0.28.1 or earlier. Releases since then are signed by a publicly trusted CA and never
needed it, so it is safe to drop: remove the certificate issued to **unom** (thumbprint
`CD1EFDEEEC9743AFC38F56C5AF30C5A3009BE941`) in `certlm.msc` under **Trusted Publishers** and
**Trusted Root Certification Authorities**. (This is *not* the driver certificate above, which the
uninstaller does remove.)
If you registered the winget source, drop it too — in an **admin** PowerShell, the same as
registering it:
+22 -10
View File
@@ -94,12 +94,14 @@ are `winget upgrade unom.PunktfunkHost` and removal is `winget uninstall unom.Pu
### About the signatures
Punktfunk signs with its own certificates rather than a publicly trusted one, so Windows warns about
an unknown publisher before setup runs. Accepting the prompt is enough. To silence it for good, the
matching **`punktfunk-host-windows_<ver>.cer`** is published next to the installer and it is the
same certificate for every release — the one-time import is in
[Install → Windows](/docs/install#windows). This applies to the winget path too: winget downloads
and runs that same installer.
Setup, and the Punktfunk executables it installs, are signed with a **publicly trusted** certificate,
so UAC names the publisher rather than warning about an unknown one, and there is nothing for you to
import. This applies to the winget path too: winget downloads and runs that same installer. (The
bundled third-party pieces keep whatever their own vendors shipped.)
Releases **0.28.1 and earlier** were signed with our own self-signed certificate, and the docs then
asked you to import `punktfunk-host-windows_<ver>.cer`. Current builds no longer produce or need it —
[Install → Windows](/docs/install#windows) shows how to remove it if you imported one.
The bundled drivers carry a **second, separate** self-signed certificate (`CN=punktfunk-driver`,
SHA-1 thumbprint `4B8493E7CD565758D335F8F4F05C5A7261A13E02`). The installer adds it to the machine's
@@ -114,12 +116,22 @@ punktfunk-host-setup-<ver>.exe /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP-
Keep `/SUPPRESSMSGBOXES`: without it an unattended run can stop on a message box nobody can see.
A silent run takes the same defaults as the wizard, so add or drop individual options with Inno's
`/MERGETASKS` — a bare name adds a task, a `!` prefix removes one. `/MERGETASKS="gamestream"` turns
on Moonlight compatibility; `/MERGETASKS="!trayicon"` skips the status icon. The task names are
`installdriver`, `installgamepad`, `installaudiocable`, `installhdrlayer`, `gamestream`,
A silent **first** install takes the same defaults as the wizard, so add or drop individual options
with Inno's `/MERGETASKS` — a bare name adds a task, a `!` prefix removes one.
`/MERGETASKS="gamestream"` turns on Moonlight compatibility; `/MERGETASKS="!trayicon"` skips the
status icon. The task names are `installdriver`, `installgamepad`, `installhdrlayer`, `gamestream`,
`allowpublicfw`, `startservice` and `trayicon`.
An **upgrade** is different: it reuses the choices the previous install recorded, not the defaults
above. That is usually what you want, but it means anything you once declined stays declined — and
if that includes `installgamepad`, the gamepad drivers are never updated alongside the host, which
surfaces later as a virtual controller games stop seeing. To force the full set on an upgrade, name
them (`/MERGETASKS` merges with the remembered set rather than replacing it):
```powershell
punktfunk-host-setup-<ver>.exe /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- /MERGETASKS="installdriver,installgamepad,installhdrlayer,startservice"
```
Two things to know before you script it:
- Setup **aborts with a non-zero exit code** if another Moonlight-compatible host — Sunshine, Apollo,
+10
View File
@@ -24,6 +24,16 @@ release is born complete and the announcement always has something to say.
Discord `#releases`. Pressing "go" is the quality gate — a half-built release is never
announced. Stable-only; a `-rc` tag is refused unless `allow_prerelease=true`.
**If a platform's run never appears, do not re-run the PR run — it cannot publish.** A re-run
replays the original event (`pull_request`), and android's publish steps are gated on a `push`, so
they stay skipped no matter how often you press it. 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
(2026-08-14: `1e5dca4c` lost its run to `b5cace3a`, 12 s later), which is how an android change
reaches main having never been built. Recover it by dispatching `android.yml` on that ref with
**`publish=true`**; that is the only manual path reaching the registry and Play, and a plain
dispatch stays build-only so a stray click can't ship to testers. Check for the gap by matching
your own merge sha in the run list — "CI ran" is not the same as "your commit ran".
Editing the notes after the tag is fine: update this file, then re-run step 4 (or PATCH the body
via the API) — the announce step always re-syncs from the file, so the file stays authoritative
even across a tag re-point.
+52 -1
View File
@@ -114,7 +114,17 @@
// every existing function keeps its signature and behaviour, and an embedder that never calls it
// is unchanged. The `Welcome` grew a trailing field, which older peers skip in both directions
// (see `Welcome::encode`), so [`WIRE_VERSION`] is unchanged.
#define PUNKTFUNK_ABI_VERSION 20
// v21: added `punktfunk_connect_ex10` — `connect_ex9` plus `device_name`, the label an unpaired
// client knocks with: what the host's pending-approval list (and the web console's
// outstanding-pairings view and approve dialog) shows, and the trust-store name on approval. The
// C ABI had no such parameter, so every embedder took [`client::device_name`]'s OS default —
// which resolves through `COMPUTERNAME`/`HOSTNAME`, neither of which exists in an Apple GUI
// process, leaving every Mac, iPad, iPhone and Apple TV knocking as the literal "This device"
// (a console with three of them pending showed three identical rows). A NEW symbol, not a
// widened one: `ex9` keeps its parameter list AND its behaviour — it passes a null name, which
// selects that same default. Additive and client-local: the name rides the `Hello::name` field
// hosts have read since the pending list existed, so [`WIRE_VERSION`] is unchanged.
#define PUNKTFUNK_ABI_VERSION 21
// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
@@ -2578,6 +2588,47 @@ PunktfunkConnection *punktfunk_connect_ex9(const char *host,
int32_t *status_out);
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Like [`punktfunk_connect_ex9`], plus `device_name` (ABI v21): the human-readable label this
// device knocks with — what the host's **pending-approval** list (and the web console's
// outstanding-pairings view and its approve dialog) shows for an unpaired client, and what the
// trust store files it under once approved. Pass the name the user already recognises this
// device by: `Host.current().localizedName` on macOS, `UIDevice.current.name` on iOS/tvOS,
// `Settings.Global.DEVICE_NAME` on Android.
//
// NULL / empty = the [`crate::client::device_name`] default, exactly as every earlier variant.
// That default is an OS hostname, which no Apple GUI process could reach until v21 — every one
// of them knocked as the literal "This device", so a console with three of them pending showed
// three identical rows. Longer than [`crate::quic::HELLO_NAME_MAX`] bytes of UTF-8 is truncated
// (on a character boundary) rather than rejected: a too-long label is a cosmetic problem, and
// failing a connect over it would be a much worse one.
//
// # Safety
// Same as [`punktfunk_connect_ex9`]; `device_name`, when non-null, must be a NUL-terminated C
// string that stays valid for the duration of the call.
PunktfunkConnection *punktfunk_connect_ex10(const char *host,
uint16_t port,
uint32_t width,
uint32_t height,
uint32_t refresh_hz,
uint32_t compositor,
uint32_t gamepad,
uint32_t bitrate_kbps,
uint8_t video_caps,
uint8_t audio_channels,
uint8_t video_codecs,
uint8_t preferred_codec,
uint8_t client_caps,
const char *launch_id,
const uint8_t *pin_sha256,
uint8_t *observed_sha256_out,
const char *client_cert_pem,
const char *client_key_pem,
const char *device_name,
uint32_t timeout_ms,
int32_t *status_out);
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Generate a persistent client identity: a self-signed certificate + private key, both
// PEM, NUL-terminated, written into the caller's buffers. Generate ONCE, store both
+2 -1
View File
@@ -303,7 +303,8 @@ pwsh -File packaging\windows\pack-host-installer.ps1 -Version 0.0.0-dev -TargetD
Push a `vX.Y.Z` tag — one tag releases every platform (see
[Release Channels](https://punktfunk.unom.io/docs/channels)). The workflow builds, signs, and
publishes `punktfunk-host-setup-X.Y.Z.exe` + the public `.cer`, refreshes the stable `latest/`
publishes `punktfunk-host-setup-X.Y.Z.exe` (no `.cer` — Azure signing is publicly trusted, and mode 2
or 3 would be needed to emit one), refreshes the stable `latest/`
alias, and attaches the installer to the unified Gitea Release. Main pushes publish rolling
`<next-minor>.<run>` **canary** builds (base derived from the latest stable tag by
`scripts/ci/pf-version.ps1`) to the `canary/` alias.
+1
View File
@@ -276,6 +276,7 @@
"pairing_pending_deny": "Ablehnen",
"pairing_pending_name_prompt": "Gerät benennen",
"pairing_pending_name_title": "Dieses Gerät zulassen",
"pairing_pending_name_desc": "{name} — Fingerprint {fp}. Gespeichert wird der Name, der hier steht.",
"pairing_pending_age_just_now": "gerade eben",
"pairing_pending_age_secs": "vor {s}s",
"pairing_pending_age_mins": "vor {min} min",
+1
View File
@@ -276,6 +276,7 @@
"pairing_pending_deny": "Deny",
"pairing_pending_name_prompt": "Name this device",
"pairing_pending_name_title": "Approve this device",
"pairing_pending_name_desc": "{name} — fingerprint {fp}. It is stored under the name you leave here.",
"pairing_pending_age_just_now": "just now",
"pairing_pending_age_secs": "{s}s ago",
"pairing_pending_age_mins": "{min} min ago",
+11 -3
View File
@@ -37,9 +37,17 @@ export const PendingDevicesSection: FC = () => {
qc.invalidateQueries({ queryKey: getListPendingDevicesQueryKey() });
qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() });
};
const onApprove = async (id: number, currentName: string) => {
// The dialog names the device it is about — the field is pre-filled with the same string, but a
// pre-filled field is editable text, not a statement of WHICH knock this is. With two devices
// waiting the operator would otherwise be approving whichever row they hope they clicked, so the
// fingerprint rides along: it is the only thing that stays unique when two devices share a name.
const onApprove = async (id: number, currentName: string, fingerprint: string) => {
const name = await promptText({
title: m.pairing_pending_name_title(),
description: m.pairing_pending_name_desc({
name: currentName,
fp: `${fingerprint.slice(0, 16)}`,
}),
label: m.pairing_pending_name_prompt(),
defaultValue: currentName,
confirmLabel: m.pairing_pending_approve(),
@@ -75,7 +83,7 @@ export const PendingDevicesSection: FC = () => {
*/
export const PendingDevices: FC<{
pending: Loadable<PendingDevice[]>;
onApprove: (id: number, currentName: string) => void;
onApprove: (id: number, currentName: string, fingerprint: string) => void;
onDeny: (id: number) => void;
/** Id of the row whose approve/deny is in flight, or null — only that row disables. */
pendingId: number | null;
@@ -136,7 +144,7 @@ export const PendingDevices: FC<{
<Button
size="sm"
disabled={pendingId === p.id}
onClick={() => onApprove(p.id, p.name)}
onClick={() => onApprove(p.id, p.name, p.fingerprint)}
>
{m.pairing_pending_approve()}
</Button>