Compare commits

..
Author SHA1 Message Date
enricobuehler 332a81b077 Merge remote-tracking branch 'origin/main' into worktree-android-console-visual-refresh
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m28s
apple / swift (pull_request) Successful in 1m41s
apple / screenshots (pull_request) Skipped
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m30s
ci / rust-arm64 (pull_request) Successful in 6m15s
android / android (pull_request) Successful in 6m58s
ci / web (pull_request) Successful in 1m16s
ci / bun-nix (pull_request) Successful in 21s
ci / docs-site (pull_request) Successful in 1m35s
ci / rust (pull_request) Successful in 15m56s
nix / flake (pull_request) Successful in 14m8s
2026-08-11 07:37:32 +02:00
enricobuehler f692a1877c fix(android): the console answers its first field test — the glass, the drum, and the room to breathe
Every item from the on-glass review on the Nothing Phone 3, which found the refresh technically
correct and visually wrong.

**The glass was structurally broken, twice.** The focus bloom was drawn OUTSIDE the clip on
purpose ("a glow that stops at the edge is just a brighter border") — but unclipped drawing does
not stop at the row's neighbours either: in a list it painted over the rows above and below, and in
the carousel it escaped the card entirely. And the focus drop shadow is drawn UNDER the surface,
which is translucent — so the shadow showed straight through the fill as a dark rectangle floating
inside every card and field. Both are gone, and the comment forbids their return: the Apple glass
(`GlassStyle.swift`) is material + an animatable tint, full stop. Focus is now the fill and border
brightening (tint up to 0.28, near Apple's 0.30) — which is also why it finally animates like the
Apple client: one interpolating fill instead of four stacked effects arriving on separate curves.

**The select field is now the Apple drum.** `ConsoleOptionBand` ports `GamepadOptionBand.swift`
whole: options ride a turning cylinder segment, position driven by one spring whose retargeting
preserves velocity — rapid steps accumulate into one accelerating travel instead of five restarted
fades. Linear, not a ring; neighbours exist only mid-flight; the soft edge is per-option opacity,
never a mask (a mask rasterises the projection away — the Apple file's own field verdict). The
band's width is fixed by the row, so a step can never reflow the chevrons; portrait narrows it
(132 dp) because at 156 the LABELS truncated, and a clipped label loses meaning where a drum value
only loses its tail into the edge fade. Chevrons are icons now, not '‹' text glyphs.

**Landscape got room.** Rows cap at the Apple client's 620 dp and sit left; the focused row's
description moved into the width a wide phone was wasting — a side pane on the right, with the
row's label anchoring it — instead of a band floating over the list's tail. Portrait keeps the
band. Add-host fields take the same cap.

**The list is no longer guillotined.** The safe area now applies to the CHROME (strip, sides, top)
only; the list runs to the physical bottom edge with the bottom inset folded into its
contentPadding, so scrolled rows glide off the screen instead of being cut at an invisible line
above it.

**"Default settings" stopped shouting.** The headline that repeated itself on every tab is a quiet
"Defaults" chip at the strip's end — same honesty (this screen edits the base layer only), no
second heading repeating the tab pill's own word.

**Tab switching is finally directional.** The old cut slid a single list's contents 24 dp under an
85 % fade — the same crossfade whichever shoulder was pressed. It is now an AnimatedContent whose
incoming section slides from the side the press pointed at while the outgoing leaves the other way.
Each pane owns its own LazyListState (one state cannot attach to two lists — the constraint that
motivated the single-list hack), seeded at the section's restored cursor.

**The carousel ignores the safe area again, on purpose.** Insetting the pager clipped the fanned
neighbours at the cutout edge; cards visibly cut off is worse than cards behind a camera. Only the
centred card matters and it sits mid-screen — the title and legend keep their insets, they are
content.

**The legend stopped jumping.** The connect takeover parked its pill at bottom-CENTRE, so pressing
Connect made the one piece of chrome that should read as fixed leap halfway across the screen. It
now sits at the same bottom-start inset as every console screen.
2026-08-11 07:31:57 +02:00
enricobuehler d715225960 Merge pull request 'Launcher tiles get their launcher's logo — a brand token on the wire, the vector in every client' (#162) from worktree-launcher-icons into main
apple / swift (push) Successful in 1m33s
android / android (push) Successful in 7m5s
ci / rust (push) Successful in 7m54s
arch / build-publish (push) Successful in 8m5s
ci / rust-arm64 (push) Successful in 1m43s
ci / web (push) Successful in 58s
ci / bun-nix (push) Successful in 16s
ci / docs-site (push) Successful in 1m13s
release / apple (push) Successful in 10m37s
deb / build-publish (push) Successful in 3m57s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 5s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 5s
deb / build-publish-client-arm64 (push) Successful in 3m46s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 5s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 5s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 56s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m27s
deb / build-publish-host (push) Successful in 7m0s
docker / builders-arm64cross (push) Successful in 30s
docker / deploy-docs (push) Successful in 51s
apple / screenshots (push) Successful in 5m48s
windows-host / package (push) Successful in 17m50s
windows-host / winget-source (push) Skipped
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m5s
flatpak / build-publish (push) Successful in 9m30s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m0s
windows-host / canary-manifest (push) Successful in 22s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m23s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m54s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m23s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m4s
Reviewed-on: #162
2026-08-11 04:58:36 +00:00
enricobuehler e1198495e0 Merge pull request 'NixOS+KDE still has no compositor on 0.27.0 — a capped KWin refuses the /proc/exe rung that #158's fix depends on' (#161) from worktree-nixos-capped-compositor-exe-denied into main
apple / swift (push) Successful in 1m32s
ci / web (push) Successful in 1m17s
ci / rust-arm64 (push) Successful in 1m42s
apple / screenshots (push) Successful in 6m3s
ci / bun-nix (push) Successful in 41s
ci / docs-site (push) Successful in 1m26s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 31s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 19s
deb / build-publish-client-arm64 (push) Successful in 1m43s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 16s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 52s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m24s
windows-host / package (push) Successful in 16m37s
windows-host / winget-source (push) Skipped
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m20s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m52s
android / android (push) Successful in 5m46s
deb / build-publish (push) Successful in 4m40s
ci / rust (push) Successful in 7m59s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 10s
arch / build-publish (push) Successful in 9m11s
docker / deploy-docs (push) Successful in 41s
windows-host / canary-manifest (push) Successful in 22s
docker / builders-arm64cross (push) Successful in 18s
deb / build-publish-host (push) Successful in 7m50s
Reviewed-on: #161
2026-08-10 21:28:10 +00:00
enricobuehler 6336e16350 Merge pull request 'bun audit went red on the console — three findings had fixes, the other two have no patched version anywhere' (#160) from worktree-bun-audit-web into main
arch / build-publish (push) Canceled after 32s
audit / cargo-audit (push) Successful in 30s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
audit / bun-audit (plugin-kit) (push) Successful in 19s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
audit / bun-audit (sdk) (push) Successful in 19s
audit / bun-audit (web) (push) Successful in 18s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-host / package (push) Canceled after 1m11s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
audit / docs-site-audit (push) Successful in 20s
audit / pnpm-audit (push) Successful in 12s
audit / license-gate (push) Successful in 4m42s
nix / flake (push) Successful in 15m20s
Reviewed-on: #160
2026-08-10 21:27:42 +00:00
enricobuehler f62a48d4a9 feat(library): launcher tiles get their launcher's logo — a brand token on the wire, the vector in every client
apple / swift (pull_request) Successful in 1m42s
apple / screenshots (pull_request) Skipped
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 3m12s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m32s
ci / rust-arm64 (pull_request) Successful in 1m52s
ci / web (pull_request) Successful in 1m6s
ci / docs-site (pull_request) Successful in 1m15s
ci / bun-nix (pull_request) Successful in 18s
android / android (pull_request) Successful in 3m56s
ci / rust (pull_request) Successful in 5m40s
A launcher tile (role: "launcher", design D4) shipped no art on purpose:
a launcher's own icon is square, every client cover-crops a 2:3 poster,
and the crop turns a mark into a strip. So the tiles were the launcher's
name on a flat accent face — legible, and the blandest thing in the grid.

Entries now carry an optional `icon`: the NAME of a brand mark, never
image bytes and never a URL. `[a-z][a-z0-9-]{0,31}`, shape-validated by
the host on every lane (a client interpolates the value into a resource
name or an asset lookup, so the guard belongs upstream of all of them,
and each client re-checks rather than trusting the peer).

A token rather than art because the alternative is closed by
construction, and deliberately: the art proxy serves what the bytes ARE
(sniff_image_type) and SVG is not on that list — it is script-capable
XML and the console renders library art in a browser. Widening that
sniff would trade a rendering nicety for a stored-XSS surface. Naming
the mark keeps the refusal intact, keeps the glyph vector at whatever
size a tile happens to be, lets it take the tile's ink, and adds nothing
to a reconcile payload that is already body-limited. The cost is that a
third-party plugin cannot ship a mark no client bundles; its tile falls
back to the launcher's name, exactly as before, and the fix is a PR
adding the master.

assets/launcher-icons/ holds seven monochrome masters with per-mark
provenance and licensing (Simple Icons CC0: lutris, heroic, epic, gog;
Font Awesome CC BY: steam, xbox; Playnite's own logo, MIT). steam is
generated FROM assets/os-icons/steam.svg so the SteamOS host badge and
the Steam launcher tile can never drift.

scripts/gen-launcher-icons.sh bakes the three derivatives that cannot
consume a master (GTK symbolic SVG, Windows PNG, Apple template PDF)
and — unlike gen-os-icons.sh, which prints path data for a human to
paste — GENERATES the three inline registries (web console, Android
ImageVector, pf-console-ui Skia). Three clients x seven paths of up to
3 kB is a transcription error waiting to happen, and a mangled character
is a silently wrong logo rather than a build failure. The generated Rust
goes through rustfmt, since `cargo fmt --all --check` is a CI gate and a
generated file that fails it would fail every regeneration.

All six renderers draw the mark CONTAINED, never cover-cropped: the
masters' viewports are not square (steam 496x512, playnite 1024x1024)
and filling a 2:3 frame would reproduce the strip this exists to avoid.
Every one keeps its old fallback for a token it has no art for.

Epic, GOG and Xbox marks ship dormant. Those plugins' launcher switches
are off by default and emit nothing, because the host has no verified
launcher_ui activation for them yet — shipping the art now keeps turning
one on the one-line plugin change those plugins promise, instead of also
needing a release of all six clients.

api/openapi.json and the SDK are regenerated (the spec's version field
was stale at 0.25.0 and now reads 0.26.0, which is the crate's actual
version — an unrelated line that regeneration necessarily corrects).

Verified: host cargo check, clippy -D warnings across pf-client-core /
pf-console-ui / punktfunk-client-session / punktfunk-client-linux, plain
build, pf-console-ui tests (77, including a new one asserting all seven
masters parse under Skia and one asserting the letterbox stays inside
its box), pf-client-core tests (188), cargo fmt --all --check, Apple
swift build, Android compileDebugKotlin, web tsc + vite build,
plugin-kit tsc, biome. The Windows client is NOT compile-verified — it
cannot be built from a Mac (scripts/xcheck.sh covers only the capture
stack by design) and CI does not build it either; its tile change needs
a real box before it ships.
2026-08-10 23:26:47 +02:00
enricobuehler c817e4bec0 fix(pf-vdisplay): a CAPPED compositor refuses /proc/exe — NixOS+KDE still undetectable
apple / swift (pull_request) Successful in 1m55s
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Failing after 3m34s
android / android (pull_request) Successful in 4m27s
ci / rust-arm64 (pull_request) Successful in 1m57s
ci / web (pull_request) Successful in 1m4s
ci / bun-nix (pull_request) Successful in 19s
ci / docs-site (pull_request) Successful in 1m18s
v0.27.0 resolved the nixpkgs wrapper decoration (`.kwin_wayland-w`) through `/proc/<pid>/exe`.
On the box that fix was written for, the kernel refuses that link.

Reading `/proc/<pid>/exe` is not gated on owning the process: `cap_ptrace_access_check` requires
the reader's effective set to be a superset of the target's PERMITTED set. NixOS's own Plasma
module ships `security.wrappers.kwin_wayland = { capabilities = "cap_sys_nice+ep"; }`, and our
host must stay uncapped — a capability is precisely what makes it unidentifiable to KWin (#136).
So the two traps compose: the name NEEDS `exe` because nixpkgs wrapped the binary, and `exe` is
DENIED because NixOS capped it. `detect_active_session` went back to `ActiveKind::None`, and every
connect died `no usable compositor` with `wayland="-"` — the identical signature to the bug #158
fixed, which is why a box worked around with a decoy process broke again when the decoy went away.

`match_name` now falls through to `argv[0]` (`/proc/<pid>/cmdline`) when `exe` is unreadable. It
reads correctly for the same reason `ps` does: make-wrapper's wrapper `exec -a "$0"`s the hidden
binary, so `argv[0]` survives the decoration `comm` does not. It is consulted LAST and never
overrides a readable `exe` — it is the process's own claim about itself, and a same-uid process can
set it to anything; the worst a spoof achieves is aiming detection at a backend that then fails its
own availability probe.

MEASURED (Linux 6.x, same-uid reader, target holding cap_sys_nice), for a file capability and for
the ambient form `security.wrappers` actually uses — identically:

  /proc/<pid> owner   real uid   (so the uid filter upstream was never the problem)
  comm                readable   (decorated + truncated, unusable alone)
  exe                 EACCES
  cmdline (argv[0])   readable

End-to-end against a LIVE capped, `.kwin_wayland-wrapped` process, old resolver vs new, both
extracted verbatim from their own sources: v0.27.0 answers `.kwin_wayland-w` (no match) in both
capped scenarios and `kwin_wayland` uncapped; the fixed one answers `kwin_wayland` in all three.

Also reached by the same rung: gamescope carries `cap_sys_nice` on a number of distros, so a
wrapped-and-capped gamescope was equally invisible to the foreign-gamescope probe.

Tests are fixture-driven (a temp dir with `comm`/`exe`/`cmdline`), for the reason #158 documented:
a renamed `/bin/sleep` stand-in dies instantly on multi-call coreutils and reads exactly like the
resolver being broken. 11 tests extracted verbatim and executed on Linux as a non-root user —
0 failures. `cargo fmt --check` and `scripts/xcheck.sh linux clippy` (`--all-targets`) clean.

Refs #158, #136.
2026-08-10 23:23:59 +02:00
enricobuehler ad806be70a fix(deps): close the dompurify, js-yaml and nanoid advisories in web
ci / rust-arm64 (pull_request) Failing after 5s
ci / web (pull_request) Successful in 1m9s
ci / docs-site (pull_request) Failing after 1m19s
ci / bun-nix (pull_request) Successful in 25s
ci / rust (pull_request) Successful in 6m47s
nix / flake (pull_request) Successful in 13m37s
The audit workflow's blocking `bun audit` leg went red on web with five
findings; sdk and plugin-kit stayed clean. Three had published fixes and are
bumped through the existing overrides block:

  * dompurify  3.4.12 → 3.4.13  GHSA-55q2-fjhq-7xh7 (IN_PLACE hook removal
    leaves a detached subtree executable). The override was already pinned at
    ^3.4.12, which the new advisory range swallowed.
  * js-yaml    4.3.0  → 4.3.1   GHSA-5p4m-2wfm-xmqj (quadratic CPU in !!omap).
  * nanoid     3.3.16 → 3.3.18  GHSA-2v37-7h3g-55p8 (custom generators loop
    forever at size zero). New override — it arrives under postcss, which was
    already pinned, but the pin does not reach postcss's own dependency.

The remaining two are the image-size infinite-loop DoS pair
(GHSA-w3rx-r6r6-pgpr, GHSA-5p2g-fcmc-qvqq), and they are ignored in the
workflow rather than fixed, because they are both unfixable and unreachable:
the vulnerable range is `<= 2.0.2` and 2.0.2 is the latest published version,
so no override can clear them; and image-size rides in under
@unom/ui > @payloadcms/richtext-lexical > ... > payload, where
@payloadcms/richtext-lexical is a peer that only @unom/ui's ./richtext export
needs. The console imports section, toast, button, card, dialog, form/*,
material and tabs — never ./richtext — so payload is auto-installed peer weight
that no bundle and no request path touches. The ignores are scoped to the web
matrix leg so sdk and plugin-kit stay strictly fail-on-any, and the reasoning
is recorded at the step so they can be dropped once image-size ships a fix or
@unom/ui marks that peer optional.

bun.nix is regenerated by the bun2nix postinstall; scripts/ci/check-bun-nix.sh
reports both bun packages in sync. web build, typecheck and server tests pass.
2026-08-10 23:09:57 +02:00
enricobuehler bde4276632 Merge pull request '0.27.0' (#159) from worktree-release-0270 into main
audit / bun-audit (sdk) (push) Successful in 24s
audit / bun-audit (plugin-kit) (push) Successful in 25s
audit / bun-audit (web) (push) Failing after 20s
audit / docs-site-audit (push) Successful in 1m2s
audit / cargo-audit (push) Successful in 35s
audit / pnpm-audit (push) Successful in 58s
apple / swift (push) Successful in 1m48s
ci / web (push) Successful in 1m8s
ci / bun-nix (push) Successful in 24s
ci / docs-site (push) Successful in 1m58s
ci / rust-arm64 (push) Successful in 2m40s
audit / license-gate (push) Successful in 4m51s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m11s
apple / screenshots (push) Successful in 3m2s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m44s
decky / build-publish (push) Successful in 49s
ci / rust (push) Successful in 17m17s
deb / build-publish (push) Successful in 16m21s
deb / build-publish-host (push) Failing after 6m51s
deb / build-publish-client-arm64 (push) Successful in 2m15s
docker / deploy-docs (push) Successful in 1m53s
arch / build-publish (push) Successful in 11m28s
sbom / sbom (push) Successful in 52s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m0s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m59s
web-screenshots / screenshots (push) Successful in 5m19s
flatpak / build-publish (push) Successful in 13m27s
windows-host / package (push) Successful in 12m40s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 18s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m8s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 10s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 8s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 17s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m29s
docker / builders-arm64cross (push) Successful in 9s
linux-client-screenshots / screenshots (push) Successful in 2m32s
android / android (push) Successful in 6m47s
release / apple (push) Successful in 11m47s
android-screenshots / screenshots (push) Successful in 1m30s
nix / flake (push) Failing after 12m40s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m37s
chore(release): bump workspace version to 0.27.0

87 commits since v0.26.0. C ABI 17 -> 18 (additive), so a minor rather than a 0.26.x patch.

The one red check is nix.yml's `flake` job: `bun run build` for punktfunk-web was OOM-killed
(exit 137) inside the nix sandbox, twice, on two different runner hosts. It is not this diff --
main built the identical web sources in 32s, and this commit touches no file under web/ or
packaging/nix/. nix.yml does not run on tags, so it cannot affect the release. Tracked as a
memory-headroom problem in the gate #158 added.
2026-08-10 19:36:43 +00:00
enricobuehler 147bc82130 chore(release): bump workspace version to 0.27.0
ci / web (pull_request) Successful in 1m9s
apple / swift (pull_request) Successful in 1m42s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m21s
ci / bun-nix (pull_request) Successful in 23s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m56s
android / android (pull_request) Successful in 8m13s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m33s
ci / rust-arm64 (pull_request) Successful in 8m39s
ci / rust (pull_request) Successful in 18m11s
nix / flake (pull_request) Successful in 17m53s
87 commits since v0.26.0. The number is forced rather than chosen: the C ABI moves
17 -> 18, and `scripts/ci/pf-version.sh` already reports PF_BASE=0.27.0.

Fills in what `docs/releases/v0.27.0.md` and the `## v0.27.0 — in development`
CHANGELOG section deferred to the bump -- the lead-in, the TL;DR, "Before you update",
the version table and the commit count -- and merges in the work that landed without
notes: the Windows Xbox HID pad, the Apple console-UI batch, and the v0.26.0 field
repairs. Everything already accumulated on main is kept as written.

Version table, all re-derived at HEAD rather than read off the diff:
  * wire 2 (unchanged), C ABI 17 -> 18 (ADDED symbol, not widened)
  * crate dirs 26 -> 27 (`punktfunk-encode-worker`), 39 members
  * gamescope `+pfhdr4` -> `+pfhdr5` (6 patches -> 7, the PipeWire UAF), pkgrel resets to 1
  * driver proto 6, gamepad channel 3, plugin index 1, SDK 0.1.4, plugin-kit 0.4.0 -- all unchanged
  * `api/openapi.json` stays 0.25.0: it moves only on an API EDIT, and the last one
    (`6cffe29b`) predates the v0.26.0 tag. It is not a mechanical per-release bump.

⚠ `crates/pf-driver-proto` is NO LONGER byte-identical to the previous release -- it was
through both v0.25.0 and v0.26.0, and the last two CHANGELOGs told packagers so. Three
additive `device_type`s. Called out explicitly so a packager who has been skipping it stops.

The Play whatsnew names the one real Android change (the OLED palette is Eclipse now;
display name only, the stored `oled` id is unchanged so saved choices carry over) and is
otherwise explicit that the release is host-side.

`docs-site/.../running-as-a-service.md` claimed "0.26.0-2 revoked it everywhere". No such
version exists -- the Arch registry goes 0.26.0-1 straight to 0.27.0 canaries -- and the
corrected 0.26.0 packages shipped under the SAME version string, which is exactly why a
machine that installed an early one was never offered the fix. Reworded to say that, since
it is the reason this release matters to those users.

Gates, all green on this commit:
  * `cargo fmt --all --check` clean
  * `cargo metadata --locked` resolves
  * `Cargo.lock` diff versions-only -- 72/72 changed lines, 36 crates
  * Play gate's own python3 arithmetic: 400/500 CHARACTERS, not byte-identical to any other
    release (`wc -c` misleads -- `•` is 3 bytes)
  * notes voice check: 1 hit, reviewed and kept -- `getcap /usr/bin/punktfunk-host` is a
    command the reader runs, the same class as the `usermod` line v0.26.0's notes carried
  * `docs-site` builds

NOT DONE: no tag yet.
2026-08-10 20:51:45 +02:00
enricobuehler 30c47eb691 Merge pull request 'NixOS could not detect a graphical session at all — and the module was never checked by CI' (#158) from worktree-nixos-wrapped-comm-detection into main
apple / swift (push) Successful in 1m43s
ci / docs-site (push) Successful in 1m19s
ci / web (push) Successful in 1m27s
ci / rust-arm64 (push) Successful in 2m47s
deb / build-publish-client-arm64 (push) Successful in 1m28s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 1m33s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 33s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 26s
apple / screenshots (push) Successful in 3m1s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m26s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m31s
ci / bun-nix (push) Successful in 35s
deb / build-publish (push) Successful in 8m7s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 6s
deb / build-publish-host (push) Successful in 8m47s
arch / build-publish (push) Successful in 9m25s
docker / deploy-docs (push) Successful in 48s
docker / builders-arm64cross (push) Successful in 13s
android / android (push) Successful in 6m57s
ci / rust (push) Successful in 6m38s
windows-host / package (push) Successful in 16m38s
windows-host / winget-source (push) Skipped
nix / flake (push) Successful in 14m42s
windows-host / canary-manifest (push) Successful in 31s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 21m25s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m12s
Reviewed-on: #158
2026-08-10 18:37:32 +00:00
enricobuehler bd26949e0a Merge pull request 'The library's hide button was invisible AND clickable — a corner nobody could see dropped games' (#157) from worktree-library-hide-affordance into main
arch / build-publish (push) Canceled after 40s
ci / rust (push) Canceled after 41s
ci / docs-site (push) Canceled after 42s
ci / web (push) Canceled after 42s
ci / rust-arm64 (push) Canceled after 41s
ci / bun-nix (push) Canceled after 40s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 24s
deb / build-publish-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 4s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 4s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-host / package (push) Canceled after 1m10s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
Reviewed-on: #157
2026-08-10 18:37:03 +00:00
enricobuehler 3afc17a06d build: record pf-console-ui's serde_json dev-dependency in the lockfile
The shared-vectors tests added in 0b550dad read console-vectors.json with serde_json, but the
lockfile never caught up: every gate that has run since was either the Android JVM suite (no cargo
at all) or the Linux container, which builds an rsynced COPY of the tree. The first cargo run
inside the worktree itself — building the phone's native library — wrote it.
2026-08-10 20:27:41 +02:00
enricobuehler 61dfc3dadc Merge remote-tracking branch 'origin/main' into worktree-nixos-wrapped-comm-detection
ci / bun-nix (pull_request) Successful in 40s
ci / web (pull_request) Successful in 1m19s
ci / docs-site (pull_request) Successful in 1m30s
ci / rust-arm64 (pull_request) Successful in 1m46s
android / android (pull_request) Successful in 5m22s
ci / rust (pull_request) Successful in 6m47s
apple / swift (pull_request) Failing after 13m41s
apple / screenshots (pull_request) Skipped
nix / flake (pull_request) Successful in 13m48s
2026-08-10 20:26:23 +02:00
enricobuehler 1befa8a2c4 docs(nix): bring the Nix docs in line with the module, and fix a stale claim they shared
There are three places Nix is documented — the public docs-site, packaging/nix/
README.md, and packaging/README.md — plus the changelog. All had drifted.

STALE CLAIM, and not only for Nix. install.md said the plugin runner's "user unit
ships **disabled** — enable it once you have" something to run. That is true only
of Arch and source installs: the deb postinst and RPM %post both
`systemctl --global enable punktfunk-scripting.service`, and the Bazzite sysext
bakes in a default.target.wants symlink (build-sysext.sh:113). bazzite.md carried
the same claim about its own image. Both corrected, per channel, with the reason
the default flipped — the library scanners are plugins, so a host without the
runner can come up with an empty library — and the `mask`-not-`disable` opt-out
the sysext's own comment documents.

docs-site:
  * install.md NixOS — `desktopSession` in the example and explained, the runner
    no longer needs enabling, and the host/console line says what autoStart does.
  * running-as-a-service.md — "Restart the host with your desktop" documented the
    drop-in for packaged installs only; NixOS gets its one-liner beside it.
  * bazzite.md — the runner is started for you, not "isn't started".

packaging/nix/README.md:
  * option tables gain `desktopSession`, `gamescopeHdr`, `gamescopePackage`, and
    the `punktfunk` group next to `input` (both are required — the udev rule
    chgrp's the vhci nodes and fails outright if the group was never created).
  * "what the module configures" gains the security.wrappers entry, and a note on
    why the capability sits on the encode worker and never on the host: a wrapper
    raises it into the ambient set, which lands it in the permitted set and fails
    KWin's /proc/<pid>/exe readlink identically to a file capability.
  * the appliance snippet no longer tells you to put pkgs.gamescope on PATH —
    gamescopeHdr does that with the patched build, and desktopSession is called
    out as the thing to leave off there.
  * a caveat recording that `nix flake check` does not check the module, and the
    two rules for editing module-check.nix (assertions stay pure Nix; assert
    list-valued unit fields on the lists, not the rendered text).

packaging/README.md: the flake ships five packages, not "host + client".

CHANGELOG.md v0.27.0: a NixOS section covering the comm/session-detection fix, the
module changes including the scripting default flip as an explicit behaviour
change, and the flake-check gap — plus the documentation bullets above.
2026-08-10 20:24:21 +02:00
enricobuehler f90a4e5f97 refactor(android): ConnectScreen splits, and the console can finally open Controllers and Licenses
WP9.2 and WP8.3 of the console visual-refresh plan.

**WP9.2 — the 1361-line ConnectScreen becomes 979 plus three files.** `HomeTiles.kt` holds a pure
`buildHomeTiles` (non-composable, so it is unit-testable — `HomeTilesTest` pins six behaviours the
console carousel had no cover for at all, including that a saved host also advertising on a NEW
DHCP address is listed once, which exercises the fingerprint match rather than just "the builder
lists what it is given"). `ConnectGrid.kt` holds the touch home. `ConnectPrompts.kt` holds
everything modal. `ConnectScreen`'s signature is untouched, so `App.kt` compiles unchanged.

What stayed, deliberately: the state and the engine — discovery, the permission dance, identity,
the trust decision, the dial and its wake fallback, the deep-link router. Those close over ~20
locals that a dozen callbacks read AND write, so lifting them means inventing a state holder, which
is a second refactor and a second thing to get wrong. A comment above `ConnectScreen` now says so.

🛑 **A real bug the split surfaced, fixed here:** the console carousel was live under a FINISHED
speed test. It looked covered by `!connecting`, and was — until the measurement ended, because
`startSpeedTest` clears `connecting` before the Done card is dismissed. From that moment the card
and the carousel underneath both consumed the pad, so one A dismissed the card *and* started a
connect. `speedTest` now sits in the `navActive` gate with every other modal.

**WP8.3 — two screens the console could not reach.** On a TV box there is no touch interface to
fall back to, so the notices and the controllers view were simply unreachable. Both are now console
screens at nav depth 2 (reached FROM settings, which is what makes the trip a push and the way back
a pop), opened by a Controller-tab row and an About row. `GpSettingsPlace` carries the cursor across
the trip, keyed by row ID rather than index, so Back lands where you left rather than on the first
row of the first tab — and because a tab's length follows the hardware, an index would have been
the stale-pointer bug the tab-switch clamp already exists for.

Four blockers, all real:

* 🛑 `ControllersScreen` installed the shared input probes unconditionally and NULLED them
  unconditionally on dispose — no identity check, unlike `GamepadNavEffect2D`. During the shell's
  push/pop both screens are briefly composed, so its teardown would have killed the incoming
  screen's pad navigation. Now it releases only the slot it still owns.
* 🛑 `LicensesScreen` had exactly ONE focusable node, and Compose only scrolls to keep a FOCUSED
  child visible — so a D-pad could not read past the first screenful of a many-screen file. Both
  screens now drive their scroll state directly: up/down steps 0.28 of the viewport, shoulders page
  0.88 — under a screenful on purpose, so the line you were reading survives the press.
* ⚠ Both were inked from the TOUCH theme (28 `colorScheme` sites plus implicit pulls from
  `OutlinedCard`, `Switch`, `OutlinedButton`, `LinearProgressIndicator`), which is always dark —
  invisible over the six PALE palettes. They are now shown through one `ColorScheme` derived from
  `LocalGamepadInk` rather than 27 call-site branches, because call-site edits cannot reach the
  implicit pulls at all. Screenshot scenes shoot both on a dark and a pale palette; the pale pair is
  the point.
* ⚠ B was already taken — the input test's exit is a 1.2 s hold. The rule is now stated on screen:
  while the test runs the pad is the test's, a short B answers with the boundary thud instead of
  doing nothing, and the legend collapses to one "Hold to finish" cell.

🛑 **Second bug fixed in passing:** that hold ended the test AT the 1.2 s mark, so the B *release*
then fell through to MainActivity's B→BACK remap and closed the whole screen. It bit the touch
screen too. The test now ends on the release, which is therefore consumed.

Residual TV gap, flagged not fixed: the Controllers screen's inner buttons ("Grant USB access",
"Test rumble", "Test haptics") have no console focus list, so they stay touch-only — a denied Sony
USB grant still has no console recovery path.
2026-08-10 20:18:20 +02:00
enricobuehler f8cde0adaf feat(nix): actually check the NixOS module in CI, and close the sweep's open issues
THE CI GAP. `nix flake check` does not check `nixosModules`. It forces the value
and asserts it is a lambda taking an open attribute set — nothing more; nix's own
source carries `// FIXME: if we have a 'nixpkgs' input, use it to check the
module.` Measured: a flake whose module sets a nonexistent OPTION, references a
nonexistent `pkgs` attribute AND calls a nonexistent `lib` function passes clean,
printing `checking NixOS module 'nixosModules.default'... all checks passed!`.
nix.yml's header claimed that leg covered the module; it never did, for the
module's whole life — on a flake whose history is Nix regressions reaching main
invisibly.

Closed with `checks.<system>.nixos-module` (packaging/nix/module-check.nix): it
evaluates the module against real nixpkgs in four scenarios (desktop, appliance,
native-only, client-only) and asserts on the rendered systemd units. The
assertions are PURE NIX so instantiating the check runs them — which means the
eval-only `--no-build` leg CI already runs is sufficient, and no Rust is built.
Stub fake-derivation packages keep it independent of punktfunk-host/-client and
the from-source gamescope; crane and bun2nix are provably not needed (they are
`throw`s in the wiring test and it still instantiates).

17 checks, including regression guards for every divergence the sweep found and
for the KWin identification trap (host ExecStart must stay on the plain store
path, never a capability wrapper, while the encode worker points AT the wrapper).
Mutation-tested: 8 mutants, each re-introducing one real defect, all 8 rejected,
baseline green. The suite already earned it once — its first run failed a correct
module because systemd renders `After=` as one space-separated line, so those
assertions now read the evaluated lists instead of the text.

Also closed from the sweep:

  * services.punktfunk.host.desktopSession (new, default false) — binds the host
    to graphical-session.target, the declarative form of the
    punktfunk-host-desktop-session.conf drop-in. Without it a Plasma/GNOME
    restart leaves the host holding a Wayland socket and portal D-Bus connection
    that died with the old compositor: it still listens, still answers, and every
    session it then serves fails at capture. Off by default because an appliance
    may never reach that target and would be left permanently stopped.

  * scripting.autoStart now defaults ON, matching the deb postinst and RPM %post,
    which both `systemctl --global enable` the runner. It was opt-in here on the
    reasoning that the runner is inert until you add automation — which stopped
    being true when the game-library scanners became plugins. A NixOS host came up
    with an empty library and no obvious reason why. The module and README carried
    the superseded rationale verbatim; both updated.

  * A warning when the host is enabled and xdg.portal is not. A warning rather
    than `xdg.portal.enable = mkDefault true`, because enabling the portal service
    with no `extraPortals` backend is its own broken state and only the operator
    knows which backend their compositor needs.

  * punktfunk-gamescope gets a `build-gamescope` dispatch input. It is on the
    critical path of every host build (`gamescopeHdr` defaults true) yet nothing
    compiled it; it tracks nixpkgs' gamescope, so a flake.lock bump — not a change
    of ours — is what breaks it, and the first to find out would be an operator
    whose system rebuild fails.

All .nix files reformatted with the flake's own declared formatter
(nixfmt-rfc-style from the PINNED nixpkgs, not a channel's).
2026-08-10 19:57:10 +02:00
enricobuehler e93947969f fix(console): the hide button was invisible AND clickable — a corner nobody could see dropped games
ci / bun-nix (pull_request) Successful in 21s
ci / docs-site (pull_request) Successful in 59s
ci / web (pull_request) Successful in 1m0s
ci / rust-arm64 (pull_request) Successful in 1m29s
ci / rust (pull_request) Successful in 4m39s
Hiding a library title shipped in 0.26.0 and was, in practice, unusable: the operator
reported there was simply no button, then found it by CLICKING the empty top-right corner
of a poster.

Both halves of that are the same defect. The card's control row was `opacity-0` until
`group-hover`, and `opacity-0` paints nothing while still HIT-TESTING. So the corner of
every tile in the grid was a live hide button with no visual presence at all: a stray
click there removed that title from every play surface — the client grid, the native
clients, the GameStream app list — with nothing on screen having suggested a control was
under the cursor. What read as "the button finally appeared" was the hide taking effect,
since `hidden` is the one state that drops the `opacity-0`. The feature announced itself
by firing.

And the reveal rested on hover ALONE. `:hover` never fires on a touch screen, so on a
tablet the hide control was unreachable by construction and discoverable only by the blind
click above. The original commit spotted this hazard for UN-hide — it kept those controls
always-visible so nobody could be stranded in the hidden state — but left the hide side
hover-gated, which is the same trap one step earlier.

So opacity and `pointer-events` now move together, always: whatever cannot be seen cannot
be clicked. `pointer-coarse:` shows the row outright wherever the device has no hover to
give, rather than making touch a second-class path. Keyboard reach is unchanged —
`pointer-events: none` does not block focus, so tabbing in still trips `focus-within`,
which now restores interactivity along with opacity.

The eye icon also gains a `title`. On a scanned entry it is the ONLY control on the card,
with no edit/delete beside it to read as a toolbar, and an unlabelled eye-with-slash is
not a promise that a game is about to leave the library.

Verified in the built CSS rather than by eye, because a variant that does not compile
fails exactly like the bug being fixed: `@media(pointer:coarse)` emits both
`pointer-coarse:opacity-100` and `pointer-coarse:pointer-events-auto`, and it lands at the
END of the sheet — media queries add no specificity, so this tie against the base
`.pointer-events-none` / `.opacity-0` is won on source order, not by accident. The
`group-hover:` and `focus-within:` forms compile to `:is(:where(.group):hover *)` and
`:focus-within`, carrying a pseudo-class each, so they win on specificity outright.

Console: tsc clean, production build clean, biome clean on the touched file, i18n 633
messages across en+de.
2026-08-10 19:55:13 +02:00
enricobuehler 430c9831ef refactor(android): the prompts that say the same thing in both interfaces are now one prompt
WP9.1 of the console visual-refresh plan — dialog unification.

Six prompts existed twice: a Material `AlertDialog` in `ConnectDialogs.kt` and a console glass card
in `GamepadDialogs.kt`, maintained by hand. They had drifted, and always in the same direction —
the console losing something:

* "Pair with PIN…" and "Use a PIN…" lost their ellipses, so the console said the buttons finished
  something the touch UI said would open another step;
* "if no prompt appears when you tap Allow" became "after Allow";
* the speed test dropped `speedTestTargetNote` entirely, leaving a console user — often on a TV box,
  which is exactly the machine whose link is worth measuring — no statement of which layer "Apply"
  was about to write to. That is a write in an unknown direction.

What is shared now is the DESCRIPTION of a prompt (a title, a list of `DialogAction`s, a body) and
what stays per-interface is only how it is drawn. `PunktfunkDialog` takes that description and
renders it as an AlertDialog or as the existing console modal. Actions are ordered primary-first:
the console stacks them that way with the cursor on the first, and the touch renderer lifts the
same first action into `confirmButton`. One order, two idioms.

The two renderers cannot be one tree — an AlertDialog composes into its own platform window while
the console modal is a Box in the calling tree, which is why one needs a `BackHandler` and the
caller's `navActive` gate and the other needs neither.

Deliberately NOT unified, and they belong apart: the PIN ceremony (a keyboard field and an editable
device name against four D-pad digit slots is a different input model, not a different skin),
Add/Edit Host (a bottom sheet against a full screen with its own on-screen keyboard), and the host
action list (an anchored dropdown against a modal stack that also grows a row per profile).

Twelve composables become six. `ConnectScreen`'s dialog block loses ten `if (gamepadUi)` branches.
2026-08-10 19:30:34 +02:00
enricobuehler 159bbdbfc2 fix(nix): port three NixOS-module divergences from the shipped systemd units
A sweep of the Nix packaging against the units the deb/rpm actually install
found three decisions that were made, documented and deliberate everywhere
else, and simply not carried into packaging/nix/nixos-module.nix.

punktfunk-web — StartLimitIntervalSec=0.
  The unit's EnvironmentFile for the mgmt token is mandatory ON PURPOSE, so the
  console genuinely fails until the host's first `serve` writes it. systemd's
  default rate limit (5 starts / 10 s) against RestartSec=2 then gives up
  permanently after ~10 s — which on an appliance is exactly the window before
  the host is ready, so a console enabled before the host's first run stayed
  dead until someone restarted it by hand. scripts/punktfunk-web.service has
  carried the override since that defect was found; the Nix module omitted it
  while its own comment went on promising "Restart retries until the host has
  created it".

punktfunk-web — Restart=always, not on-failure.
  A console that exits 0 has still stopped serving, and on-failure leaves it
  down. Matches the shipped unit and web-run.cmd on Windows, both of which
  relaunch bun on ANY exit. An explicit `systemctl --user stop` is unaffected.

punktfunk-scripting — the sandbox was missing entirely.
  The shipped unit confines the runner with NoNewPrivileges, ProtectSystem=
  strict, ReadWritePaths=%h /tmp and an AF_UNIX/AF_INET/AF_INET6 address-family
  restriction, plus PrivateTmp=no (a field report: a private /tmp hides
  /tmp/vhclient and /tmp/.X11-unix, so a plugin launches its vendor binary and
  then cannot reach the daemon behind it). The NixOS unit had none of it — so
  the one unit here that executes arbitrary operator TypeScript by design ran
  strictly LESS confined on NixOS than on every other channel.

Verified by evaluating the module against the pinned nixpkgs and rendering the
units: assertions clean, cap_sys_nice=ep on the encode-worker wrapper, firewall
47984/47989/47990/47992/47993/48010, and each unit carrying exactly the
directives above. That evaluation is NOT something CI does — measured: `nix
flake check` passes a nixosModule containing a nonexistent option, a nonexistent
pkgs attribute and a nonexistent lib function, printing "checking NixOS module
... all checks passed!" while never evaluating it against nixpkgs. nix.yml's
header claims that leg covers the module. It does not; tracked separately.
2026-08-10 19:27:14 +02:00
enricobuehler 4b48748b8a feat(android): the console can finally decide a host's clipboard and profile — and says what it is doing
WP8.2 and the rest of WP8.7 from the console visual-refresh plan.

**The console's Edit Host was missing two decisions entirely.** The touch edit sheet has always
offered a shared-clipboard switch and a profile binding; the console form built only name, address,
port and MAC. Nothing was ever LOST — `KnownHost.copy` preserved both — but a couch-only user could
never CHANGE either, and a TV box has no touch interface to fall back to. Both are now rows in the
console form, driven like settings rows: left/right steps them, A flips or cycles. The binding is
filtered through the live catalog, so a host bound to a since-deleted profile reads as unset rather
than as a name nothing resolves — the same guard the touch sheet applies. "Default settings" leads
the ring as the ABSENCE of a binding, not as a fake catalog entry.

**Accessibility, finished.** The library's coverflow now says which poster a press acts on — from
the art alone a centred cover and a neighbour are indistinguishable, and only the centred one
launches. The group heading is a live region: it is the sole signal that the cursor has crossed
from the launchers into the games, and a coverflow gives a reader no other way to notice, being one
strip rather than two lists. The store badge says why it is there ("Opens Steam" / "From Steam")
instead of reading out a bare vendor name after the title.
2026-08-10 19:26:13 +02:00
enricobuehler 0b550dad84 feat(android): the console tables stop drifting in silence, and the stats overlay gets a pad route
WP8 and WP9 of `punktfunk-planning/design/android-console-ui-visual-refresh.md`, in part.

**WP9.3 — shared parity vectors.** The console's background palettes, its settings section names
and its screen-transition motion each existed in three hand-written copies (`pf-console-ui`, this
client, the Apple client) held together by a comment asking the next person to keep them in step.
`clients/shared/console-vectors.json` now holds them, read the way `deeplink-vectors.json` already
is: `include_str!` in Rust, a relative path in Kotlin, `#filePath` in Swift — never a copy, because
a copy is a fourth contract free to go stale. It carries the DERIVED tables too, the 16-cell mesh
and the 4 blob colours per palette, which is the half that reaches the screen and the half Android
never checked: `GamepadPaletteTest` only ever measured the `stops` they are computed from.

Two drifts it immediately caught, both now closed:

* **The easing was the wrong curve.** `ConsoleMotion.EaseOutCubic` shipped as
  `cubic-bezier(0.215, 0.61, 0.355, 1)` while claiming to be the desktop's `ease_out_cubic`. It is
  not: that is the Penner/Ceaser table's curve, ~0.80 at the midpoint where `1 − (1−t)³` is 0.875 —
  visibly slacker over a 260 ms transition. Compose's `Easing` is a plain function, so it now
  evaluates the real thing analytically rather than approximating it at all. (Apple approximates
  with a different bezier only because SwiftUI's `timingCurve` cannot take a closure; the vectors
  sample the curve with a tolerance so all three can meet it.)
* **The desktop has a seventh tab.** Input — touch mode, mouse, invert-scroll, shortcuts — with
  nothing to set on a phone or a TV. `settings.rs` claims in prose that a setting is found under
  the same word on every client; that was true modulo an omission nobody could see. The vectors
  model it with `desktop_only` rather than picking a side, so neither client has to be wrong.

Rust reads it from three tests placed in the files that own the constants, so nothing had to be
made `pub` to be checkable. Verified green under Linux (the crate is `cfg(linux|windows)` throughout
— `cargo test` on a Mac compiles nothing and passes vacuously): 77 passed, 0 failed. Android's side
gates in CI as a FILTERED task; a plain `:app:testDebugUnitTest` would drag the ~20 Roborazzi
screenshot scenes into every push, and those are a release-artifact job.

**WP8.1 — a pad route to the stats overlay.** The tier could only be cycled by a three-finger tap,
which does not exist on a TV, on a gamepad-only session, or under touch passthrough — while the
settings row promised a live cycle. `Select + X` now cycles it, byte-identical to the Apple
client's `GamepadWire.back | GamepadWire.x`, implemented as the mic chord's twin in `GamepadRouter`
and edge-triggered on the button that completes the mask. The buttons still reach the game, as both
existing chords do. `GamepadChordTest` pins eight cases the kit had no cover for at all, including
that the three chords intersect only on Select and that none is reachable through another.

**WP8.5 — a start-of-stream banner.** The desktop's `skia_overlay` banner, ported with its timing
(opaque 5.4 s, then a 0.6 s fade) and its rule of naming only shortcuts that exist: pad chords when
a pad is present, the touch gesture when there is a touchscreen and the mode can use it. Nothing
`Ctrl+Alt+Shift` is advertised, because Android has none of it. It yields to the motion-unreachable
notice rather than stacking with it — that one reports something broken about *this* session.

**WP8.6 — the home card says which profile it connects with.** `HomeTile` carried a
`pinnedProfileId` the card never drew, so a pinned host+profile card was distinguishable from the
host's own only by a subtitle that had been quietly repurposed to hold the profile name. Both now
show the address like every other card and wear a tinted profile chip — the touch grid's own
convention and the Apple client's, inked from the console palette. Unsaved tiles (discovered, Add
Host) take a dashed edge, which is what the other two surfaces already use to say "not yours yet".

⚠ Not a detail panel: the Apple client REMOVED its own and moved the status onto the card, which is
where the lock and the online pip already were here.

**WP8.7 — accessibility, in part.** The console screens carried three `contentDescription`s and no
`semantics`, `Role` or `stateDescription` at all. A settings row now announces once, merged —
label, value, and the description that lives in the floating band far from it — with `Role.Switch`
and a real toggle state, because a toggle row's on/off string was drawn by nothing at all: the
switch replaces the value text, and the switch was two undescribed `Box`es. Decoration is silenced
rather than labelled (the chevrons were read aloud as punctuation on every focused row). The hint
bar's glyphs, the tab strip and the home tiles are done; `GamepadAddHostScreen` and `LibraryScreen`
are not yet.
2026-08-10 19:20:40 +02:00
enricobuehler 002702bcec fix(pf-vdisplay): NixOS sessions were undetectable — comm is the WRAPPER's name
The session probe decided "is a desktop live?" by reading /proc/<pid>/comm for
every process of our uid and exact-matching it against "kwin_wayland" /
"gamescope" / "gnome-shell" / "Hyprland". comm is the kernel's name for the
executed FILE, truncated to 15 bytes — not argv[0].

nixpkgs wraps essentially every graphical binary: wrapProgram moves the real
ELF aside to `.<name>-wrapped` and installs a wrapper under the original name,
which then `exec -a "$0"`s the hidden file. So on NixOS the kernel reports
`.kwin_wayland-w` (15 bytes of `.kwin_wayland-wrapped`) while ps/pgrep -a show
a perfectly ordinary `kwin_wayland`, because they read argv. Measured against a
live kernel: `.kwin_wayland-w`, `.kwin_wayland_w` (KWin's own
kwin_wayland_wrapper), `.gamescope-wrap`, all 15 bytes.

Nothing downstream could recover from that one string comparison:

  - detect_active_session returned ActiveKind::None on a *running* KDE desktop;
  - wayland_display is only resolved for a detected kind, so the connect log
    reported wayland="-" even though WAYLAND_DISPLAY was correct;
  - pick_compositor's Auto arm returns the DETECTED backend, so a live, fully
    working KWin sitting in available() was never chosen — every connect died
    "no usable compositor";
  - and PUNKTFUNK_COMPOSITOR could not rescue it: pinned_at_a_dead_session
    consults the same probe, turning the miss into a hard error instead.

No environment variable reached the comparison — the XDG_CURRENT_DESKTOP
fallback in detect() is only on the pinned path. Capture itself was never at
fault: a decoy process merely NAMED kwin_wayland satisfied the probe and the
stream came up against the real KWin.

Resolve the name through /proc/<pid>/exe (the full, untruncated file name) and
strip the nixpkgs decoration. Both the leading `.` and the trailing `-wrapped`
are required before anything is stripped, so KWin's own real
`kwin_wayland_wrapper` binary keeps its name rather than collapsing into
`kwin_wayland` and handing the probe the parent's PID. The comm fast path is
kept for every ordinary distro — one read, no readlink, and no name that
matched before can stop matching.

Also applied to foreign_gamescope_running, which had the same defect: nixpkgs
wraps gamescope too, so the attach-vs-spawn ladder saw no foreign session.

Tests are fixture-driven rather than spawn-driven on purpose: a stand-in has to
be a real ELF that tolerates being renamed, and /bin/sleep is not one — modern
coreutils is a multi-call binary that dispatches on the executable's own name,
so a copy called `.kwin_wayland-wrapped` exits instantly and /proc/<pid>/exe is
gone before it can be read. That failure looks exactly like this resolver being
broken; it cost one debugging round here and the same trap is already recorded
in punktfunk-host's /proc matcher.
2026-08-10 18:38:14 +02:00
enricobuehler 88c1e94d94 Merge pull request 'A takeover's mask no longer bars the box's own way back into Game Mode' (#156) from worktree-worktree-gamescope-unmask-midstream into main
apple / swift (push) Successful in 1m37s
ci / rust-arm64 (push) Successful in 3m29s
ci / web (push) Successful in 2m41s
ci / bun-nix (push) Successful in 44s
ci / docs-site (push) Successful in 1m35s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
apple / screenshots (push) Successful in 3m4s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 49s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
deb / build-publish-client-arm64 (push) Successful in 1m43s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 18s
android / android (push) Successful in 7m9s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 52s
docker / builders-arm64cross (push) Successful in 8s
docker / deploy-docs (push) Successful in 45s
ci / rust (push) Successful in 9m31s
arch / build-publish (push) Successful in 10m50s
deb / build-publish (push) Successful in 7m41s
deb / build-publish-host (push) Successful in 8m52s
windows-host / package (push) Successful in 14m7s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 24s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m22s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m44s
Reviewed-on: #156
2026-08-10 16:23:44 +00:00
enricobuehler cac500acc7 feat(android): the console UI stops moving under the cursor, and gets the desktop's real backdrop
Implements P1–P3 (WP1–WP7) of `punktfunk-planning/design/android-console-ui-visual-refresh.md`.

**The two named bugs.**

Settings rows GREW ON FOCUS. The focused row unfolded its description in place
(`AnimatedVisibility` + `expandVertically`), so every D-pad step shrank one row and grew another
and shifted every row below the cursor — on a list simultaneously being scrolled to keep the
focused row visible, whose target therefore moved out from under it mid-animation. Sideways, the
value's `AnimatedContent` animated its own WIDTH on every step, walking the ‹ chevron back and
forth. Rows are now fixed: the description renders in a floating `ConsoleDetailBand` above the
legend pill — the desktop console's reserved detail band (`screens/settings.rs`) achieved by FLOAT
rather than by subtraction, so an overlay can never displace the list. Apple's per-row
`reservesSpace` was rejected for density. The value slot gets a fixed minimum, end alignment, a
snapped `SizeTransform` and tabular figures, so `1920 × 1080 → 2560 × 1440` changes nothing but
the glyphs. `GamepadSettingsLayoutTest` is the regression net — it drives the real screen and
asserts the neighbouring rows' bounds and the focused label's bounds do not move.

The NP3's HOLE PUNCH sat over settings and add-host content. All twelve console call sites padded
with `systemBarsPadding()`, which EXCLUDES `WindowInsets.displayCutout`; in landscape a punch is a
left/right edge inset with no bar behind it. New `Modifier.consoleSafeArea()` is
`systemBars ∪ displayCutout` (the rule Material3's own components lay out against) and the
floating legends' landscape branch keeps ignoring the bars while still taking the cutout —
`SENSOR_LANDSCAPE` parks the punch on exactly that corner. The backdrop stays full-bleed; it is
ambience, and running under the camera is what ambience should do.

**The premium-feel levers.**

The screen transition is now the desktop console's contract instead of `Crossfade(240)`: push
slides the incoming screen up 36 dp out of a fade at 0.985→1 while the outgoing recedes to 0.96;
pop runs it backwards. Direction comes from a nav depth on `GamepadScreen`. `ConsoleMotion` is the
single motion vocabulary (`shell.rs:29`'s `TRANSITION_S`, `render.rs:120-151`'s geometry, plus the
focus/value/tab tweens that were scattered literals) and `ConsoleShape` the corner radii that were
four literals in as many files. Reduce-motion collapses push/pop to a 90 ms cross-fade.

The backdrop is the desktop's actual field: `mesh_sksl` (`library.rs:433-525`) ported to AGSL —
4×4 bicubic colour mesh, four drifting interior warp points, ±8° hue sway, vignette, scrim, `calm`
— compiled per palette and cached, gated on API 33+ (`RuntimeShader`). `GamepadPalette` already
carried `CELL_RAMP`; it grows `MESH_COLORS`, `MESH_INTERIOR` and `meshColors`, so the 16-cell
table is now literally shared with the Rust. API 28–32 keeps the four-blob field, and a shader
that fails to compile falls back to it rather than taking the console down. The `cross()` builtin
is written out by hand — AGSL's function set is a subset of SkSL's and not worth betting on.
Constants are formatted through `Locale.ROOT`: on a German-locale device `String.format` emits
`0,075`, which is a syntax error in shader source.

`Modifier.consoleGlass` gives every row, field, card and tile a vertical luminance gradient, a
1 px top-edge highlight fading into the border, and — with focus — a drop shadow plus an accent
bloom drawn outside the clip, so focus reads as a lens rather than a recolour. Modals get
`consoleCard()`, which fixes a real bug: the cards were a hardcoded near-black indigo while their
text came from the palette, so on any of the six PALE palettes a dialog rendered dark ink on a
dark card and was unreadable. `GamepadInk` grows `card`, `modalScrim`, `highlight` and `danger`.

Micro-interactions: menu haptics (tick on move, thud on a refused press, pulse on confirm) on the
driving pad's own motors, else the phone body, else silence — a TV has neither; a refused value
step nudges 4 dp toward the press and springs back; the pressed-side chevron ticks 2 dp; the
switch knob squashes at mid-travel; one indicator pill GLIDES between the section tabs instead of
six fills cross-fading; the home carousel dips 0.97 under A and its neighbours turn away on the
Apple client's projected `cos` fan (never `rotation3DEffect` — see its note on the settle flash).

Palette leaks closed: the library's group heading was hardcoded white (invisible on a pale field),
the poster ground a fixed indigo, and the launcher badge read `MaterialTheme.colorScheme.primary`
— the TOUCH theme's wallpaper-seeded colour, nothing to do with the field it sat on. The add-host
caret and the keyboard's fill went the same way. The home header now outranks the controller chip,
which was eating "Select a Host" down to "Selec…" on a 360 dp phone.

Screenshot scenes gain the console home on the mesh (API 36), the same home on the blob fallback
(API 31), a landscape home where the carousel's fan is visible, and a pale-palette pass of each.

Not in this change: WP8 (functional parity gaps) and WP9 (structural) — the plan's own P4/P5, and
the dialog cards' backdrop frost, which would need a `HazeState` plumbed through `ConnectScreen`
to be worth anything behind a 94 %-opaque surface.
2026-08-10 18:20:46 +02:00
enricobuehler 657e82cd29 fix(gamescope): a takeover's mask no longer bars the box's own way back into Game Mode
ci / bun-nix (pull_request) Successful in 17s
ci / docs-site (pull_request) Successful in 1m20s
ci / web (pull_request) Successful in 1m29s
apple / swift (pull_request) Successful in 1m44s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m47s
android / android (pull_request) Successful in 4m24s
ci / rust (pull_request) Successful in 6m24s
A managed takeover runtime-masks the box's `gamescope-session-plus@*` unit so its
session supervisor cannot restart it underneath our Steam. The only unmask ran in
`do_restore_tv_session`, on client disconnect — so for the whole stream the mask
stayed on, and it silently barred the door the user was most likely to walk
through next.

`mask_unit`'s own doc said the mask "blocks nothing" on images whose sddm helper
execs the session script directly. That is half right, and the half it gets wrong
is this bug: on f43 bazzite-deck the script's last act is

    systemctl --user --wait start gamescope-session-plus@${CLIENT}.service

(verified on the .41 VM). What the mask fails to stop is the RELOGIN LOOP — sddm
keeps trying regardless, which is why stopping the DM is the real defense. What it
very much does stop is the unit, and with it every entry into game mode, including
the user's own deliberate "Return to Gaming Mode" after a mid-stream switch to the
desktop. Steam then sits on its "Switch to Desktop…" modal forever. `--runtime`
lives in tmpfs, so a reboot cleared it — hence "it works right after a reboot" —
and a plain `unmask` does not (measured: still `masked-runtime`).

So the mask's sound lifetime is shorter than the takeover's: it ends the moment the
box stops being ours. The mid-stream session watcher already detects exactly that,
so it now lifts the mask on a confirmed switch to a desktop session — ahead of the
`compositor_for_kind` arm, because a switch we cannot follow still has to unbar the
return. `Gaming` and `None` deliberately do not lift: a takeover's own managed
session reads as `Gaming` and one momentarily down between relaunches reads as
`None`, and lifting on either would void the mask for the whole stream, in exactly
the SDDM-storm window it exists for.

Fixes a second, worse leak on the way: `honor_session_select_switch` consumed
`STOPPED_AUTOLOGIN` — the only record of what carries a mask — without unmasking,
so under a DM-stop takeover the disconnect restore found an empty list and lifted
nothing. That mask outlived not just the stream but the boot. It is also what let
that path's own step 1 work at all, since the DM's autologin heads back into game
mode through precisely this unit.

The lift is idempotent, keeps the restart list intact (the disconnect restore still
owes those units a `start`), and every hand-back path now routes through it.

Verified on Linux: `switch_ends_mask_window` decision table, plus an ignored
end-to-end test driving real `systemctl --user` (masked → survives Gaming/None →
lifted by a desktop switch → restart list intact → idempotent). Proven non-vacuous
by planting "Gaming also lifts", which fails it on the during-stream assert.
2026-08-10 17:16:29 +02:00
enricobuehler e283f17ab4 Merge pull request 'Apple: a Shortcut or a home-screen widget jumps straight into a host's game library' (#155) from worktree-apple-shortcuts-library-widget into main
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 17s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13s
ci / bun-nix (push) Successful in 41s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 13s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
ci / rust-arm64 (push) Successful in 1m33s
docker / builders-arm64cross (push) Successful in 6s
ci / web (push) Successful in 1m38s
apple / swift (push) Successful in 1m53s
ci / docs-site (push) Successful in 1m56s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m13s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 2m21s
docker / deploy-docs (push) Successful in 32s
apple / screenshots (push) Successful in 2m57s
ci / rust (push) Successful in 6m14s
release / apple (push) Successful in 3m48s
Reviewed-on: #155
2026-08-10 14:56:19 +00:00
enricobuehler 1fc184516a feat(apple): the browse route is real — a Shortcut or a widget jumps straight into a host's library
ci / bun-nix (pull_request) Successful in 28s
ci / web (pull_request) Successful in 1m7s
ci / docs-site (pull_request) Successful in 1m20s
ci / rust-arm64 (pull_request) Successful in 1m32s
apple / swift (pull_request) Successful in 1m41s
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Successful in 12m36s
The reserved punktfunk://browse/<host-ref> route now routes on Apple: it
drives the same libraryTarget every internal surface writes, so the link
lands in whichever presentation the current mode owns — the gamepad
console's in-place library screen, the touch cover, the macOS sheet, or
tvOS's cover. Connect's posture minus the connect: a pin conflict
refuses, a live session is never preempted, an unsaved host gets a
notice (the library rides the paired mTLS identity, so there is nothing
to browse before the host is saved). browse ignores launch=/profile= —
nothing streams until a title is picked, and that connect resolves its
own profile.

On top of the route, the two new front doors:

- OpenLibraryIntent ("Open Game Library") beside Connect/Wake/End in
  Shortcuts/Siri/Spotlight, host-parameterized like the others and
  round-tripping through the URL — one router, no second path.
- A configurable library widget (kind "PunktfunkLibrary",
  AppIntentConfiguration over HostEntity — the configuration the
  HostEntity doc comment anticipated): pick a host, tap into its
  library. Unconfigured it follows the most recent host; a configured
  host that was removed shows the empty state rather than silently
  following a different host. Same .never timeline + HostStore push as
  the hosts widget, now reloading both kinds.

DeepLink.browse(host:) is the one emitter both doors share, covered by
a round-trip test beside connect's; the parse side was already in the
grammar and the vector file. Docs updated (clients, game-library,
profiles-and-links).
2026-08-10 14:11:01 +02:00
enricobuehler 7a8f63e906 Merge pull request 'The console UI answers the iPad field test — and the screen that would never open again was a UIKit read mid-render' (#154) from worktree-apple-gamepad-relanded into main
apple / swift (push) Successful in 1m37s
release / apple (push) Successful in 4m9s
ci / rust-arm64 (push) Successful in 4m48s
ci / web (push) Successful in 1m2s
ci / bun-nix (push) Successful in 17s
ci / docs-site (push) Successful in 2m6s
apple / screenshots (push) Successful in 3m2s
android / android (push) Successful in 11m1s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 52s
deb / build-publish-client-arm64 (push) Successful in 3m48s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m11s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m41s
docker / builders-arm64cross (push) Successful in 6s
deb / build-publish-host (push) Successful in 8m48s
ci / rust (push) Successful in 14m25s
docker / deploy-docs (push) Successful in 31s
arch / build-publish (push) Successful in 15m35s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m43s
flatpak / build-publish (push) Successful in 7m12s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m54s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m14s
deb / build-publish (push) Successful in 15m17s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m26s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m34s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m49s
Reviewed-on: #154
2026-08-10 10:51:03 +00:00
enricobuehler 5ca0dfdcd2 fix(apple): stream audio coexists with Music instead of fighting it
apple / swift (pull_request) Successful in 1m43s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m38s
ci / web (pull_request) Successful in 2m28s
ci / bun-nix (pull_request) Successful in 32s
android / android (pull_request) Successful in 4m18s
ci / docs-site (pull_request) Successful in 2m24s
ci / rust (pull_request) Successful in 8m25s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m30s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m29s
Field report, with a competitor cited doing it right: connecting to a host
PAUSED the user's Apple Music, and resuming Music mid-stream silenced the
stream's audio for good. Both are one defect — the audio session was
EXCLUSIVE. Activating it evicted whoever was playing (the pause at
connect), and Music's resume evicted us right back; iOS stops the engines
on that interruption and restarts nothing, so the stream stayed silent
until reconnect.

`.mixWithOthers` on every category path (playAndRecord, playback, tvOS) is
the heart of it: a mixable session interrupts nobody at activation and is
not interrupted by another app's mixable playback — game audio rides over
the playlist, the way a console does it. The trade is that a mixable
session is nobody's Now Playing app, so the lock screen keeps showing the
music. For a game stream that is the correct reading.

The other half is `installInterruptionObserver`: interruptions still exist
for a mixable session — a phone call, Siri, an app claiming a NON-mixable
session of its own — and ending one restarts nothing by itself. On
`.ended` the observer re-runs the full activation (an interruption can drop
the category, and the earpiece steer is per-route) and revives the engines
through the same conservative `reviveStoppedEngines` the route-change and
media-reset observers already use — unconditionally, not only on the
`.shouldResume` hint: a live stream is the one case where the user's intent
to keep hearing it is not in doubt, and the revive already declines when
playback never went down.

The teardown's `.notifyOthersOnDeactivation` stays as a courtesy for the
edge where an old exclusive install interrupted something; a mixable
session has nobody to notify.

macOS build (session paths are #if'd out there), tvOS typecheck, 284 tests.
2026-08-10 12:47:05 +02:00
enricobuehler a14b000c9b feat(apple): the tray gradient blur is back — as a real blur this time
The form screens' pinned title and legend lost their gradient blur when
GamepadTrayScrim went (2590238b): the scrim was `.ultraThinMaterial`, and a
material by definition lifts and tints whatever it blurs — it read grey over
the aurora, and washed with the palette's ground it read coloured. There is
no public way to blur a backdrop without that tint, so the layer was deleted
rather than re-tuned, and the rows have scrolled to a raw edge since.

Glur's GlurView blurs the backdrop through a gradient with NO material stage
on top — the rows soften as they slide under the pinned trays and nothing
carries a colour. Three things about the adoption are deliberate:

  - It is the library's GlurBackdrop product, which uses a private API
    (a variable-blur CAFilter on UIKit/AppKit). The public `.glur()`
    modifier is a Metal shader on a view's OWN content and silently no-ops
    over platform-backed views like ScrollView, so it cannot reach a
    backdrop at all. GlurBackdrop ships as a separate module precisely so
    adopters can weigh the App Store exposure; it disables hit testing
    itself, so the band never eats a touch.

  - The dependency is pinned by REVISION: GlurBackdrop exists only on main —
    no release carries it (the newest tag, `1.1`, predates it and is not
    three-component semver anyway, so `from:` resolution stops at 1.0.4,
    which doesn't have the product). Wired into BOTH manifests — the
    package for `swift build`, and the xcodeproj (remote package reference
    + per-target product deps) for the app targets, which compile these
    sources themselves and know nothing of Package.swift's dependencies.

  - The band takes NO content-side overhang, unlike the scrim it replaces —
    and dropping the scrim's hard-won -44/-72 runway is the point, not an
    oversight. That runway existed because a material carries body at every
    alpha and had to dissolve OUTSIDE the tray; carried over here it blurred
    fully-visible rows at rest (first on-glass verdict). Glur's ramp reaches
    literal zero, so the band ends exactly at the tray's layout edge: open
    content untouched, rows soften only where they actually slide under.
    The 80 pt outer bleed and the pinned shape
    (`transaction { $0.animation = nil }`) are kept — full-bleed by LAYOUT
    rather than `.ignoresSafeArea()`, so a push never shows the band
    growing into place.

Mounted exactly where the scrim was: settings, add-host and pair trays
(top + bottom) and the library title (top). Glur is attributed on the
Acknowledgements screen alongside the bundled font — it is a Swift package,
so the Rust-closure notices generator rightly knows nothing about it.

macOS build, tvOS typecheck; band placement and the blur itself verified on
the iPad on glass.
2026-08-10 12:46:45 +02:00
enricobuehler 35b5ee6a36 Merge pull request 'punktfunk-encode-worker: GPU priority via a capability-carrying worker, with WP3 on-glass complete' (#153) from worktree-worktree-encode-worker into main
audit / bun-audit (plugin-kit) (push) Successful in 20s
audit / bun-audit (web) (push) Failing after 20s
audit / bun-audit (sdk) (push) Successful in 20s
audit / pnpm-audit (push) Successful in 9s
audit / docs-site-audit (push) Successful in 20s
audit / cargo-audit (push) Successful in 1m9s
apple / swift (push) Successful in 1m42s
ci / web (push) Successful in 1m21s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m48s
ci / docs-site (push) Successful in 1m19s
ci / bun-nix (push) Successful in 17s
android / android (push) Canceled after 5m0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 5m1s
ci / rust (push) Canceled after 4m17s
ci / rust-arm64 (push) Canceled after 4m8s
deb / build-publish (push) Canceled after 54s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 3s
release / apple (push) Canceled after 3m58s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 1s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 2m10s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
decky / build-publish (push) Successful in 26s
audit / license-gate (push) Successful in 6m39s
windows-host / package (push) Successful in 13m21s
windows-host / winget-source (push) Skipped
nix / flake (push) Successful in 15m53s
windows-host / canary-manifest (push) Successful in 25s
Reviewed-on: #153
2026-08-10 10:45:23 +00:00
enricobuehler 548eb4fa14 Merge pull request 'gamescope aborted on every connect — a PipeWire use-after-free wearing an "unreachable" assert' (#152) from worktree-gamescope-pipewire-buffer-abort into main
arch / build-publish (push) Canceled after 27s
ci / rust (push) Canceled after 36s
ci / rust-arm64 (push) Canceled after 36s
ci / web (push) Canceled after 36s
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/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
Reviewed-on: #152
2026-08-10 10:45:05 +00:00
enricobuehler 84faeb1bf1 docs(pf-encode): Linux Main10 is live — the 'inert until Phase 5.1' comment outlived the code
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m8s
apple / swift (pull_request) Successful in 1m36s
apple / screenshots (pull_request) Skipped
ci / bun-nix (pull_request) Successful in 1m15s
ci / web (pull_request) Successful in 1m52s
ci / docs-site (pull_request) Successful in 1m58s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m26s
ci / rust (pull_request) Failing after 4m39s
ci / rust-arm64 (pull_request) Successful in 5m35s
android / android (pull_request) Successful in 5m44s
nix / flake (pull_request) Failing after 18m37s
The bit_depth field said '8 on Linux until Phase 5.1 lands a P010 capture path'.
The code outran it: the gamescope HDR capture patches offer 10-bit BT.2020/PQ,
nvenc_fmt maps X2Rgb10/X2Bgr10 to ARGB10/ABGR10, and is_ten_bit_input flips
bit_depth and hdr from the negotiated input. Verified on home-nobara-1:
'resolved session plan ... bit_depth: 10, hdr: true' on the direct backend.

A 10-bit frame deliberately takes neither the NV12 nor the YUV444 convert (both
compute CSCs write 8-bit planes) and rides packed RGB to the encoder, which does
its own BT.2020 CSC — pf-capture/src/linux/pipewire.rs owns that gate. So Main10
needed no P010 path to arrive, and P010 is now a perf follow-up (skip NVENC's
internal CSC, as NV12 does for SDR), not the thing that makes 10-bit work.
2026-08-10 12:39:46 +02:00
enricobuehler ad63994cb9 fix(pf-encode): the 10-bit probe was the last ffmpeg NVENC open on a direct-SDK host
ci / bun-nix (pull_request) Successful in 24s
ci / web (pull_request) Successful in 1m3s
apple / swift (pull_request) Successful in 1m43s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m14s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m59s
android / android (pull_request) Canceled after 3m11s
ci / rust (pull_request) Canceled after 3m13s
ci / rust-arm64 (pull_request) Canceled after 3m13s
nix / flake (pull_request) Canceled after 2m4s
windows / build (x86_64-pc-windows-msvc) (pull_request) Canceled after 1m9s
`can_encode_10bit`'s Linux NVIDIA arm answered "can this GPU encode 10-bit?" by
opening an ffmpeg `hevc_nvenc` encoder. On a host that then streams over the
direct SDK, that is the LOG-3 field bug: one ffmpeg NVENC open in a direct-SDK
process wedges every later open process-wide with `NV_ENC_ERR_INVALID_VERSION`
until the host restarts.

`can_encode_444` was moved off the ffmpeg probe for exactly this reason on
2026-07-27. The 10-bit one was deliberately left behind, on the reading that
"Linux HDR genuinely rides the libav P010 path". `open_video` contradicts that:

    if cuda && nvenc_direct_enabled() {          // no 10-bit exclusion
        … NvencCudaEncoder::open(…, bit_depth, …)

A CUDA capture goes to the direct backend at whatever depth was resolved, and
`is_ten_bit_input` already accepts the packed 10-bit RGB (`X2Bgr10`) that a
gamescope HDR capture negotiates. So on a default NVIDIA host the probe was
loading ffmpeg's NVENC client for a session that never uses it.

Observed on home-nobara-1 2026-08-10, gamescope + RTX 5070 Ti, client HDR on:

    resolved session plan … bit_depth: 10, hdr: true
    pipewire format negotiated … xBGR_210LE mapped=Some(X2Bgr10) modifier=0 hdr=true
    encoder submit failed — encoder rebuilt in place … NV_ENC_ERR_INVALID_VERSION
    encoder did not recover after repeated in-place rebuilds — ending the video session

and with `PUNKTFUNK_NVENC_DIRECT=0` (nothing mixes, libav serves everything) the
same HDR session streams clean: 0 errors, bit_depth=10, hdr: true.

The 10-bit cap now rides `nvenc_cuda::probe_support()`'s existing throwaway
session — the same place the 4:4:4 cap already rides, queried per listed GUID
with `NV_ENC_CAPS_SUPPORT_10BIT_ENCODE`, which is what the Windows NVENC arm has
always done (`enc/windows/nvenc.rs`). Unanswered fails CLOSED: an 8-bit session
beats a wedged one. A host that will really serve over libav
(`PUNKTFUNK_NVENC_DIRECT=0`, or a build without `--features nvenc`) keeps the
ffmpeg probe, where it validates the actual path and ffmpeg's client is loaded
anyway.

⚠ NOT YET VALIDATED ON GLASS. Gates are green — clippy `-D warnings` with
`--features nvenc,vulkan-encode,pyrowave` on linux/amd64, 67 pf-encode tests,
fmt — but the end-to-end HDR run is still owed. This branch is 42 commits behind
main and its build cannot complete a punktfunk/1 handshake on home-nobara-1 at
all (it stalls between "audio channels resolved" and "encode bit depth" and
times out at 10 s, on EVERY attempt). That stall is NOT this change: a control
build with only the routing reverted stalls identically, and the released
0.27.0 RPM on the same box handshakes fine and reaches `bit_depth=10`. Rebase
onto main before re-testing.
2026-08-10 12:30:24 +02:00
enricobuehler bac63059a9 feat(apple): drive the console UI from a hardware keyboard
Asked for by a field user: "make the iPadOS client compatible with keyboard to
select games with keyboard arrows, enter to launch". An iPad on a Magic
Keyboard and a couch Mac are the same situation the console layout was built
for — a screen driven from a distance with a fixed set of directional inputs —
and the cursor/confirm/back model already exists here for the pad. A keyboard
is a third input onto it, not a new navigation scheme: arrows move, Return and
Space activate, Esc backs out, everywhere the controller already worked
(carousel, menu lists, prompts) plus the plain poster grid.

`active` mirrors each caller's existing controller gate rather than being a
second, parallel notion of "who has input". Without that, a launcher sitting
under an open screen would keep eating key presses and navigate behind it —
the same defect the pad gate exists to prevent. Esc returns `.ignored` when a
screen has no back action, so it still reaches the `.cancelAction` shortcut
that closes a macOS sheet.

The plain grid needed real arithmetic rather than a flat index. It renders up
to TWO `LazyVGrid` sections (launchers above titles), so a flat index steps by
the wrong amount at the boundary whenever the first section's last row is
partial — up from the titles' first row lands mid-launcher-row instead of
above. `LibraryGridNav` moves within a section and hands off at its edges
preserving the column, clamping into partial rows. It lives in PunktfunkKit
because it is edge-case arithmetic and that is the target tests can reach; 12
cases cover the partial row, the hand-off, a stale cursor, an empty grid and a
zero column count.

The column count comes from the grid's MEASURED width run through
`.adaptive`'s own fitting rule, so up/down move exactly one visual row instead
of a guess that drifts with window size. Measured via a background
GeometryReader — a sibling inside a ScrollView would claim the whole viewport.

The grid cursor starts nil and only appears on the first arrow press, so a
touch user is never shown a selection they didn't ask for.

tvOS is excluded throughout: its focus engine already routes hardware arrows,
and these screens hand it navigation authority deliberately.

17 PunktfunkKit tests pass; macOS + tvOS typecheck; launcher and settings
verified rendering and navigating in the iPad Pro 13" simulator.
2026-08-10 09:59:28 +02:00
enricobuehler 3daead7d71 fix(apple): the sub-screens' legends sit where the launcher's does
Carries the display-corner inset (previous commit) through the three screens
that pin the same legend pill — settings, add-host, pair — so they don't drift
from the launcher the moment the user opens one. Each of those trays is
`maxWidth: .infinity, alignment: .leading`, so the pill is left-aligned there
too and the iPad's short centre indicator is never in its way; the keyboard
tray sits ABOVE the legend in the stack, so pulling the tray down doesn't put
keycaps over the strip.

And the settings tab strip now takes its padding from the row metrics instead
of two fixed numbers. It was the other half of the iPad sizing complaint: the
tier work grew the fields under it while the tab pills stayed at phone scale,
which read as a tab bar that belonged to a different screen.

macOS + tvOS typecheck.

Re-landed on the probe spelling: the corner inset is read from
`\.displayBottomInset` (DisplayBottomInsetProbe via ContentView), never asked
of UIKit during body — the original spelling froze the update graph of any
view that called it on device (see the legend-pinning commit).
2026-08-10 09:59:25 +02:00
enricobuehler 18d0009c35 feat(apple): the trust prompt takes controller input
Trust-on-first-use had no pad wiring at all, and it appears at the worst
possible moment for that: mid-connect, with capture already disabled so
ContentView can blur the stream and stop forwarding. A pad-only user pressed A
to connect, got this card, and found the controller in their hands doing
nothing — the only way past was to reach for the screen. A trusts, B cancels,
X runs the PIN ceremony instead.

The legend only appears with a pad attached; naming controller buttons to a
trackpad user would be describing hardware they don't have. `GamepadManager` is
observed rather than read once so the bar arrives if a pad wakes up while the
card is already open.

Safe to poll unconditionally while the card is up: capture is off for its whole
lifetime and the home screens are unmounted behind the session view, so nothing
else is reading the pad. The A press that STARTED the connect is typically
still held when this mounts — `GamepadMenuInput` adopts the held state without
firing on start, so it doesn't auto-trust.

tvOS is untouched: the focus engine already drives these buttons.

macOS + tvOS typecheck.
2026-08-10 09:58:26 +02:00
enricobuehler 8ef5350431 fix(apple): pin the launcher legend to the display's corner
On an iPad the controls pill sat two to three times further from the bottom of
the screen than from the left, so it read as floating rather than tucked into
the corner. `safeAreaInset` places its content INSIDE the safe area, so the
pill's bottom margin was stacking on top of the device's own ~24 pt
home-indicator inset while the leading margin had nothing to stack on.

Subtracting that inset is the fix; GETTING the inset is where this commit's
history is. Three spellings failed before this one, each silently:

  - a `GeometryReader` carrying `.ignoresSafeArea()` — a proxy reports no
    inset for an edge it has been told to ignore, so it can only answer 0;
  - `.ignoresSafeArea(.container, edges: .bottom)` on the inset CONTENT,
    which does not move content the inset mechanism itself placed;
  - asking UIKit for the key window (`UIApplication.shared.connectedScenes…`)
    DURING body — which answers the right number and then KILLS the asking
    view. On a physical iPad (never the simulator) that walk re-enters UIKit
    layout mid-render and SwiftUI silently severs the view's update graph:
    from then on every `@State` write lands in storage without `body` ever
    running again. That is exactly how Settings and Add Host stopped opening
    while their triggers kept firing and `showSettings` kept reading true —
    no AttributeGraph warning, nothing in the log. Found by bisecting device
    builds; a build that opens the screen programmatically renders green with
    input-driven navigation completely dead, so only a real press counts.

So the inset is measured where it is knowable: DisplayBottomInsetProbe, a
UIView that reads its WINDOW's inset from UIKit's own callbacks
(didMoveToWindow / safeAreaInsetsDidChange / layoutSubviews), hops out of the
current update, and publishes through `\.displayBottomInset` from ContentView.
The legend reads the environment — proven safe on glass — and
`gamepadLegendBottomPadding` is pure arithmetic.

Tablets only. The padding goes negative to pull the pill back down through the
indicator strip, which is safe there because the pill is left-aligned and an
iPad's indicator is a short bar in the middle. A phone's indicator is taller
and its legend runs most of the width, so the same move would cross it —
phones keep the plain margin, and the tier comes from the size classes, so an
iPad in a narrow Stage Manager window is treated as the in-hand case it is.

macOS + tvOS typecheck; margins and Settings/Add Host opening from a real
controller press verified on the iPad itself.
2026-08-10 09:58:20 +02:00
enricobuehler 522ac7bd49 feat(apple): the connect path's modals are pad-navigable
`.alert` and `.confirmationDialog` are UIKit/AppKit surfaces: a game controller
cannot move through their buttons or press one. On iOS/macOS that made every
prompt in the connect path a dead end for a pad-only user, and they are not
incidental prompts — "Pairing required" is the FIRST thing an unpaired host
shows (so pairing was unreachable before it even got to a PIN), "Connection
failed" strands the console UI behind a modal only a finger can dismiss, and
"Waiting for approval" owns the only Cancel for a connect that may never
complete. GamepadPromptView renders those states as a console card with a
focus list of actions; the system alerts stand down while it is up. tvOS keeps
them — the focus engine drives them natively there, which is exactly why this
gap was invisible from that platform.

Two things are deliberate rather than incidental:

The gate is "not STREAMING", not `model.connection == nil`. A connection
object exists well before a stream does — through the pair-required and
approval handshakes, precisely when these fire — so gating on the connection
would hand those cases back to the system dialog. Streaming is the one state
that must keep the alert: there the pad belongs to GamepadCapture.

And the overlay hangs off `driven`, not `home`, for the same reason: `home`
renders only while the connection is nil, so a prompt mounted there would be
skipped in the very case it was written for.

The launcher stands down from the controller poll while a prompt is up
(`promptActive`) — without it the host carousel keeps scrolling underneath the
modal and one A press reaches both.

macOS + tvOS typecheck; console UI verified opening Settings in the iPad
simulator with the prompts wired in.
2026-08-10 09:56:25 +02:00
enricobuehler 8ab4918923 fix(apple): the host strip stopped flashing as it settles
Reported from the iPad: the cards arrive, come to rest, and then flash. They
did — and it was not the motion. Every card sat at the WRONG fill for as long
as the entrance animation ran, then snapped to its real one in a single frame
once SwiftUI tore the animation down, roughly a third of a second AFTER the
last card had stopped moving.

`rotation3DEffect` was the cause. A real 3D transform renders its subtree
through an offscreen layer, and these cards carry translucent glass, which
resolves differently in there — so the strip showed the aurora through itself
for the duration and went opaque the instant the layer was dropped.

Measured on an iPad Pro 13", sampling the centred tile: it held #4a3d87 across
twelve frames in which nothing moved, then stepped to #423970 — 23/255 of blue
in one frame, on every card at once, while the backdrop beside them stayed
exactly #713fa0 throughout. Stretching the timeline from 1.02 s to 2.82 s moved
the step from 0.70 s to 2.50 s after launch: the same 0.32 s before the end in
both runs, which is what proves it is the animation ENDING and not any state
the tiles were waiting on. Deleting the rotation deleted the step outright.
`compositingGroup()` above and below the transforms each changed nothing, so
this is the 3D layer specifically, not compositing in general.

The turn is now projected instead of rendered: cos(angle) as a horizontal
squeeze IS the orthographic projection of a Y-axis rotation, hinged on the edge
the card fans from — which also puts the code behind the doc comment that
already described the fan that way (the sign of a rotation carried it before;
cos is even, so the anchor has to). Affine, so there is no offscreen pass and
no layer to drop. What's lost is the perspective trapezoid, which at card size
was never what sold the motion; the fan-out, the stagger and the overshoot are
untouched.

Also drops the temporary PUNKTFUNK_DIAG_OPEN hook that gated each step of this
branch in the simulator.

284 tests pass; macOS + tvOS typecheck; entrance re-recorded on the iPad Pro
simulator — the tile now settles monotonically (#4153cf → #383c77 → #39396e →
#393a6e) and holds, with the fan-out still reading as a turn.
2026-08-10 09:36:02 +02:00
enricobuehler 09bac99090 feat(apple): pairing you can actually do with a controller
PairSheet is a `Form` with two `TextField`s. On tvOS the focus engine drives
those natively, but on iOS/macOS a controller cannot reach a text field, type
into it, or press the button underneath — so for anyone in the console UI,
pairing (the ONE thing between a fresh install and a first stream) ended at
"now touch the screen".

GamepadPairView is the same ceremony in the gamepad UI's own vocabulary: the
vertical focus list the settings and add-host screens use, A on a field to
open GamepadKeyboard in a bottom tray, B to peel one layer. It mirrors
GamepadAddHostView field for field, because it is the same interaction and
someone who has added a host should recognise it immediately.

The ceremony itself moved to a shared `PairCeremony` used by both
presentations, so they can never disagree about what a wrong PIN means, what
a host rejection says, or when a late result must be discarded.

On iOS it is a shell layer like settings and add-host, and it LEADS the
shell's screen order: it blocks a connect the user already asked for and can
be raised from on top of the library (launching a title on an unpaired host),
so it has to win; backing out reveals whatever it interrupted. macOS has no
shell, so its sheet switches content by mode instead. tvOS is untouched.

macOS + tvOS typecheck; console UI verified opening Settings in the iPad
simulator with the pair screen wired into the shell.
2026-08-10 08:12:50 +02:00
enricobuehler 5db3b3c4fd feat(apple): the controls legend is clickable
Every hint cell that names an action is a real button on iOS/iPadOS/macOS.
The legend already lists every action a screen has, in one fixed place, so a
user without a pad in their hands — an iPad on a stand, a Mac driven by
trackpad, anyone running `gamepadUIMode == "always"` — was reading a complete
menu they could not press.

tvOS keeps them inert deliberately. There is no pointer there, so a tappable
cell would have to be FOCUSABLE, and that puts six new stops in the path of a
focus engine whose flow on these screens is load-bearing and hard-won — while
every action in the legend already has a native route (select, Menu,
Play/Pause, the focusable tab pills).

Cells that name an INPUT rather than an action stay labels: "↔ Adjust" is the
stick itself, and "A Type" over the on-screen keyboard has no tap equivalent
because a touch user types by tapping the keycap.

Two details that are load-bearing rather than tidy: the decorative hairline
gets `allowsHitTesting(false)` (it sits on top of the cells), and the press
style's `contentShape` sits below its `scaleEffect` so shrinking the artwork
cannot move the target out from under a resting finger and lose the touch-up.

macOS + tvOS typecheck; 272 tests pass; console UI verified opening Settings
in the iPad simulator.
2026-08-10 08:05:14 +02:00
enricobuehler a23c028492 fix(host): the console reported the resolution the client asked for, not the one it got
`/api/v1/local/summary` (and the console card behind it) read the live-stats mode
slot, which bring-up seeded from the NEGOTIATED mode:

    let live_mode = Arc::new(AtomicU64::new(pack_mode(
        mode.width, mode.height, interval_hz(interval))));

The refresh was already corrected there — the comment says so, because KWin caps
a virtual output's rate — but the SIZE was still the request. Only a mid-stream
resize ever fixed it: the rebuild path below publishes `delivered_mode(frame..)`,
and bring-up never did.

Attach is what makes this matter rather than being pedantry. On a box with a
physical display the gamescope backend logs

    gamescope: box drives a physical display — attaching at its own mode (no
    re-mode) client_w=5120 client_h=1440

and streams the panel's size. Measured on home-nobara-1 with a 1080p HDMI panel
attached: the capture negotiated 1920x1080 and NVENC opened 1920x1080@240, while
the summary reported 5120x1440 — the console confidently naming a resolution
nobody was watching, which is exactly the shape of the stale attach-path report
noted on .41 in July ("reusing w=5120 h=1440" while the session was really 1080p).

Seeding the slot from `delivered_mode(frame.width, frame.height, interval)` uses
the same helper the rebuild path already trusts, and changes only the two fields
that were wrong — its refresh term IS `interval_hz(interval)`, so that half is
bit-for-bit what it was.

This publishes the STATS slot only. It deliberately does not send the client a
corrective `Reconfigured`: that remains owed exactly where it already was, under
`adopted_at_bringup`, because an ordinary connect's mode came from the Welcome
rather than from an accept the client has already acted on.

Verified on home-nobara-1, attach session against a 1080p panel:
  summary session:   {"width":1920,"height":1080,"fps":240}
  actually captured: pipewire format negotiated width=1920 height=1080
Before the change the same session reported 5120x1440.
2026-08-10 07:55:18 +02:00
enricobuehler 5b3ea6e8db fix(pf-encode): one NVENC open failure could kill every session on the box
`punktfunk-host` died twice on home-nobara-1 with the same stack:

    __strlen_evex <- av_vbprintf <- format_line <- av_log_default_callback
      <- ff_cuda_check <- ff_nvenc_encode_init <- avcodec_open2
      <- NvencEncoder::open <- NvencEncoder::reset <- virtual_stream

once as an outright SIGSEGV mid-session, and once as a thread wedged in that
stack so the service never answered SIGTERM and systemd escalated to SIGABRT
("State 'stop-sigterm' timed out. Aborting."). Both times a client's session was
rebuilding its encoder. The blast radius is the whole host process — every other
client's session goes with it.

The fault is in libav, not here. `ff_cuda_check` logs the failing CUDA call as
`"%s failed -> %s: %s"` using an `err_name`/`err_string` pair the error lookup
does not always fill, and glibc then walks whatever was on the stack. We cannot
patch the distro's FFmpeg, so the fix denies it the chance to format: the guard
already used by the 4:4:4 probe drops the level to AV_LOG_FATAL across the open,
and `av_log_default_callback` returns on the level check before `format_line` —
these messages are AV_LOG_ERROR. The failure is not swallowed; it still comes
back as `Err(e)` and is reported with our own context, which now says the libav
text was deliberately silenced so nobody hunts for a message that will not come.

Scoped to the `open_with` call ALONE. The ENOSYS arm immediately below recurses
into `Self::open`, and `QuietLibavLog` holds a non-reentrant global mutex —
wrapping the whole `match` would have deadlocked the intra-refresh retry.

Verified on home-nobara-1 (fc44, libavcodec 62). With CUDA made unavailable so
the open fails inside the CUDA layer, the old binary prints

    [hevc_nvenc @ ..] cuInit(0) failed -> CUDA_ERROR_NO_DEVICE: no CUDA-capable
    device is detected

— that line IS `ff_cuda_check` formatting the two `%s` — and the fixed binary
does not; both exit 1 with our error instead. A successful open is unaffected on
both the direct-SDK and the libav paths (90/90 frames, identical output size).

What this does NOT claim: the uninitialized-pointer condition itself was not
reproduced on demand — it depends on the CUDA error lookup failing to fill the
strings, and in the forced case above it filled them fine. What is demonstrated
is that the formatting call which faulted is no longer reached during the open.
2026-08-10 07:46:47 +02:00
enricobuehler ffb1ecfebe feat(apple): size the console UI for the iPad
GamepadFormMetrics had exactly two tiers, tvOS and everything-else, so a 13"
iPad Pro drew its settings rows, section tabs and legend at iPhone scale in
the middle of a very large display — the field verdict was that the sizing
"does not adapt to larger screens". There is a third tier now, between the
in-hand and 10-foot sets.

Chosen from the SIZE CLASSES, not the device idiom: an iPad in a narrow Stage
Manager or Split View window keeps the in-hand numbers, because what is being
read is the window, not the panel behind it. Phone sizing is untouched.

Published from ContentView — the app root — rather than only from
`gamepadPaletteInk`. A screen that applies that modifier itself sits ABOVE its
own copy of the environment, so its `@Environment` resolves against its parent
and would read the bare phone default no matter what the modifier published.
The screenshot harness publishes it too, for the same reason: it mounts these
screens with no ContentView above them, so an iPad capture used to render the
whole gamepad UI at iPhone scale.

macOS + tvOS typecheck; the console UI verified in the iPad simulator opening
Settings at the tablet tier.
2026-08-10 07:44:36 +02:00
enricobuehler 08e462fee9 fix(apple): Graphite's selected settings tab was white on light grey
The tab strip's selected pill is FILLED with the palette accent, and
`ink.onAccent` — chosen by that accent's own luminance, and documented for
"a filled pill's label" — has existed for exactly this since the palettes
landed. The strip was using the plain foreground instead, which is white on
every dark palette and therefore looked fine on all but one: Graphite's
accent is a light grey (luma ≈ 0.80), so its selected tab read white on
white.
2026-08-10 07:41:43 +02:00
enricobuehler 2590238b8f fix(apple): the console sub-screens' backdrop, glass and option band
Four things the field reported on glass, all in the console's visual layer.

The tray scrim is GONE, not re-tuned. It laid `.ultraThinMaterial` across the
top and bottom of every form screen, and a material by definition lifts and
tints whatever it blurs — so it read grey, and washing it with the palette's
ground only made it read coloured. There is no public way to blur a backdrop
without that tint, so the layer had to go. The darkening it also provided was
never its to provide: the backdrop's own legibility scrim
(0.38/0.06/0.08/0.40 — the same gradient pf-console-ui bakes into its shader)
has always been one layer down, which is why the console has no tray band.

Pale palettes rendered every row, pill and card as a flat white slab, and
lowering the tint did nothing, because the opacity came from the glass BODY:
`.regular` is a bright, high-body material and a pale palette's `ink.glass` is
literal white. Pale palettes take `Glass.clear` now, with a light wash to keep
dark ink legible.

A focused settings row changed colour in a hard jump, a beat late, while its
scale animated smoothly beside it — the focus tint rode `Glass.tint`, and a
Glass value is opaque to SwiftUI's animation system. It is a plain fill
between the glass and the label now, so the existing animation covers it.

The host cards appeared to be swapped for different ones as their entrance
landed: `CardEntrance` swings each tile in on a rotation3DEffect, and Liquid
Glass samples the backdrop through its own layer, which it cannot do under a
3D transform. Those tiles take the material path (`forceMaterial`).

And the option band never turned like a cylinder because the band MASKED
itself: a mask rasterises what it covers, flattening `rotation3DEffect`'s
perspective, so the projection was computed and discarded every frame. The
soft edge is folded into each option's own opacity instead. Its ±1 neighbours
stay hidden at rest — showing them reproduced the documented overlap defect
("2752 × 2064" with "280 ×" through it), which is why they were dropped.

Also fixes pale palettes washing out: the calm mix added a plusLighter wash of
a near-white ground on top of a field already mixed toward it, saturating the
form screens to white.

macOS + tvOS typecheck; console UI verified opening Settings in the simulator,
with no tray band.
2026-08-10 07:33:11 +02:00
enricobuehler beb639f067 fix(ui): the OLED palette is called Eclipse now
Every other background reads as a place or a material — Violet, Nebula,
Abyss, Ember, Moss, Graphite, Holo, Sunset, Bloom, Dawn, Mint, Opal — and
"OLED" was a spec sheet sitting among them, naming the panel it suits rather
than what it looks like. It looks like black with a thin violet corona at one
corner, which is an eclipse, and that puts it beside Nebula and Abyss.

The ID stays "oled" in all three clients: it is the stored ui_palette value
AND the cross-client key, so renaming it would orphan every saved choice and
silently desync Apple, Android and the console UI. Only the label moved. No
test or doc pinned the old string.

Palette tests pass; macOS builds.
2026-08-10 07:30:30 +02:00
enricobuehler 90450ff1f6 feat(apple): the legends remember which pad you own
`buttonGlyph` asked the LIVE controller for its `sfSymbolsName` and fell back
to generic letter glyphs, so the moment a DualSense slept, disconnected or
ran flat, every ✕/◯ hint turned into A/B under its owner's hands. Under
`gamepadUIMode == "always"` — the console UI up with no pad by design — they
were never right at all.

GamepadManager now remembers the family of the last controller that was
actually attached (`DefaultsKey.lastGamepadKind`, written on selection and
deliberately never cleared on disconnect), and the legends resolve live →
remembered → generic. Not a user-facing setting: a glyph-style picker is one
more row asking a question the app can answer itself, and the remembered pad
is right essentially always.

GamepadGlyphs maps by POSITION, not by name, because that is what
`GCExtendedGamepad`'s buttonA/B/X/Y are. So ✕ is role `.a` (the bottom
button), and a Switch pad gets Nintendo's transposed labels rather than Xbox
letters printed on the wrong physical buttons.

The test asserts every symbol name RESOLVES on the running OS. That is the
load-bearing one: `Image(systemName:)` draws an unknown name as nothing at
all — no crash, no log, no red build — so a typo would silently blank a
legend on device and no amount of building would catch it.

macOS + tvOS typecheck; the 5 glyph tests pass; the console UI verified
opening its Settings screen in the iPad simulator.
2026-08-10 07:29:33 +02:00
enricobuehler 890b67a863 fix(validation): v5 called jitter a leak — a leak is a trend, not a spread
v5's verdict was `max - min` over the sampled fd counts with a default tolerance
of 0. An encode worker's fd count legitimately moves by one when a dmabuf fd is
in flight at the sampling instant, so the spread was permanently 1 and the leg
failed on a perfectly healthy box — reported, like every red leg here, as "a
shipping blocker, not a flake".

Measured on home-nobara-1 (KDE, RTX 5070 Ti), 33 samples over 480 s:

    54 54 54 54 54 54 54 54 55 55 54 54 54 55 54 54 55 54 54 54 55 54 …54

It oscillates and ENDS on 54, exactly where it started. Nothing accumulates.

The replacement is median-of-thirds: median(last third) - median(first third).
That is strictly MORE sensitive to what R2 is actually about — a steady leak
moves the trend just as much as it moves the spread, while bounded jitter moves
only the spread — so this is not the tolerance being widened to get a green.
The spread is still printed, now labelled as jitter when the trend is flat. The
warm-up window already covers the one-off first-sight-of-each-buffer cost, so a
plateau inside it is by design not a leak; a step that never comes back still
trends and still fails.

The self-test grows the cases that force this to be a real assertion: the
measured oscillation must trend to zero, a synthetic leak must still trend up, a
flat series must be flat, and a step that never returns must be caught. Writing
them is what caught my own arithmetic — the first draft asserted a leak trend of
12 where the reader correctly says 10.

Also records what the v5 log now makes obvious: `--minutes` does NOT set the wall
clock. `spike` is frame-count bounded (`seconds * fps`), and a KWin virtual
output being driven hard delivers ~197 fps against a `--fps 60` budget, so a
"10 minute" run ended after 182 s. Ask for more minutes than you want.
2026-08-10 03:04:05 +02:00
enricobuehler 9cdbfabd4d fix(validation): v4.e demanded a rung the spike vehicle cannot reach
v4.e killed the worker mid-session and then required "the encode worker died
mid-session" in the spike's log. That line, and the respawn that follows it, are
emitted by `RemotePyroWave::reset` — and the only caller of `Encoder::reset` is
the real session's `reset_stalled_encoder` loop in native/stream.rs. `spike` is
a dev tool with no recovery loop at all: it does

    encoder.submit(&frame).context("encoder submit")?

and exits. So a worker killed under the spike can never reach reset, the line
can never appear, and the leg reported

    FAILED — a red leg here is a shipping blocker, not a flake.

for a ladder rung the product implements correctly. A false negative in the one
place that must not have one: this kit exists to refuse false PASSes, and a
false FAIL spends exactly the same credibility.

Verified on glass first, so the rung is not being excused on a reading of the
source. home-nobara-1 (KDE, RTX 5070 Ti), real client session, worker pid 44249
killed with -9: `video_streaming` stayed true across the kill, and the host
logged

    pyrowave: respawned the encode worker after a mid-session death
      worker=/usr/bin/punktfunk-encode-worker priority=Granted(Realtime)
    encoder submit failed — encoder rebuilt in place, forcing an IDR
      error=... Broken pipe (os error 32) reset=1 max=5

v4.e now asserts the half the spike can actually observe — the death surfaces as
an ATTRIBUTABLE worker-IPC error naming the worker, after real encode windows,
and the host process does not die with it. A hang, an unexplained failure, or a
dead host still fails. The respawn half is printed as the human follow-up, in
the same idiom v1 already uses for its on-glass half, and written into `recipe`
with the two commands that close it.
2026-08-10 02:38:55 +02:00
enricobuehler fdef4c90ce fix(gamescope): stop the PipeWire use-after-free that aborted a session on every connect
ci / bun-nix (pull_request) Successful in 27s
ci / web (pull_request) Successful in 1m7s
ci / docs-site (pull_request) Successful in 1m20s
ci / rust-arm64 (pull_request) Successful in 1m31s
ci / rust (pull_request) Successful in 7m25s
A managed gamescope session on Nobara 44 (VM 123) died on essentially every
client connect. The visible symptom was a black screen; underneath,
`punktfunk-gamescope` was SIGABRT crash-looping — 11 coredumps in three minutes
— until `gamescope-session-plus` ran out of retries and came up on the *stock*
`/usr/bin/gamescope` at its default 1920x1080, which looks like a working game
mode and carries none of our capture patches.

    punktfunk-gamescope: ../src/pipewire.cpp:88: void destroy_buffer(
      pipewire_buffer*): Assertion `false' failed.
    #4  __assert_fail
    #5  destroy_buffer(pipewire_buffer*).cold

The abort is a use-after-free wearing an `assert(false); // unreachable`.
`pw_buffer->user_data` is associated with its `pipewire_buffer` in exactly one
place, at the bottom of `stream_handle_add_buffer` — after all four `goto error`
paths, whose label is a bare `delete buffer`. And `stream_handle_remove_buffer`
clears `buffer->buffer`, the only route back to the `pw_buffer`, while a still-
`copying` buffer is deleted later on the steamcompmgr thread with no way to
reach the slot. PipeWire recycles `pw_buffer` slots across renegotiations, so
the next remove reads `buffer->type` out of freed memory, falls off the end of
the switch and aborts.

The host sets the session to the client's mode on connect, and that mode change
is what renegotiates the stream — which is why "every connect" was the trigger.

Patch 0007 fixes the association rather than the symptom: set `user_data` at
allocation so it is valid on every path out of `add_buffer` and clear it on the
error path; clear it in `remove_buffer`, the last point both halves are known;
null-check the two consumers. The `default:` arm then logs instead of aborting.
Offered upstream — nothing about it is punktfunk-specific.

Two traps this cost time on, both now written down in the README:

  * It is NOT HDR-specific. The abort was first seen right after a 10-bit
    stream negotiated, so `PUNKTFUNK_GAMESCOPE_HDR=0` looked like a workaround.
    The failing argv carries no `--hdr-enabled` at all.
  * `gamescope-session-plus` hides it by falling back to stock gamescope, so a
    session existing proves nothing — read the banner.

`.pfhdrN` moves to 5 even though no capability moved: every deployed pfhdr4
binary crash-loops, so an operator needs to be able to tell them apart. All
`>=` thresholds in the host's probe are unaffected.

Also documents `libstdc++-static` as a build dependency — it is punktfunk's
requirement (the script links the C++ runtime statically on purpose), so no
`dnf builddep` will ever pull it, and without it meson fails with a message
naming neither the flag nor the package.

Verified on VM 123 with the patched binary installed: 5 rapid connect/
disconnect cycles plus 3 further sessions, zero new gamescope coredumps (43
before, 43 after), Steam game mode streaming real content at 5120x1440, and
`/tmp/chimeraos-short-session-tracker` never created — the short-session latch
that used to strand the box in plasma was downstream of this crash.
2026-08-10 02:16:22 +02:00
enricobuehler 8508f8f3c3 Merge pull request 'The gamescope bind is armed on every gamescope box, takes a user namespace that breaks Xwayland, and leaves a drop-in that bricks the next boot' (#151) from worktree-bind-userns into main
ci / web (push) Successful in 1m9s
apple / swift (push) Successful in 1m31s
ci / rust-arm64 (push) Failing after 1m49s
apple / screenshots (push) Canceled after 43s
ci / bun-nix (push) Successful in 42s
android / android (push) Canceled after 2m38s
ci / rust (push) Canceled after 2m41s
ci / docs-site (push) Canceled after 1m27s
deb / build-publish-host (push) Canceled after 9s
deb / build-publish (push) Canceled after 8s
deb / build-publish-client-arm64 (push) Canceled after 7s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 18s
docker / builders-arm64cross (push) Successful in 9s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 51s
docker / deploy-docs (push) Successful in 26s
arch / build-publish (push) Successful in 10m41s
windows-host / package (push) Successful in 12m18s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 16s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 21m14s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m38s
Reviewed-on: #151
2026-08-09 22:41:40 +00:00
enricobuehler 6695300b67 fix(pf-vdisplay): the gamescope bind took a user namespace that broke Xwayland, and left a drop-in that bricked the next boot
ci / bun-nix (pull_request) Successful in 24s
ci / docs-site (pull_request) Successful in 1m5s
ci / web (pull_request) Successful in 1m24s
ci / rust-arm64 (pull_request) Successful in 1m28s
apple / swift (pull_request) Successful in 1m37s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Canceled after 4m4s
ci / rust (pull_request) Canceled after 4m13s
Field-diagnosed on Nobara (fc44, canary g13179011), where Game Mode became unstartable and the box
was handed to plasma. #144's bind works — the patched build genuinely reaches a session script that
hardcodes /usr/bin/gamescope — but a mount namespace in a systemd USER unit is also a USER namespace,
and only this uid is mapped in it. Measured on the box:

    on disk / in a unit without the bind :  drwxrwxrwt 2 0 0          /tmp/.X11-unix
    in a unit WITH the bind              :  drwxrwxrwt 2 65534 65534  /tmp/.X11-unix
    uid_map inside                       :  1000 1000 1

wlroots checks that /tmp/.X11-unix is "owned by root or us", sees nobody, and refuses:

    wlserver: [xwayland/sockets.c:100] /tmp/.X11-unix not owned by root or us
    wlserver: [xwayland/sockets.c:217] No display available in the first 33
    -> SIGSEGV in run_pipewire

Three ~10 s failures then feed chimeraos' short-session tracker, session-plus stops even trying, and
steamos-session-select rewrites the user's session to plasma. So the symptom an operator reports is
"thrown onto KDE and I can't get back" — two removes from the cause.

Two further bugs found while fixing it, each worse than the one reported:

  * THE BIND WAS ARMED EVERYWHERE. The condition was only `gamescope_bin() != /usr/bin/gamescope`,
    so every box with punktfunk-gamescope installed took a namespace it has no use for — Bazzite,
    SteamOS-likes, the Deck. The blast radius was every gamescope box, not just the hardcoded-path
    ones the mechanism exists for. Now the host READS the session script and arms only where it
    never mentions GAMESCOPE_BIN and names /usr/bin/gamescope outright; everything else is
    bit-for-bit pre-#144, no namespace at all. An unreadable script does not arm.

  * THE DROP-IN OUTLIVED ITS SOURCES. It was written to ~/.config/systemd/user/ on the TEMPLATE, so
    it also applied to the box's OWN autologin unit at every boot — while both paths it binds live in
    tmpfs. After a reboot the drop-in survives and its sources do not, and BindReadOnlyPaths= with a
    missing source fails the unit outright. THAT is why the field symptom survived a reboot. It now
    lives in $XDG_RUNTIME_DIR (dies with the login session), removal covers both the runtime and the
    legacy $HOME path, and restore_takeover_on_startup does that removal unconditionally at host
    start — which is the upgrade path for every box already running canary g13179011. Without it,
    updating the host would not un-brick them.

  * A bind was armed even when gamescope_bin() fell back to the bare name "gamescope". The wrapper
    execs `gamescope` through PATH inside the unit — onto the path we just bound the wrapper over.
    Fork bomb. Refused ahead of even the operator's force.

Where the bind IS armed it now carries its own compensation: a user-owned $XDG_RUNTIME_DIR/punktfunk-x11
bound read-WRITE over /tmp/.X11-unix (Xwayland creates the socket there), so the ownership check sees
"us". Skipped when that directory is already ours or absent — neither is the hazard. Stale sockets are
pruned by connect-test so a SIGKILLed session cannot walk the 33 display slots away.

And rather than trust that reasoning, the host now ASKS THE BOX before arming: it runs the field
reproduction with the real property set — `systemd-run --user --wait --collect --property=<the same
args> -- stat -c %u /tmp/.X11-unix` — and arms only if the answer is our uid. Anything else (65534, a
rejected property, no user manager, a blown 10 s budget) means no bind, and the session runs stock
gamescope: no HDR, no in-node cursor, but it STARTS. A runtime backstop disarms and relaunches if a
session launched with the bind armed produces no node in its window, latching one-way per process.

The XFixes-cursor concern that argued against relocating the socket does not hold: the only host-side
X client is spawned under `plan.gamescope_cursor`, which is `gamescope && !gamescope_composites_cursor()`,
and our shipped +pfhdr4 build is patch level 4 — so on the very route where the bind arms, that reader
is never constructed.
2026-08-10 00:39:18 +02:00
enricobuehler 73d435b967 Merge pull request 'The driver clippy gate has been red on main since the Xbox pad landed' (#150) from worktree-drivers-clippy into main
ci / web (push) Successful in 1m6s
ci / rust-arm64 (push) Successful in 1m25s
ci / bun-nix (push) Successful in 28s
ci / docs-site (push) Successful in 1m12s
ci / rust (push) Successful in 9m34s
windows-drivers / probe-and-proto (push) Successful in 21s
windows-drivers / driver-build (push) Successful in 1m39s
windows-host / package (push) Successful in 12m52s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 18s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 10s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 15s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / builders-arm64cross (push) Successful in 6s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 17s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 58s
docker / deploy-docs (push) Successful in 42s
Reviewed-on: #150
2026-08-09 21:55:42 +00:00
enricobuehler c7df7b45af fix(drivers/pf-gamepad): the three Xbox identities as a range — the driver clippy gate is red on main
ci / web (pull_request) Successful in 1m12s
ci / docs-site (pull_request) Successful in 1m46s
ci / bun-nix (pull_request) Successful in 37s
ci / rust-arm64 (pull_request) Successful in 3m49s
ci / rust (pull_request) Successful in 11m37s
windows-drivers / probe-and-proto (pull_request) Successful in 23s
windows-drivers / driver-build (pull_request) Successful in 1m43s
`cargo clippy --all-targets -- -D warnings` over the shipped drivers (the step that
enforces the unsafe-audit gates) fails on main since #149 landed: clippy 1.96's
`manual_range_patterns` fires on all five `4 | 5 | 6` device-type arms, and `-D warnings`
turns each into an error, so `pf-gamepad` fails to compile as both lib and lib-test and
the whole step never reaches the other five crates.

Device types 4/5/6 are the Xbox Wireless / One S / Elite Series 2 identities added by
#149 — contiguous by construction, so `4..=6` is the same set. Purely a lint fix: no arm
gains or loses a device type, and the comments that already record *why* the three share
one report shape, one descriptor and one vendor string are untouched.
2026-08-09 23:46:43 +02:00
enricobuehler 5d7091bf87 Merge pull request 'The Windows Xbox pad: make games actually see it' (#149) from worktree-xbox-pad-wgi-visibility into main
apple / swift (push) Successful in 1m35s
windows-drivers / probe-and-proto (push) Successful in 26s
windows-drivers / driver-build (push) Failing after 1m40s
android / android (push) Successful in 6m12s
ci / rust-arm64 (push) Successful in 5m5s
ci / bun-nix (push) Successful in 21s
ci / web (push) Successful in 5m31s
arch / build-publish (push) Successful in 8m28s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 13s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
ci / docs-site (push) Successful in 4m14s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
release / apple (push) Successful in 10m1s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Failing after 45s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 3m4s
docker / deploy-docs (push) Skipped
ci / rust (push) Successful in 12m22s
deb / build-publish-client-arm64 (push) Successful in 4m27s
deb / build-publish-host (push) Successful in 7m16s
docker / builders-arm64cross (push) Successful in 12s
deb / build-publish (push) Successful in 9m58s
apple / screenshots (push) Successful in 6m11s
windows-host / package (push) Canceled after 12m58s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
flatpak / build-publish (push) Successful in 9m46s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m48s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m50s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m23s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m31s
nix / flake (push) Failing after 21m34s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 24m43s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 25m26s
Reviewed-on: #149
2026-08-09 21:36:29 +00:00
enricobuehler e19f11bb0d feat(abi/apple): carry the trigger motors to non-Rust clients — ABI 18, next_rumble_cmd2
windows-drivers / probe-and-proto (pull_request) Successful in 25s
apple / swift (pull_request) Successful in 1m37s
apple / screenshots (pull_request) Skipped
windows-drivers / driver-build (pull_request) Failing after 1m43s
ci / web (pull_request) Successful in 2m57s
ci / bun-nix (pull_request) Successful in 18s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m36s
android / android (pull_request) Successful in 4m0s
ci / rust-arm64 (pull_request) Successful in 4m19s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m32s
ci / docs-site (pull_request) Successful in 4m36s
ci / rust (pull_request) Failing after 9m15s
nix / flake (pull_request) Successful in 15m3s
The `0xCA` wire already carries the two Xbox impulse-trigger motors (v3), and the Rust decode path
already parses them; `datagram_task.rs` dropped them on the floor with a comment naming exactly this
work as what remained. The blocker was the C ABI: every non-Rust client pulls rumble through
`punktfunk_connection_next_rumble_cmd`, whose out-params cannot carry two more channels.

    PunktfunkStatus punktfunk_connection_next_rumble_cmd2(
        PunktfunkConnection *c, uint16_t *pad, uint16_t *low, uint16_t *high,
        uint16_t *left_trigger, uint16_t *right_trigger,
        uint32_t *backstop_ms, uint32_t timeout_ms);

⚠️ ADDED, not widened. `_cmd` keeps its signature and its values bit-identical for handle-only
traffic — out-of-tree embedders depend on it and `docs/embedding-the-c-abi.md` documents it, so
silently changing an exported symbol would break every consumer at once. `nm` on the staticlib shows
all four rumble entry points still exported. `ABI_VERSION` 17 → 18; every other site reads it
dynamically, so there are no hardcoded mirrors to drift.

⚠️ ONE HONEST BEHAVIOURAL DELTA, documented in `abi.rs` and pinned by a test: against a
trigger-driving host a `_cmd` caller now receives commands with `low == high == 0` where the demux
previously dropped the update entirely. They are idempotent handle stops, and the redundant-stop
suppression cannot fold them because the command as a whole is not silent. Zero cost today —
nothing sources non-zero triggers.

The dedupe-jitter proof was RE-DERIVED rather than widened, which is the kind of thing that quietly
rots when a tuple grows: the nudge touches only `low` by ±1 LSB and `emit` is only reached with a
non-silent level, so the nudged tuple can collide with the four-field stop sentinel only at
`(1,0,0,0)`. A test pins both directions — refuse at `(1,0,0,0)`, flip freely at `(1,0,lt,0)`.

Apple renders them: `RumbleRenderer` gains `Motor?` slots at `GCHapticsLocality.leftTrigger` /
`.rightTrigger` beside the existing handles. A controller without trigger actuators degrades
silently — a nil engine yields a nil slot and `reconcile` no-ops — and absent localities are never
logged, because on most pads that is the normal case rather than a fault. The macOS DualSense
raw-HID branch stays a deliberate no-op: a DualSense has ADAPTIVE triggers, not trigger rumble
motors, and inventing a mapping there would buzz the wrong thing.

🛑 BUILT AHEAD OF A PRODUCER, DELIBERATELY, AND NOTHING HERE CLAIMS OTHERWISE. Nothing can currently
source trigger rumble on Windows and that is measured, not assumed: `XINPUT_VIBRATION` has two
members, and GameInput — the only four-motor API — does not enumerate an xinputhid-promoted Xbox pad
at all, verified against a REAL Microsoft Elite which is equally invisible to it while classic
XInput reads it live. So this path has never been exercised end to end and the comments say so.

VERIFIED
  * `cargo test -p punktfunk-core --features quic --lib` 378 passed on macOS, 203 on Windows;
    clippy `-D warnings` clean with and without default features; `cargo fmt --all --check` clean.
  * The generated header is regenerated and idempotent on re-run (CI diffs it).
  * SWIFT ACTUALLY COMPILES AND RUNS: `swift build` clean and `swift test` 262 passed / 0 failures
    in `clients/apple`, against a locally built xcframework. (Editor SourceKit errors about
    `PunktfunkCore`/`DualSenseHID` are index noise from that gitignored artifact — a real build
    resolves both, and the `DualSenseHID` references are untouched by this change.)
  * `cargo build -p punktfunk-host` clean on Windows.

NOT VERIFIED
  * End to end — see above; there is no producer.
  * Whether a real Xbox pad on Apple actually reports the two trigger localities. The degrade needs
    no code, but the positive case is untested.
  * `pf-client-core` (the SDL renderer) does not build on macOS at baseline and is unbuilt here. It
    only reads `RumbleCommand` fields and never constructs one, so added fields cannot break it, but
    it still calls `_cmd`; wiring `SDL_RumbleGamepadTriggers` is separate work.

ANDROID: NOT DONE, and it should stay that way for now. `pack_rumble` packs pad/backstop/low/high
into bits 0..52 of a `jlong` with `-1` reserved as a sentinel — two more `u16` do not fit. The right
fix if ever wanted is the direct-`ByteBuffer` shape `nativeNextHidout` already uses in the same file
(zero-allocation, caller-owned, the established idiom), not a second `jlong` (racy across two calls)
nor `long[]` (an allocation per pull). But no Android device exposes trigger actuators at all, so
there is nothing to render. Separately stale and also not fixed: `NativeBridge.kt`'s KDoc still
documents the v2 `ttl_ms` layout rather than `backstop_ms`.
2026-08-09 23:30:02 +02:00
enricobuehler 7f1f7ba87c fix(pads/windows): say WHY a pad index is taken, and stop the devtest lying when it is
Debugging the on-glass session, a devtest run died with

    error=create gamepad bootstrap mailbox Global\pfds-boot-0: Zugriff verweigert (0x80070005)
    (install/repair: punktfunk-host.exe driver install --gamepad)

and then — this is the part that cost real time — kept printing "virtual Xbox One S Controller up",
streamed frames into nothing, and let the operator measure the INCUMBENT pad on that index. The
XInput packet count sat frozen and read as "the pad is dead", which was a wrong conclusion drawn
from a harness that had already failed and not said so.

WHAT IT ACTUALLY WAS. Pad lifetime is deliberately tied to the SESSION (native/input.rs: "the
gamepads are created and torn down with the session"), and a live session's pad legitimately owns
`Global\pfds-boot-0`. The mailbox's SDDL is `D:P(A;;GA;;;SY)(A;;GA;;;LS)` — SYSTEM and LocalService
only — and the host service runs as LocalSystem while a hand-run devtest runs as an elevated
Administrator, which is in neither ACE. `CreateFileMappingW` over an existing name is really an
OPEN, access-checked against the incumbent's DACL, so it returned ACCESS_DENIED and bailed at the
`?` BEFORE reaching the `ERROR_ALREADY_EXISTS` branch that already had the right sentence. That
branch only ever fires when both processes run as the same account.

The name is per-index on purpose and stays that way: `Global\pfds-boot-{index}` is the rendezvous
the driver polls, and its existence doubles as host-liveness. Making it per-process would let two
hosts build two devices on one wire index — the "the game sees two controllers" bug. The collision
is correct; only the diagnosis was wrong.

  * `gamepad_raii.rs` classifies the failure: on ACCESS_DENIED it probes with `OpenFileMappingW`,
    which separates what the OS collapsed — object-manager lookup precedes the access check, so
    absent gives FILE_NOT_FOUND and present-but-forbidden gives ACCESS_DENIED. It now says the
    mailbox belongs to a live session's pad and that nothing is wrong with the drivers.
  * `pad_slots.rs` carries that as a typed `PadCreateFault` through the anyhow chain, so `ensure`
    prints the fault's remedy instead of the per-backend reinstall hint, plus the pad index.
  * `devtest.rs` now BAILS when no pad was actually built, instead of announcing success. This is
    the fix that matters: every probe an operator runs next will still find a device on that index.
  * `native.rs` names what a detached input thread still holds, since that is one of the ways a pad
    can outlive its session.

DELIBERATELY NOT CHANGED, with reasons: the session-scoped pad lifetime (intentional and
documented); the mailbox naming (load-bearing, above); the retry/backoff (latching would resurrect
the `broken` flag `PadGate` exists to kill); the 10 s thread-detach in `serve_session` and the
service's `TerminateProcess` shutdown — both are real ways a devnode can outlive its owner, but
neither is evidenced in the field case and inventing a fix for an unobserved path is how you get a
regression instead of a bugfix.

`pf-inject/lib.rs` drops the `cfg(any(linux, windows))` gate on `pad_gate`/`pad_slots`. Neither
touches an OS pad API, and the gate meant a classification whose entire subject is a `cfg(windows)`
failure could not be tested on a dev machine at all.

VERIFIED
  * ON WINDOWS (.173): `cargo test -p pf-inject --lib` 109/109; `cargo build -p punktfunk-host`
    clean. Both agents' Windows code was compile-UNVERIFIED before this run.
  * macOS: 5 new tests, including one that pins the anyhow downcast through the exact three-layer
    context chain the Windows code builds — the assumption that could not otherwise be checked.
  * `cargo fmt --all --check` clean.

NOT VERIFIED
  * That a LocalSystem-owned mailbox really answers `OpenFileMappingW` with ACCESS_DENIED rather
    than FILE_NOT_FOUND from an Administrator token. That is reasoned from the object manager's
    lookup-then-access-check order, not measured. Repro on .173: hold a session pad on index 0, run
    the devtest from an elevated console, and check the new sentence appears.
2026-08-09 23:29:30 +02:00
enricobuehler d39843a858 Merge pull request 'The Game Mode takeover blamed polkit for a group it never named, and prescribed two remedies that cannot work' (#148) from worktree-dm-helper-preflight into main
apple / swift (push) Successful in 1m50s
ci / web (push) Successful in 1m35s
ci / rust-arm64 (push) Successful in 3m7s
ci / docs-site (push) Successful in 1m14s
ci / bun-nix (push) Successful in 30s
android / android (push) Successful in 6m53s
arch / build-publish (push) Successful in 8m35s
apple / screenshots (push) Successful in 6m15s
deb / build-publish (push) Successful in 3m43s
deb / build-publish-client-arm64 (push) Successful in 3m34s
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 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / builders-arm64cross (push) Successful in 10s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Failing after 43s
windows-host / package (push) Successful in 12m7s
windows-host / winget-source (push) Skipped
ci / rust (push) Successful in 11m26s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Failing after 41s
windows-host / canary-manifest (push) Successful in 25s
docker / deploy-docs (push) Skipped
deb / build-publish-host (push) Successful in 9m1s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 9m49s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 9m29s
Reviewed-on: #148
2026-08-09 21:17:36 +00:00
enricobuehler fb309e0262 fix(pf-vdisplay): the takeover blamed polkit for a group it never named, and offered two remedies that cannot work
ci / bun-nix (pull_request) Successful in 33s
ci / web (pull_request) Successful in 1m11s
apple / swift (pull_request) Successful in 1m38s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 2m9s
ci / rust-arm64 (pull_request) Successful in 3m25s
android / android (pull_request) Successful in 4m28s
ci / rust (pull_request) Successful in 19m29s
Field triage on Nobara, 2026-08-09. Every connect degraded to ATTACH — which on that box mirrors a
game-mode session the host never configured, and looked like a black screen on every connect. The
host said:

    the packaged pf-dm-helper polkit action is missing or was denied (reinstall the punktfunk
    package, or install the display-manager polkit rule from the docs)

Every clause of that was wrong. The action was installed, `allow_any`, and its exec.path annotation
matched the installed helper; pkexec authorized it and RAN the helper. The helper refused, and said
exactly why:

    pf-dm-helper: user 'nobara-user' is not in the 'punktfunk' group — refusing.
      Grant it with: sudo usermod -aG punktfunk nobara-user   (then re-login)

That text never reached the log, because `dm_helper` ran the helper with `.status()` — which
discards stderr and collapses the exit code to a bool. The one thing that would have ended the
investigation in seconds was thrown away at the call site, and the caller then guessed. Neither
suggested remedy adds anyone to a group, so a reader who followed both stayed broken and learned the
docs were useless. It fails soft, with no error and no failed unit, so nobody finds it on purpose.

Now: `.output()`, and four failure modes that stay distinguishable because they need different
fixes — helper not installed, pkexec could not run it, polkit denied it (pkexec's own 126/127), and
the helper ran and refused, whose stderr rides through VERBATIM rather than being re-described. Null
stdin too, so a pkexec that decides to prompt gets EOF instead of parking a stream thread on a tty
read.

The same gate gates the `linger` verb, so on a sessionless host an unjoined user fails there first —
carrying the reason there as well, or the misdiagnosis just moves one message earlier.

A new startup preflight says it before a stream is being built rather than during one, gated so it
cannot nag a box that would never attempt a takeover: not root, a display-manager alias exists, a
managed session launcher exists, a packaged helper exists, and the user is not in the group. It reads
membership from the user database rather than this process's groups, deliberately: that is what the
helper reads (it runs as root and resolves the caller from the database), so `usermod -aG` satisfies
the DM gate immediately and the warning stops. Using `getgroups()` would keep warning on a box where
the takeover already works.

Packaging said the group was for "the virtual Steam Deck pad (usbip)" — so anyone without a Deck pad
correctly skipped it and landed here by following instructions properly. All three scriptlets now
lead with Game Mode, name both grants, and record that creating the group is necessary and NOT
sufficient. Docs get the same treatment: the group is an admonition above the DM-flavor list in
gamescope.md, a black-screen entry in troubleshooting.md that tells the reader to read the quoted
reason FIRST, and the per-distro install pages no longer frame it as pad-only.
2026-08-09 23:15:34 +02:00
enricobuehler 94c2f62490 test(tools): a GameInput probe — and it cannot see our promoted Xbox pad
`win-input-matrix` covered four of the five rows and said so; GameInput was the gap, because it has
no binding in the `windows` crate and needs hand-written COM. This adds it: `--gameinput` reports
whether GameInput has a reading, and `--gi-rumble l,h,lt,rt [--gi-pid PID]` drives
`SetRumbleState`. Every vtable slot is taken from the SDK header, not guessed — a COM vtable is
positional, so a wrong slot calls a different method with the wrong signature.

WHY RUMBLE AND NOT JUST ENUMERATION. `XINPUT_VIBRATION` has two members, so classic XInput can never
exercise an Xbox pad's two IMPULSE-TRIGGER motors. `GameInputRumbleParams` has four
(`lowFrequency`, `highFrequency`, `leftTrigger`, `rightTrigger`), which makes GameInput the only API
that can settle `design/trigger-rumble-plane.md` §2.1's open question — the `enable`-mask bit
assignment for the two trigger actuators, where bits 2/3 (the handles) are measured and bits 0/1
(the triggers) are inferred from field order and nothing else.

TWO THINGS MEASURED ON .173, 2026-08-09:

1.  GameInput's device enumeration is ASYNCHRONOUS, and the first `GetCurrentReading` reliably
   returns nothing even with pads actively reporting. This is the GameInput analogue of `wake_wgi`:
   the API looks like a query and is really a cache someone else fills. A bounded poll fixes it.
   ⚠️ Focus is NOT the cause, and the header rules it out rather than my guessing:
   `GameInputDefaultFocusPolicy` is 0 and every `GameInputFocusPolicy` flag is a RESTRICTION, so the
   default already admits background input. Do not "fix" this with `SetFocusPolicy`.

2. 🛑 **GameInput never sees our pad.** Hunting by product id for six seconds with the pad live and
   sweeping, it enumerated `054C:0CE6` (DualSense) and `3434:D031` (8BitDo) — both plain HID pads —
   and never `045E:02FD`, ours, while classic XInput was reading ours live in the same moment.

⇒ THE TRIGGER ENABLE BITS REMAIN CONJECTURE, but for a better reason than before: it is not that
nobody has tried, it is that on this box NOTHING CAN DELIVER a four-motor rumble to our pad. XInput
structurally cannot; GameInput can but does not see it.

⚠️ The obvious suspicion is that `xinputhid` claiming the HID collection exclusively is what hides
the pad from GameInput — which would mean promotion costs us the API most Game-Pass-era titles use,
a trade we have shipped by default. **That is NOT established here.** The decisive control is cheap
and has not been run: power on the REAL Xbox Elite, which Microsoft's own driver promotes the same
way, and see whether GameInput enumerates it. If a real promoted Xbox pad is also absent, this is a
property of GameInput in a non-interactive session and not our defect — the same shape as the WGI
`ts=0` row, which a real Elite reproduced.

VERIFIED
  * `cargo fmt --check` clean; `cargo clippy --target x86_64-pc-windows-msvc --all-targets
    -- -D warnings` clean (cross-checked from macOS).
  * Builds and runs on .173; `GameInputCreate` succeeds, readings arrive after the poll, and
    `SetRumbleState` is accepted.
  * The runtime is loaded by name, so a box without GameInput reports "unavailable" rather than
    failing to link or crashing.

NOT VERIFIED
  * That `SetRumbleState` reaches ANY pad's motors — it was accepted for the DualSense but nothing
    observable was checked on that device, and it never reached ours.
  * `GameInputDeviceInfo` is read only for `vendorId`/`productId` (offsets 4 and 6). The rest of the
    struct has variable-size members whose layout would have to be mirrored exactly; nothing here
    needs them. `supportedRumbleMotors` is in there and would answer "does GameInput think this pad
    has trigger motors" — worth adding if this line of enquiry continues.
2026-08-09 22:55:17 +02:00
enricobuehler 2b1843ed1c fix(drivers/pf-gamepad): the right stick is Z/Rz — as declared, it was dead
Found on glass, first real streaming session: everything worked except the right stick, and Steam
correctly showed "Xbox One S Controller". `XBOX_RDESC` declared the right stick as `Rx`/`Ry`.
`xinputhid`, which translates our HID collection into XUSB, maps `Z`/`Rz` to the right stick and
does not treat `Rx`/`Ry` as one, so those two axes reached nothing.

Two usage bytes. Left and right were declared identically here — same collection, same globals,
same size and count — so the usages were the entire difference, which is what makes the diagnosis
airtight rather than plausible. Note `DUALSENSE_RDESC`, a real capture, also uses `Z`/`Rz` for its
right stick and puts the TRIGGERS on `Rx`/`Ry`; that is most likely where the original mistake came
from.

⚠️ Byte offsets are unchanged — still 16×2 at bit 5.0 — so `xbox_proto`'s layout tests and the
host-side packing are untouched. This is a pure relabelling.

🛑 THE REAL LESSON IS THE HARNESS, AND IT IS FIXED HERE TOO. This survived every bench measurement
because `dualsense-windows-test` drove LS-X and the A button and left the other five analogue axes
at zero. `XInputGetState` read `RX [0..0]`, which I read as "the devtest doesn't move it" — true,
and useless: a harness that exercises one axis cannot tell "this axis is not mapped" from "nothing
is driving it", and the two are indistinguishable in every consumer. The devtest now sweeps all six
axes on distinct phases and ramps both triggers, so one run shows which axes arrive AND that they
are not crosstalking onto each other's bytes.

MEASURED ON .173, same run shape before and after, devtest sweeping all six axes:
  before:  LX [-11264..24576]  LY [-32768..31744]  RX [0..0]        RY [-1..-1]       LT [0..248]  RT [7..255]
  after:   LX  [-8192..26624]  LY [-32768..31744]  RX [-32768..31744] RY [-24576..10240] LT [0..248]  RT [7..255]

VERIFIED
  * `cargo test -p pf-inject --lib` 104/104 on Windows; `xbox` subset 11/11 on macOS — the layout
    tests still pass because nothing moved.
  * Driver rebuilds and signs; the descriptor is still 223 bytes so the `wReportLength` const assert
    is undisturbed.
  * `cargo fmt --all --check` clean.

NOT VERIFIED
  * Not yet re-tested in a real streaming session — that is the next on-glass run.
  * ⚠️ A leftover finding from the same session, unrelated to this fix and NOT investigated: the
    session's pad devnode SURVIVES client disconnect and keeps the `Global\pfds-boot-0` bootstrap
    mailbox, so a devtest run afterwards fails with `Zugriff verweigert (0x80070005)` and silently
    measures the stale pad instead. Restarting the service releases it. Worth its own look.
2026-08-09 22:19:25 +02:00
enricobuehler 4f9071b980 feat(pads/windows): three Xbox identities — Wireless, One S and Elite Series 2
Until now there was one Xbox identity, `device_type = 4` / `045E:0B13`, and Windows folded a
client's `XboxOne` request onto it because the only Windows Xbox backend was the XUSB companion,
which presents one fixed 360 identity and cannot vary it. The HID backend can, so the fold goes and
two identities join it:

  devtype 4  045E:0B13  pf_xboxwireless  Xbox Wireless Controller
  devtype 5  045E:02FD  pf_xboxones      Xbox Wireless Controller (One S)
  devtype 6  045E:0B22  pf_xboxelite     Xbox Elite Wireless Controller Series 2

`GamepadPref::XboxElite` takes wire byte 11 — the first unassigned one, and the round-trip test
previously asserted `from_u8(11) == Auto` with a comment saying assigning it must update that; the
sentinel moved to 12. The C ABI mirror and the generated header moved with it.

 ALL THREE SHARE ONE REPORT DESCRIPTOR, deliberately. In HID terms they are the same pad; the
descriptor is the report shape, not the identity. §3 of the handoff records that our single
hand-written descriptor already cost three separate bugs, and inventing two more would multiply
that debt for no measured gain. They differ in VID/PID, product string, hardware id and Device
Manager description only.

⚠️ All three install `pfGamepadXbox`, the section that attaches the `xinputhid` bus filter. That
was the open risk: Microsoft's `xinputhid.inf` promotes by an explicit hardware-id allow-list
containing `02D1, 02DD, 02E3, 02EA, 0B00, 0B0A, 0B13, 02FF` — and NEITHER `02FD` NOR `0B22` is on
it. Measured on .173: promotion does not care, because it comes from our own AddReg rather than
from matching Microsoft's ids. All three gain `IG_00`, register an XUSB interface, and are read
live by classic XInput. Had this gone the other way the two new identities would have been strictly
worse than the one they joined.

The XUSB escape hatch needed a runtime degrade to stay honest. `pick_gamepad` is compile-time only,
so with `PUNKTFUNK_XBOX_BACKEND=xusb` the host would have resolved and echoed `xboxelite` in its
`Welcome` while actually building a 360 pad. `degrade_xbox_identity` folds the identity back at
runtime, mirroring `degrade_if_no_uhid`.

VERIFIED ON WINDOWS (.173 — none of this compiles on macOS; the driver needs the WDK and the rest
is `cfg(windows)`):
  * `cargo test -p pf-inject --lib` 104/104 — including `hwid_matches_inf`,
    `hwid_devtype_table_matches_the_driver` and `only_the_xbox_identity_installs_the_xinputhid_section`,
    all now sweeping the whole identity set and asserting the section split in both directions.
  * `cargo test -p punktfunk-core --lib gamepad` 7/7; `cargo check -p punktfunk-host` clean.
  * Driver builds and signs; the descriptor/`wReportLength` const asserts still hold with the
    descriptor shared three ways.
  * ON GLASS, per identity, via the new `--xboxones` / `--xboxelite` devtest legs: each gets its own
    devnode (`PF_XBOX_0` / `PF_XBOX_ONES_0` / `PF_XBOX_ELITE_0`), each HID child gains `IG_00`, each
    registers an XUSB interface, and XInput reads each live (packets advancing, `buttons=0x1000`).
  * macOS: `cargo fmt --all --check` clean in both workspaces.

NOT VERIFIED / NOT DONE
  * **Elite paddles are NOT implemented.** `BTN_PADDLE1..4` would need descriptor buttons, and once
    `xinputhid` promotes the pad it claims the HID collection exclusively — XInput has no paddle
    fields and the HID consumers that do may be locked out, so the buttons would likely reach
    nobody. The decisive measurement is cheap and named in the code: hold a paddle bit set and see
    whether a user-mode HID reader still gets reports. Until then the Edge remains the only virtual
    pad with native back-button slots and nothing should be advertised otherwise.
  * **No client picker offers the Elite**, and none can auto-detect it — SDL3's `GamepadType` has no
    Elite variant. It is reachable today only via `PUNKTFUNK_GAMEPAD=xboxelite` or a hand-edited
    client setting. All five clients ship the same curated six options by deliberate parity, so
    adding one is a cross-client UX change, not part of this.
  * Nothing here has run in a real streaming session; every measurement came from the devtest.
2026-08-09 21:41:53 +02:00
enricobuehler 1317901122 Merge pull request 'Uninstalling the Windows host left every audio device it minted behind forever — and the installer script documented that as a decision' (#145) from worktree-win-audio-uninstall-cleanup into main
android / android (push) Failing after 1m32s
ci / rust-arm64 (push) Successful in 1m53s
apple / swift (push) Successful in 1m34s
ci / bun-nix (push) Successful in 22s
ci / web (push) Successful in 1m48s
ci / docs-site (push) Successful in 1m44s
deb / build-publish-client-arm64 (push) Successful in 1m44s
deb / build-publish (push) Successful in 4m5s
ci / rust (push) Successful in 7m24s
apple / screenshots (push) Successful in 5m54s
arch / build-publish (push) Successful in 9m40s
deb / build-publish-host (push) Successful in 7m28s
windows-host / package (push) Successful in 13m52s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 20s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m53s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / builders-arm64cross (push) Successful in 7s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m4s
docker / deploy-docs (push) Failing after 6m14s
Reviewed-on: #145
2026-08-09 19:30:31 +00:00
enricobuehler 620f017d9a fix(validation): tell the CAPTURE it is a PyroWave session, or NVIDIA hands it a CUDA buffer
`--codec pyrowave` selects the ENCODER. The capture pipeline picks its consumer from
`ZeroCopyPolicy::pyrowave_session`, which on the spike path is fed only by the global
`PUNKTFUNK_ENCODER=pyrowave` lab lever (punktfunk-host/src/capture.rs). Without it, .21 resolved

    capture pipeline resolved: cuda-import -> nvenc   capture_arm="cuda-import" consumer="nvenc"
    zero-copy: dmabuf imported to CUDA (no CPU copy)  nv12=true

and the wavelet encoder refused the payload on its first submit: "unsupported FramePayload (need
Dmabuf or Cpu RGB)". That is not a worker bug — the arm that failed was the pure in-process one.

It reproduces only where the A/B actually lives. An AMD box has no CUDA arm to pick, so .25 resolved
straight to dmabuf-passthrough and the kit looked correct there. With the lever set, .21 resolves
`dmabuf-passthrough -> pyrowave` and both arms encode 2700/2700 frames.

V3b then passes on .21 (RTX 5070 Ti, GRID 2 at ~100% GPU, 5120x1440 — the portal captures the real
monitor, --width/--height being synthetic-only):

    in-process, refused       p50 2.85 ms   p99 8.39 ms   (10 windows)
    capped worker, granted    p50 2.65 ms   p99 4.10 ms   (11 windows)
    p99 delta -4.29 ms

The worker reports `priority=Granted(Realtime)` with `ext=VK_KHR_global_priority` on the FIRST
attempt and logs no fallback line; the refused arm logs "every global queue priority class was
refused". So the capability still buys the lever from a SEPARATE process, with the IPC hop in the
loop — 8.39 -> 4.10 ms is a 51% p99 cut, against PW1's in-host 6.4 -> 4.4 at 1080p. Different
resolution and a harder load, so treat the class as confirmed and the absolute numbers as not
comparable to PW1's.
2026-08-09 21:23:57 +02:00
enricobuehler 854b14a52e fix(validation): a leg that never ran must say so, not blame the arm
The V3b run on .21 died with `open portal capturer: timed out waiting for the ScreenCast portal` —
a GNOME consent dialog nobody answered — and the kit reported "arm A is not the in-process arm".
That is false: the arm was constructed correctly (`PUNKTFUNK_ENCODE_WORKER=off` is right there in
the captured env header), it simply never reached encoder-open, so the line the assert looks for
could not exist. A red that points at the wrong thing costs the same debugging time as a green that
hides a real one.

`spike_failure_reason` now runs BEFORE any arm-identity assert in v2, v3a and v3b, and names the
actual cause: the portal timeout gets its own message saying the dialog appears on the HOST's own
screen and cannot be answered from inside a stream — which is precisely the situation that produced
this failure, since the operator was watching the box through a game session at the time.

Falls back to the first ERROR line, then to "no PUNKTFUNK_PERF window at all", so a spike that dies
some other way still reports that rather than a misattribution.
2026-08-09 21:15:46 +02:00
enricobuehler bd5735b803 feat(pads/windows): make the HID Xbox pad the default, and carry the trigger motors on the wire
Three changes that only make sense together: the HID backend becomes the default now that it is a
superset of the XUSB one, the rumble datagram grows the two Xbox impulse-trigger motors, and the
INF-shape tests learn about the Xbox identity's own install section.

WP-E — `PUNKTFUNK_XBOX_BACKEND` now defaults to `hid`; `=xusb` is the escape hatch.
The knob existed for exactly one reason, recorded in its own doc comment: the HID pad could not
reach classic XInput, so defaulting to it would trade a known-working path for an unproven one.
That objection is gone — with the `xinputhid` bus filter the INF now attaches, the HID pad is
promoted like real hardware and keeps classic XInput while gaining everything XUSB never had
(Steam, SDL, RawInput, DirectInput, joy.cpl, WGI) plus rumble, which XUSB could not source at all.
The escape hatch stays because promotion leans on Microsoft's inbox `xinputhid.inf`; if a servicing
update changes it, one env var restores the old behaviour with no reinstall. An unrecognised value
takes the DEFAULT rather than the opt-out, so a typo cannot silently drop a user onto the path with
no HID collection.

WP-D — the `0xCA` rumble datagram gains a v3 form:
  v1  7 B: [0xCA][u16 pad][u16 low][u16 high]
  v2 10 B: … [u8 seq][u16 ttl_ms]
  v3 14 B: … [u16 lt][u16 rt]
v3 is built FROM v2's bytes rather than restating the layout, so the prefix relationship is
structural instead of a convention two encoders have to keep agreeing on, and every reader gates
with `>=`. The four levels share one seq and one ttl on purpose: they are one statement of the
pad's feedback at one instant, and sharing means the whole v2 apparatus — renewal cadence, stop
burst, the client's seq gate, the lease clamp — governs the triggers with no new code. The new
`RumbleUpdate` fields are plain `u16`, not `Option`: on a level-triggered plane "absent" must mean
zero, because "absent → keep the previous value" is the stuck-rumble bug in a new costume.
Only one backend can ever source them — the Windows HID Xbox pad, whose output report 0x03 carries
them. `XINPUT_VIBRATION` and evdev `FF_RUMBLE` have two members and no third, so every other
producer sends `lt = rt = 0`.

⚠️ The two TRIGGER `enable`-mask bits remain CONJECTURE. Bits 2/3 = left/right handle are measured;
bit 0/1 = the triggers are inferred from field order and nothing else. `parse_xbox_output` says so
inline, and no test asserts them — every test vector uses masks (0xFF, 0x00, 0x0C, 0xF3) whose
expectations hold whichever bits turn out to be right. XInput cannot settle this: it has two
motors.

The INF tests — `hwid_matches_inf` matched the install section by the exact string `=pfGamepad,`
and so stopped seeing the Xbox hardware ids the moment that identity moved to its own
`pfGamepadXbox` section. It failed loudly, which is the good outcome; it is now prefix-matched and
tolerant of further per-identity sections. Added
`only_the_xbox_identity_installs_the_xinputhid_section`, which asserts the split in BOTH
directions: the Xbox line must not install the shared section, and no other line may install the
Xbox one. Merging them back is a one-line edit that looks like tidying and would hand a DualSense
to Microsoft's Xbox translator.

VERIFIED
  * ON WINDOWS (.173, the only place this code compiles): `cargo test -p pf-inject --lib` 104/104,
    including the new trigger tests and both INF tests; `cargo check -p punktfunk-host` clean.
  * macOS: `cargo fmt --all --check` clean; `cargo test -p punktfunk-core --features quic` rumble
    suite 22/22, including v3 round-trip and v3<->v2 cross-version parsing.
  * The pre-existing `c_abi_harness_round_trips` failure on macOS is `ld: library 'opus' not found`
    and reproduces with these changes stashed.

NOT VERIFIED
  * No trigger rumble has ever been observed end to end — nothing can drive it yet (see the
    conjecture note above), and no client renders it.
  * The default flip has NOT been exercised in a real streaming session; every measurement so far
    came from the devtest harness. That is the on-glass run.
  * Non-Rust clients do not decode v3. They are blocked on a C ABI entry point first
    (`punktfunk_connection_next_rumble_cmd` has fixed out-params, ABI_VERSION 17); Apple could
    render it via GCHapticsLocality.leftTrigger/.rightTrigger, Android structurally cannot (its
    packed jlong is full) and has no trigger actuators anyway.
2026-08-09 21:10:08 +02:00
enricobuehler 7a9fa4501c Merge pull request 'Nobara could never use the patched gamescope — and the RPM it was told to install was unsigned' (#144) from worktree-gamescope-pin-bump-nobara into main
apple / swift (push) Successful in 1m35s
ci / rust-arm64 (push) Successful in 1m59s
android / android (push) Failing after 2m37s
ci / bun-nix (push) Successful in 17s
ci / docs-site (push) Successful in 1m19s
ci / web (push) Successful in 2m27s
deb / build-publish-client-arm64 (push) Successful in 1m54s
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 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
deb / build-publish (push) Successful in 3m37s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
apple / screenshots (push) Successful in 5m43s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Failing after 41s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m2s
docker / deploy-docs (push) Skipped
arch / build-publish (push) Successful in 9m55s
deb / build-publish-host (push) Successful in 6m53s
docker / builders-arm64cross (push) Successful in 12s
ci / rust (push) Successful in 11m23s
flatpak / build-publish (push) Successful in 9m37s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 8m57s
windows-host / package (push) Successful in 18m53s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 16s
nix / flake (push) Failing after 17m1s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 24m26s
Reviewed-on: #144
2026-08-09 18:53:59 +00:00
enricobuehler d87a8df28d fix(windows): uninstall removes the audio devices the host mints
ci / bun-nix (pull_request) Successful in 25s
ci / web (pull_request) Successful in 1m21s
ci / docs-site (pull_request) Successful in 1m23s
apple / swift (pull_request) Successful in 1m42s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m37s
android / android (pull_request) Successful in 6m12s
ci / rust (pull_request) Successful in 7m37s
The field report: uninstalling punktfunk left "Punktfunk Speakers",
"Punktfunk Microphone" and the per-pad "Wireless Controller" endpoints
sitting in Windows' Sound settings forever.

They have no installer payload behind them, which is why nothing in the
uninstall touched them. The host mints them at RUNTIME as extra devnodes
on Valve's streaming-audio drivers, and both providers deliberately
re-resolve their devnode across restarts instead of re-minting it — so
they persist by design. Persistent across restarts must not mean
permanent: the .iss even documented leaving them behind as a decision.

New `driver uninstall --audio` leg (a third Inno [UninstallRun] entry,
after the two driver legs and well after `service uninstall`, since a
live host re-mints on its next wiring pass):

* restores the default playback device first, if a host that died
  mid-stream left it parked on our loopback sink — otherwise Windows
  re-picks by its own ranking rather than giving the operator back the
  device they had;
* removes every MEDIA-class devnode carrying one of our three durable
  owner markers (pad slot, minted role, probe), phantoms included;
* deletes each endpoint's MMDevices record, resolved through the
  devnode link BEFORE the devnode goes.

Marker-matched, never name-matched: our instances are name-identical to
Steam's own, and Steam's devnodes, its drivers, and a VB-CABLE from the
era when we bundled one carry no marker and stay untouched. A ROOT\
enumeration guard means a marker-shaped value on a real sound card can
never cost the user their hardware.

The registry half is best-effort: those keys are SYSTEM-owned and the
uninstaller runs elevated but as a user, so on a stock box the record
survives as an inert NOTPRESENT entry that Sound settings only shows
behind "Show Disconnected Devices". The device itself is gone either
way, and seizing ownership of SYSTEM registry keys from an uninstaller
is a worse thing to ship than that scrap.
2026-08-09 20:53:22 +02:00
enricobuehler 77f0a25d18 feat(drivers/pf-gamepad): ship the xinputhid bus filter, so Windows finally promotes our Xbox pad
The field report that started this work was an Xbox controller that no game could see on a Windows
host for two weeks. Root cause was that our Xbox pad reaches no Windows input API a modern title
uses. This is the fix, and it is two registry values.

Windows promotes Xbox pads with `xinputhid`, whose INF is an explicit hardware-id ALLOW-LIST — its
own comment says "we can not use a Compatability ID for the loading of this driver, and so rely on
individual hardware IDs". A software-enumerated devnode can never match those ids, so we write what
the matching install sections would have written. `045E:0B13`, the PID this identity already
claimed, is on that allow-list twice, so the identity choice turned out to be exactly right.

🛑 THE PAIRING IS THE WHOLE FINDING, AND THE TWO VALUES GO IN DIFFERENT KEYS. `UpperFilters` is a
`.HW` AddReg (hardware key); `DevicePropertyFlags` is a DDInstall AddReg (software key). A live A/B
on .173: removing `DevicePropertyFlags` alone reverts EVERYTHING — no `IG_00`, no XUSB interface, no
XInput, no WGI entry — while `UpperFilters` alone is completely inert. `1` = `BusDevice`, which
Microsoft glosses as "a focused bus filter driver for the IG_ problem". It is not a description of
the device, it is the switch. An earlier session installed the filter WITHOUT it, measured a device
that produced nothing, and recorded "never ship it". The filter was never broken; it had never been
switched on. That conclusion is now retracted.

⚠️ The Xbox line gets its OWN DDInstall section, `pfGamepadXbox`. All five identities previously
shared `pfGamepad`, so an AddReg there would have handed a DualSense, DualShock 4, Edge and Steam
Deck to Microsoft's Xbox translator. The regression check below exists for exactly that.

MEASURED ON .173 (Win11 26200), INF-SHIPPED — no hand-written registry values:
  * `UpperFilters=xinputhid` lands on the hardware key and `DevicePropertyFlags=1` on the software
    key, applied by the INF at install.
  * The HID child gains the `IG_00` token: `HID\PUNKTFUNK&IG_00\...`.
  * An XUSB interface appears: `\\?\hid#punktfunk&ig_00#...#{ec87f1e3-...}`.
  * classic XInput reads it live — packets ADVANCING, `buttons=0x1000` (the devtest's A), and the
    stick sweeping. XInput had NEVER seen this backend before.
  * `XInputSetState` rumble round-trips: `rumble from game: pad=0 low=65535 high=32767`.
  * REGRESSION CHECK PASSED: with the DualSense identity up, its devnode has an EMPTY
    `UpperFilters` and no `DevicePropertyFlags`. The PlayStation pads are untouched.

WGI `Gamepad` lists the pad but reads `ts=0`. That is NOT ours: a real Xbox Elite Series 2, promoted
by Microsoft's own driver on the same box, reads `ts=0` in WGI at the very moment classic XInput is
reading live data from it (`buttons=0x1000 LY=-32768`). Our pad is behaviourally indistinguishable
from real hardware here; the row is a property of the non-interactive session.

NOT VERIFIED
  * On-glass in a console session. Everything above ran over ssh, which is what makes the WGI row
    unreadable; the real-Elite control is what settles it, not a clean WGI reading.
  * GameInput — no binding in the `windows` crate, still unmeasured for this backend.
  * `PUNKTFUNK_XBOX_BACKEND` still defaults to XUSB. This changes what the HID backend CAN do; it
    does not change which backend is chosen. That is WP-E and it is a separate decision.
  * Trigger-actuator enable bits, still conjecture — `XINPUT_VIBRATION` has two members and cannot
    exercise them.
2026-08-09 20:18:49 +02:00
enricobuehler f9fe496dbc feat(drivers/pf-gamepad): declare the rumble output report, and the Xbox pad gets rumble at all
`XBOX_RDESC` declared no OUTPUT item — zero `0x91` bytes. hidclass routes an output report only if
the descriptor declares one, so `on_output_report` never fired, `publish_output` never wrote the
out-ring, and `parse_xbox_output` in `inject/windows/xbox_windows.rs` was unreachable code. The
entire host-side rumble plane was already built, wired and tested, and was simply never fed. The
HID Xbox pad therefore had NO rumble whatsoever, not merely no trigger rumble.

This appends the PID-page `Set Effect Report` collection, report id `0x03`, 8 payload bytes, sized
to exactly the layout `parse_xbox_output` and `design/trigger-rumble-plane.md` §2.1 already
specify. It is declared AFTER the final Input item and re-states every global it uses, so the
16-byte input layout `xbox_proto`'s tests pin is untouched.

⚠️ PROVENANCE: hand-written, and it could not be otherwise. The Elite capture taken for WP-A
reports `OUTPUT items: 0` — Windows exposes no literal descriptor bytes and the reconstruction
carries no output collection for that pad — so there was nothing to copy. The comment says so and
asks for a Linux hidraw capture to replace it.

Also adds a compile-time assert pairing every descriptor with its HID-descriptor `wReportLength`.
Those are two copies of one length, edited in different places, and a mismatch fails SILENTLY:
hidclass asks for `wReportLength` bytes, parses whatever it got, and the pad either enumerates
truncated or not at all with nothing naming the cause. It now cannot build out of step. This
caught nothing today because I updated both by hand, but it is exactly the trap this descriptor
has already sprung twice in other forms.

MEASURED ON .173 (Win11 26200), with the pad promoted via the WP-B0 xinputhid bus-filter config:
  * `XInputSetState(0xFFFF, 0x8000)` produced, on the host side,
      `rumble from game: pad=0 low=65535 high=32767`
      `rumble from game: pad=0 low=0 high=0`
    i.e. XInputSetState -> xinputhid -> HID output report 0x03 -> on_output_report -> out-ring ->
    parse_xbox_output -> PadFeedback. First rumble this backend has ever delivered.
  * The round-trip values confirm the descriptor's `Logical Maximum (100)` percent domain is
    right: 0x8000 -> 50% -> 32767. A 0..255 domain would have produced different numbers.
  * This also answers `trigger-rumble-plane.md`'s WP0 gate — YES, Windows writes output reports
    to a synthesized 045E:0B13 — which was blocking the whole trigger plane.
  * classic XInput reads the pad fully: packets advancing, `buttons=0x1000` (the devtest's A), and
    `LX [-32768..31744]`, the complete sweep. LY/RX/RY frozen is correct; the devtest drives only
    LS-X and A.

VERIFIED
  * `cargo test -p pf-inject --lib xbox` 11/11 — the input layout is byte-identical, as intended.
  * `hid-descriptor-dump --rust-source ... --symbol XBOX_RDESC` decodes it clean: input report
    0x01 unchanged at 16 bytes and the same offsets, new output report 0x03 at 9 bytes on the
    wire, feature 0x85 unchanged, `structure: OK`.
  * Driver builds and signs on .173 with the WDK; the new const asserts compile, so all five
    descriptor/wReportLength pairs agree.
  * fmt clean on both tools; .173 fully reverted afterwards.

NOT VERIFIED
  * The enable-mask bit assignments for the two TRIGGER actuators. `XINPUT_VIBRATION` has only two
    members, so XInput can never drive them and this run could not exercise them. Still open, as
    trigger-rumble-plane.md WP0 says.
  * That this equals the real pad's output collection, byte for byte. Needs Linux hidraw.
  * Nothing about the INF is changed: `pf_gamepad.inx` still has no AddReg, so none of the
    promotion config ships. The rumble descriptor is inert until something drives it.
2026-08-09 20:07:34 +02:00
enricobuehler 13438b1287 test(tools): ask Windows which input APIs can see the pad, and find what promotes it
The Xbox-pad-on-Windows programme is a five-row matrix — classic XInput, WGI `Gamepad`, WGI
`RawGameController`, GameInput, and the HID/DirectInput/Steam family — and nothing in this tree
measured any of it. Every reading in the handoff came from ad-hoc off-tree tools, which is why
several could not be reproduced later and why one was a false positive. `win-input-matrix` makes
the matrix a command you can run twice and diff.

Two traps are baked into it because both have already cost this programme a wrong conclusion.
`--watch` samples repeatedly and reports LIVE vs MUTE per device, because an API listing a pad that
never reports is the exact failure mode here — worse than not listing it, since a title that binds
the first gamepad latches a dead one. And the doc comment insists on a baseline with the virtual pad
STOPPED: a real Xbox pad owns XInput slot 0, which is how `rc=0 LX=-885` was once read as success
with our pad already killed.

 `wake_wgi()` is not optional and is commented as such. `Gamepad::Gamepads()` and
`RawGameController::RawGameControllers()` return a cache filled by WGI's device-watcher, which a
GUI app has already started and a console app has not. Without subscribing to the Added events
first, BOTH collections come back empty with real controllers attached — measured here: a DualSense
sitting in the HID interface class, `RawGameControllers` count=0. A probe missing this reports "WGI
cannot see the pad" when WGI could not see anything.

WHAT IT FOUND (full record in measurements/2026-08-09-xbox-hid-xinputhid-busfilter.md): with
`UpperFilters=xinputhid` on the pad's PARENT devnode AND `DevicePropertyFlags=1` in that parent's
SOFTWARE key, the HID Xbox pad is promoted for the first time — the child gains the `IG_00` token,
an XUSB interface appears, classic XInput admits it, and WGI `Gamepad` lists it. All four had never
happened on this backend. A one-value A/B proves `DevicePropertyFlags` is the decisive half:
removing it alone reverts all four. That retro-explains the earlier "the filter installs fine and
produces nothing" result — the filter was loading without ever being put in bus-filter mode, which
is what `BusDevice = 0x1` means in Microsoft's own comment in `xinputhid.inf`.

Not a workspace member, for the same reason as `hid-descriptor-dump`: it is a Windows-only
bring-your-own-hardware tool with no business on a CI leg.

VERIFIED
  * `cargo fmt --check` clean; `cargo clippy --target x86_64-pc-windows-msvc --all-targets
    -- -D warnings` clean (cross-checked from macOS; the target is installed).
  * Builds and runs on .173 (Win11 26200).
  * Self-checked against known-good hardware before any conclusion was drawn from it: baseline
    reads the USB DualSense as LIVE in both WGI collections and the resting 8BitDo as MUTE.
  * The A/B was run in both directions on the same box in one session.
  * `cargo metadata` on the root workspace resolves and does NOT list this crate.
  * .173 fully reverted: registry values removed, devnodes removed, oem100.inf deleted, both certs
    delstored, 6 pre-existing pf_gamepad packages and the production service untouched.

NOT VERIFIED
  * GameInput — no binding in the `windows` crate, needs hand-written COM vtables. Not covered;
    the doc comment says so.
  * That the promotion survives a reboot or a devnode re-create from a shipped INF `AddReg` rather
    than a hand-written registry value. Nothing is shipped: `pf_gamepad.inx` is UNCHANGED and still
    contains no AddReg of any kind.
  * WHY the promoted pad still translates no data. Enumeration is fixed; translation is not. The
    evidence points at the report descriptor, which is gated on the §3.3 decision.
2026-08-09 19:24:28 +02:00
enricobuehler ae35e8b4d7 test(tools): capture the real Xbox descriptor, because ours was invented and disagrees with it
`XBOX_RDESC` is the only report descriptor in `pf-gamepad` that was hand-written rather than
captured off hardware, and its own provenance warning has now come true three times. The fix for
that class of bug is not another careful reading — it is a tool that goes and asks the device.

`tools/hid-descriptor-dump` does that: it dumps a real HID device's report descriptor, decodes it
into an annotated item listing plus a bit-offset LAYOUT TABLE, and can decode a blob we already
ship through the same decoder (`--rust-source <file> --symbol <NAME>`) so the two are diffable
line for line. `--read N` pulls live wire bytes, which is the only ground truth a reconstructed
descriptor cannot give you.

Deliberately NOT a workspace member — it pulls `hidapi`, a C library wanting libudev on Linux,
which has no business in `cargo build --workspace` or on a CI leg with no pad attached. It is a
bring-your-own-hardware tool and it is excluded in the root manifest, so CI never sees it.

The captured Elite disagrees with our blob in four ways, and the dangerous one is field ORDER:
the real pad reports sticks, ONE combined 16-bit Z trigger, then BUTTONS, then the hat, in an
UNNUMBERED 15-byte report; ours declares Report ID 1, two Simulation-page trigger axes, then the
hat, then 15 buttons. Since we claim a genuine Microsoft VID/PID and SDL/Steam/Windows all apply
stock mappings keyed on it, that ordering difference is exactly how every control silently lands
on the wrong action. The driver comment now records the diff and the two blockers that stop the
capture from simply being pasted in.

VERIFIED
  * `cargo fmt --check` clean, `cargo clippy --all-targets -- -D warnings` clean (macOS).
  * The tool builds and runs on macOS and on .173 (Windows 11 26200, cargo 1.96, MSVC, no WDK).
  * TOOL VALIDATED AGAINST A KNOWN-GOOD CONTROL: pointed at the live DualSense on .173, it
    reproduces the real `DUALSENSE_RDESC` layout exactly (input 0x01, 64 B, X,Y,Z,Rz,Rx,Ry at
    bytes 1..6, hat 8.0, 15 buttons 8.4, output 0x02, the full feature ladder), and `--read`
    returned live len=64 reports with sticks centred at 80 80 80 80 and the counter incrementing.
  * `cargo metadata` on the root workspace still resolves and does NOT list this crate.
  * The Elite capture is reproducible: `--vid 045E --pid 0B22`.

NOT VERIFIED
  * That the capture equals the pad's NATIVE report map. Windows exposes no API for a device's
    literal descriptor bytes, so hidapi reconstructs from `HidD_GetPreparsedData` — faithful in
    structure, item order and bit offsets, not byte-exact (measured: the DualSense's real 273-byte
    descriptor reconstructs to 467). `xinputhid` also filters that pad, and the captured shape is
    the legacy DirectInput view. A byte-exact answer needs Linux hidraw.
  * Why the Elite returned ZERO input reports across two runs (72 s and 90 s) while the DualSense
    streamed fine on the same code path — untouched pad, or exclusive claim by the XInput
    translator. Unresolved.
  * Nothing here was built on Windows as a driver: `XBOX_RDESC` itself is UNCHANGED, so no
    behaviour changes. The only edit to the driver is its provenance comment.
2026-08-09 19:02:13 +02:00
enricobuehler f34acf1d73 fix(drivers/pf-gamepad): the Xbox descriptor never declared the channel-proof report, so the pad served neutral forever
`XBOX_RDESC` declared only Input report 1. The sealed pad channel delivers its DATA section
over a vendor Feature report `0x85` (`ProofTransport::HidFeatureReport`), and the proof
handler's own comment records the assumption that made this invisible — "0x85 is already
declared as a Feature report in all three captured descriptors". True of the captured
PlayStation blobs; false of this hand-constructed one.

So hidclass rejected the host's `HidD_GetFeature` before the driver ever saw it, the host
refused to hand over the section, and the pad answered every read with its neutral report.
The HID Xbox pad had never delivered a single input report since it was written.

Declaring `0x85` with a 63-byte payload (1 id + 63 = 64 = FeatureReportByteLength) fixes it.
Verified on glass on .173: `gamepad driver attached to the shared section proto=3 late=false`,
and WGI's RawGameController path then reads the pad live — advancing timestamps, the devtest's
left-stick sweep, buttons toggling. Before the fix: 12 consecutive samples, one frozen
timestamp, every axis at dead centre.

This is the descriptor-provenance warning in this file coming true. It is still CONSTRUCTED
rather than captured, and that remains the open risk — `xinputhid` appears to validate the
descriptor and refuses ours, and a real Elite is a multi-collection device where ours has one.

Codec layout tests still 11/11; fmt clean. Only device_type 4 is affected, which nothing
shipping uses yet.
2026-08-09 18:16:24 +02:00
enricobuehler d2a2bcc25d feat(drivers/pf-xusb): answer the async input wait, and put xinputhid on the stack
The two things this driver's README has always listed as the missing WGI/GameInput work,
both user-mode, neither needing a bus driver:

`IOCTL_XUSB_WAIT_FOR_INPUT` is now pended on a manual queue and completed by the periodic
timer on a dwPacketNumber edge, answering with the same 29-byte GET_STATE payload the
synchronous path serves. Declining it was enough for classic xinput1_4, which just falls
back to sync GET_STATE polling — that is why the pad has always worked there. It is not
enough for WGI/GameInput, which poll asynchronously: to them a decline is a refusal, not
a fallback. Completion is edge-gated because releasing a waiter on an unchanged packet
spins its caller at timer rate. WAIT_GUIDE_BUTTON stays declined — we have no state to
signal on.

The INF adds UpperFilters=xinputhid on the XUSB devnode. Note the earlier attempt put
that filter on the HID child of the *other* backend, which was simply the wrong devnode:
XInput does not read HID at all, it enumerates GUID_DEVINTERFACE_XUSB, which is what this
driver registers.

Verified on .173: build + sign + catalog exit 0; infverif "INF is VALID"; the devnode
starts Status OK with UpperFilters=xinputhid readable back from its enum key; and XInput
still sees the pad (slot 1 live alongside the box's real Elite in slot 0), so the async
queue is no regression to the path that already worked.

NOT yet measured: whether WGI/GameInput now admit the pad. `IG_` is the wrong probe for
this driver — it is a HID-path artifact and pf-xusb is System-class with no HID child, so
its absence says nothing either way. That needs a real WinRT/GameInput enumeration test.
2026-08-09 17:29:10 +02:00
enricobuehler 4e04c2bbf8 feat(host/pads): route the Xbox pad to the HID backend behind PUNKTFUNK_XBOX_BACKEND=hid
Wires `xbox_windows` into the per-pad router so an Xbox-family pad can be built as a real
HID device instead of the XUSB companion, and adds the knob that selects between them.

Opt-in rather than the new default, deliberately. XUSB is what classic-XInput games read
today; the HID pad buys the Steam / WGI / GameInput / DirectInput visibility XUSB can
never have, but whether Windows promotes it into an Xbox-profile device that XInput and
WGI Gamepad accept is still the open question. Flipping the default before that is
settled would trade a known-working path for an unproven one. The two backends are
mutually exclusive per pad by construction — one match arm or the other — because
presenting both hands a game two controllers for one pair of hands.

Verified on .173: cargo check -p punktfunk-host exit 0, clippy -D warnings clean,
`cargo test -p punktfunk-host gamepad` 8/8 green, fmt clean.
2026-08-09 17:03:25 +02:00
enricobuehler d498ff4a60 test(drivers): give the Xbox identity a root-enumerated id, and verify the whole thing on Windows
`root\pf_xboxwireless` alongside the plain id, mirroring the DualSense model line — the
INF already documents that variant as the one devgen/devcon tests bind, and without it
the Xbox identity could only be exercised through a running host.

Verified end to end on .173 (Windows 11 26200, WDK 10.0.26100.0):
- build-gamepad-drivers.ps1 builds + signs + catalogs the driver, exit 0
- infverif /v /w on the generated pf_gamepad.inf: "INF is VALID"
- pnputil stages the package; devgen creates the devnode; it starts clean:
  Status OK, Class HIDClass, "Punktfunk Virtual Xbox Wireless Controller"
- it enumerates a HID child, Status OK, carrying HID_DEVICE_SYSTEM_GAME and
  HID_DEVICE_UP:0001_U:0005 — Windows parsed the constructed report descriptor and
  classified the pad as a Game Pad (usage page 0x01, usage 0x05), which is precisely
  what pf-xusb could never do

Test devnode, phantom child, driver package and both certs were removed afterwards.

Two build gotchas worth knowing, both already handled inside build-gamepad-drivers.ps1
and both of which cost a cycle here: CARGO_TARGET_DIR pointing outside the workspace
breaks wdk-sys (wdk-build walks up from OUT_DIR looking for a Cargo.lock and finds
none), and the WDK version must be pinned via Version_Number=10.0.26100.0 or bindgen
picks SDK 10.0.28000.0, which ships no km/crt headers.

Still open: the SwDeviceCreate USB identity (HID\VID_045E&PID_0B13) cannot be checked
through a devgen node, which has no USB hardware ids — that needs the host path. So the
WGI-promotion question is still unanswered, and host routing is still unwritten.
2026-08-09 16:54:00 +02:00
enricobuehler bbc0513f0c fix(validation): the kit could not read a real log — tracing wraps field names in ANSI
Found by running it. The first V3a run on .25 encoded 2700 frames in BOTH arms, at 59.6 fps, with 22
perf windows each — and the kit reported "fewer than 3 usable perf windows", because `tracing`'s fmt
layer wraps field NAMES in SGR escapes. The bytes on disk are `p99_us\e[0m\e[2m=\e[0m4601`, so
`s/.*p99_us=\([0-9][0-9]*\).*/\1/p` never matched. The message text is plain, which is why the
window COUNT was right and only the numbers vanished — and why the fixtures never caught it: they
were hand-written, and cleaner than reality.

Anything matching a field breaks the same way, so this was not only V3a: v2's `priority=Realtime`,
the demotion `reason=`, and v4's rungs all read fields. Every log read now goes through one
`log_cat` that strips SGR, and the spike is launched with NO_COLOR=1 so fresh logs are plain at the
source too — a human grepping a red leg by hand is defeated by those escapes exactly as the parser
was.

The self-test gains the same four perf windows a second time, ANSI-wrapped, asserting an identical
result: same numbers, same expectation, so a failure there can only mean the stripping broke. That
fixture caught its own first draft, which built the line in one printf with 27 placeholders against
23 arguments and emitted empty escapes — hence the field-at-a-time helper.

With this, V3a self-reports on .25 (sway headless, real dmabuf capture, AMD 780M/RADV, 2700 frames
per arm, both arms at default GPU priority):

    in-process        p50 2.08 ms   p99 4.18 ms   (21 windows)
    uncapped worker   p50 2.07 ms   p99 3.52 ms   (21 windows)
    p99 delta -0.66 ms  ->  PASS

R1's pre-registered abandonment gate does not fire: the process boundary is not merely under the
+1.0 ms ceiling, it is measurably FASTER at the tail, while p50 is unchanged (2.08 vs 2.07). An
earlier hand-extraction of the same logs gave -0.43 ms, so the direction reproduces across runs.
Caveat for whoever reads this later: idle iGPU in a KVM guest, RADV, no GPU-bound load. This bounds
the IPC hop; it says nothing about V3b, which still needs .21 under GRID 2.
2026-08-09 16:33:29 +02:00
enricobuehler 2de604ecab test(validation): the on-glass kit for the encode worker, including the 0.26.0-1 regression test
WP3 of design/gpu-priority-capability-worker-implementation-plan.md. Five legs, the first of which is
the test that would have caught the field incident: in a KDE session with the worker installed and
capped, `getcap` on the host must be EMPTY, its CapPrm all zeroes, `readlink /proc/<pid>/exe` must
resolve, and `punktfunk-host probe-compositor` must exit 0 — which on KWin succeeds only when the
privileged zkde_screencast_unstable_v1 global was actually advertised to this client.

Read-only by default; the one mutating rung (kill -9) is behind --allow-mutate and kills only a
worker that is a child of the spike the script itself started. It NEVER calls setcap: the uncapped
arms use a plain copy of the worker, which does not carry security.capability, verified uncapped
before use. So no leg needs root and none restores state. A skip is never a pass — exit 2 means
incomplete, distinct from 1 (failure).

V3 is split, which the plan did not do. Its stated form compares against PW1's in-process-capped
baselines, and those exist only on .21 under GRID 2:

  * V3a is the pre-registered abandonment gate and needs no capability at all — in-process versus an
    UNCAPPED worker, both at default priority, so the only difference is the process boundary. Fails
    if the worker's p99 exceeds inline by more than --gate-ms (1.0). This runs on any box with a GPU.
  * V3b is the lever itself, capped worker versus the refused in-process arm, and says plainly that
    an idle GPU makes it meaningless.

The false PASS this kit exists to refuse: a CPU-backed frame makes the proxy pin itself in-process
for the session, so a synthetic source would quietly turn the "worker" arm into a second in-process
arm and pass the gate for the wrong reason. The worker arm is only accepted with a dmabuf-passthrough
capture, a capability-carrying-worker line, and no fallback line anywhere in the log.

Also asserts the host and worker are different inodes — a hardlink shares the file capability, which
is the same incident by another route.
2026-08-09 13:07:31 +02:00
enricobuehler f26d21125d docs: the capability moves to the encode worker, and the host must never be given one
Rewrites the "GPU scheduling priority" section around the split: punktfunk-encode-worker carries
cap_sys_nice=ep, punktfunk-host carries nothing on any channel, ever. The KWin identification
mechanism is spelled out in plain words and the failure line is quoted verbatim
("KWin does not expose zkde_screencast_unstable_v1 to this client") so someone searching for their
symptom lands on the explanation.

The warning names all three ways an operator would reach for the capability — hand setcap, a systemd
AmbientCapabilities= line, a NixOS security.wrappers entry — because all three put it in the same
permitted set and all three cost KDE desktop streaming. That is the failure mode that made this
worth documenting: it looks exactly like a missing .desktop and survives reinstalling both ends.

configuration.md gains PUNKTFUNK_ENCODE_WORKER (path, or `off` to force the in-process encoder) and
re-describes PYROWAVE_QUEUE_PRIORITY as an intent forwarded to whichever process does the encode.
kde.md gains one line on the troubleshooting bullet someone actually lands on: getcap on the host
must print nothing.

The published 0.26.0 notes are deliberately untouched — they are the record of what shipped. The
flipped phrasing lives in v0.27.0's notes instead; v0.26.0.md:37 ("a system privilege that turns out
to stop KDE recognising the host at all") is the line that goes stale when this ships.
2026-08-09 12:51:01 +02:00
enricobuehler 4f8cce6751 feat(packaging): grant CAP_SYS_NICE to the encode worker on all six channels, and assert the host never gets it
767e67ca's per-channel mechanics were correct; they were aimed at the wrong binary. Each one is
restored here pointed at punktfunk-encode-worker, and every host-side removal from #136 stays
verbatim. All grants remain best-effort — an uncapped worker still encodes, at default priority, so
a failed setcap must never fail an install.

  * Arch: setcap in post_install AND post_upgrade (a replaced binary is a new inode).
  * RPM: %caps(cap_sys_nice=ep) in %files, never a %post setcap — %caps applies, restores and
    verifies, and covers Fedora as well as Bazzite via rpm-ostree layering.
  * Bazzite + Arch sysext: setcap on the staging tree before mksquashfs, which does record
    security.capability. The assertion is amended, not removed: host EMPTY is still a hard fail, and
    the worker must carry exactly cap_sys_nice=ep — missing is fine, anything else is not.
  * deb: setcap in postinst.
  * NixOS: security.wrappers for the WORKER plus PUNKTFUNK_ENCODE_WORKER in the unit. A file
    capability cannot live on a store path, and an ambient grant is right here precisely because
    nothing ever identifies the worker. The host's ExecStart stays on the store path.
  * Steam Deck: setcap the worker; the .desktop the script writes stays valid this time.

Four things the plan's channel table missed:

  * packaging/arch/build-sysext.sh had no capability handling at all, and a sysext can never run a
    pacman scriptlet — the SteamOS image would have shipped the lever permanently inert.
  * scripts/steamdeck/update.sh had none either. It rebuilds both binaries, so a new inode drops the
    grant, and it is the documented steady-state path: the lever would have died on the first update.
    It also never healed a Deck already capped by 0.26.0-1.
  * A capped worker is AT_SECURE, and glibc drops $ORIGIN-expanded RPATH entries for secure binaries
    unless they normalise into a trusted system dir. Copying the host's rpath under BUNDLE_FFMPEG=1
    would have left the capped worker unable to find libavcodec on exactly the channel that bundles
    it. Absolute DT_RPATH instead.
  * Nix crane scopes by -p, so the worker would not have been built at all, and it needs its own
    addDriverRunpath.

scripts/ci/assert-cap-matrix.sh mechanizes the lesson from 0.26.0-1 — verify the PACKAGE, never the
board. It unpacks the built Arch package, the deb, the rpm and the mounted sysext raw and asserts one
matrix: the host carries NOTHING (hard fail), the worker exactly cap_sys_nice=ep. The sysext reader
first proves it can round-trip a capability through mksquashfs/unsquashfs at all, so an unreadable
artifact fails rather than issuing a blind PASS, and --self-test red-teams the assertions themselves.

Red-teaming the leg found a real bug: setcap originally ran BEFORE the assertion, so "the worker
arrived carrying something unexpected" was unreachable and a stray %caps would have been silently
overwritten. Both sysext scripts now assert, then grant, then assert again.
2026-08-09 12:50:47 +02:00
enricobuehler 4a4118e3ce feat(pf-encode): encode PyroWave in a capability-carrying worker, so the host never holds a capability
PyroWave encodes on the same GPU shader cores the game saturates, and an elevated
VK_KHR_global_priority queue is the compute-preemption lever for it — measured on .21 (RTX 5070 Ti,
GRID 2 loop): encode p99 6.4 -> 4.4 ms. Every driver refuses every priority class without
CAP_SYS_NICE, on NVIDIA and on RADV alike, so the lever is decoration on a packaged host.

0.26.0-1 granted that capability to punktfunk-host and killed desktop streaming on every KDE box:
KWin identifies a client by resolving /proc/<pid>/exe and matching an installed .desktop's Exec=,
the kernel refuses that readlink to a reader whose effective set is not a superset of the target's
PERMITTED set (cap_ptrace_access_check), and KWin holds no capabilities. #136 revoked it everywhere.

The capability therefore cannot live in the process that fronts KWin. It lives in a new, deliberately
small binary — punktfunk-encode-worker — which owns the priority-elevated Vulkan device and talks to
nothing but the socket its parent spawned it on: no Wayland, no D-Bus, no network, no plugins. It is
a SEPARATE FILE and must stay one; a hardlink or a hidden host subcommand shares the inode, hence the
capability, and silently re-creates the incident. That rule is written where someone would break it,
in the worker crate's own Cargo.toml.

`open_inner` is reused verbatim in the worker — the same REALTIME->HIGH->none ladder, the same
refusal-never-fails-open invariant, the same PUNKTFUNK_PERF split — so the A/B stays comparable with
PW1. The only in-process change is a flag for whether THIS process prints the INERT warn, plus an
out-parameter reporting the class that was granted.

Three things the design did not anticipate:

  * An AU cannot ride in the message body. MAX_MSG is 64 KiB and bodies are serde_json, which
    renders a Vec<u8> as one decimal per byte: a 1080p60 AU is ~333 KB of JSON and 4K ~3.3 MB, and
    the minimum per-frame budget is already 64 KiB. So the AU crosses on a memfd the worker creates
    once and pwrites each frame; the fd crosses once, in Ready. A test pins the arithmetic so nobody
    "simplifies" the memfd away. Cursor bitmaps take the same route, only when their serial changes.

  * set_wire_chunking has to cross the wire even though poll_chunk does not. Chunking changes the AU
    BYTES, not merely how they are handed out — it feeds rate_budget()'s deflation and build_au's
    windowed framing — so a proxy-local copy would have the host cutting dense AUs at boundaries that
    are not window boundaries. Forwarded and mirrored. poll_chunk itself needs no protocol: the
    identical AuChunker runs host-side on the whole AU the worker returns.

  * CPU-backed frames really do reach this encoder (force_cpu_for_nvenc_444, and the raw-dmabuf
    degrade latch), and a 1080p BGRA frame is ~8 MB. The first non-dmabuf frame pins the session
    in-process with one warn rather than putting 480 MB/s on a socket.

Every rung falls back to the in-process encoder exactly as today with one warn and never a dead
session: PUNKTFUNK_ENCODE_WORKER=off, binary missing, spawn failure, handshake timeout, proto or
workspace-version skew (host and worker are different files now, so that check is load-bearing),
InitErr, a refused frame, and socket EOF mid-session — which respawns once, then pins inline.

Also: recv retries EINTR with the REMAINING deadline, not a fresh one. With SO_RCVTIMEO the kernel
returns EINTR rather than restarting, so a signal would otherwise read as a dead worker; re-arming
with the full budget would instead let a steady signal rate defer a real hang forever.
2026-08-09 12:50:26 +02:00
enricobuehler dcfba07803 refactor(pf-zerocopy): split the worker rails out of the zerocopy vocabulary so a second worker can reuse them
The encode worker (design/gpu-priority-capability-worker.md) needs exactly what the zerocopy worker
already has — SEQPACKET framing, fds as SCM_RIGHTS, a pinned-exe spawn that survives an on-disk
replacement, and a reaper that never blocks session teardown on a wedged child — but it must NOT
inherit the zerocopy protocol. Its messages are its own and version independently.

So `imp/proto.rs` keeps the vocabulary (PROTO_VERSION, ImportKind, Request, Reply, BufferDesc) and
all transport moves to `imp/ipc.rs`, reachable as `pf_zerocopy::ipc`. No behaviour change for the
zerocopy worker: client.rs now calls `ipc::self_exe()`/`ipc::spawn_worker()` and keeps the same fd-3
dup2 slot, PR_SET_PDEATHSIG, kill-then-reap-outside-the-lock, bounded reap with a D-state re-park,
and per-generation zombie sweep it had before.

Two real changes underneath the move:

  * The cmsg store was sized for exactly one fd (CMSG_SPACE(4) = 24 B). A multi-planar dmabuf can
    carry up to four, so it is now CMSG_SPACE(4*4); `send_fds`/`recv_fds` take a slice while `send`
    and `recv` keep their single-fd shapes as the fast path. An over-long fd list is rejected with
    io::Error rather than asserting — that is how MAX_MSG overflow is already handled — and the
    receive cap is enforced by the kernel through msg_controllen, so a 5-fd peer trips MSG_CTRUNC.

  * The old recv loop read only the FIRST i32 of each SCM_RIGHTS control message. Nothing sends two
    fds yet so it never fired, but every descriptor after the first in a multi-fd message would have
    leaked into the process. It now reads all of them.

Spawn takes the executable path as a parameter instead of assuming /proc/self/exe. The zerocopy
worker keeps self-exec; the encode worker passes its own binary, which must be a separate FILE and
never a subcommand — a shared inode shares the file capability.
2026-08-09 12:49:53 +02:00
enricobuehler 99f2130b28 feat(host/pads): the Windows Xbox backend, compiled and tested on Windows
Adds `xbox_windows` — the host half of the HID Xbox pad: the sealed-channel open under
the Bluetooth identity (SwDeviceCreate `pf_xboxwireless` + `USB\VID_045E&PID_0B13`, so
hidclass derives the real-pad `HID\VID_045E&PID_0B13` child ids), device_type 4 stamped
before the magic, and the `PadProto` impl that publishes through `xbox_proto`. No rich
plane: an Xbox pad has no touchpad, lightbar, adaptive triggers or IMU in its HID
contract, so apply_rich/clear_rich/neutralize_gyro are deliberately no-ops.

Rumble comes back off the driver's republished output reports. The Bluetooth rumble
report carries magnitudes on a 0..100 scale, not 0..255 — assuming otherwise silently
costs 60% of the range — and the enable mask gates each motor independently.

The two INF/driver guard tests now cover the new identity. `hwid_devtype_table_matches
_the_driver` caught the addition on its vacuity count, which is exactly what it is for.

Verified on the Arc laptop (.221, Win11 26200): `cargo test -p pf-inject --lib` 100/100
green, `cargo clippy --lib --profile test -- -D warnings` clean, fmt clean. Note
`clippy --all-targets` fails there on a PRE-EXISTING issue unrelated to this change —
tests/motion_contract.rs imports the linux-gated `switch_proto`.

Still unbuilt: the driver itself (.221 has no WDK) and the host routing that would send
an Xbox pad here instead of to XUSB. The report descriptor remains constructed rather
than captured — diff it against a real pad before shipping.
2026-08-09 12:24:14 +02:00
enricobuehler f266636392 feat(host/pads): an Xbox pad on Windows becomes a real HID device, so Steam can see it
`pf-xusb` registers only GUID_DEVINTERFACE_XUSB and exposes no HID collection, so
Steam's hidapi enumeration, DirectInput, joy.cpl and WGI/GameInput cannot see the pad
at all — only classic XInputGetState via xinput1_4's interface walk ever does. A
reporter spent two weeks on a dead controller for exactly that reason; switching the
client to DualSense, a real HID pad through the pf-gamepad UMDF driver, fixed it in
seconds.

This gives the Xbox pad that same footing: a new device_type 4 on the existing HID
minidriver, identified as a Bluetooth Xbox Wireless Controller (045E:0B13). The wired
ids the tree already uses (045E:028E, 045E:02EA) are vendor-class XUSB/GIP devices with
no HID interface on real hardware, so a HID child claiming one is a device that has
never existed and has nothing for Windows to promote.

Driver: identity, a constructed 132-byte Game Pad report descriptor, neutral report,
strings and the pf_xboxwireless hardware id. Host: `xbox_proto`, the byte-exact codec
mirroring that descriptor, with 11 layout tests.

One shared-path fix falls out. The timer completed every pended READ_REPORT with the
full 64-byte slot, and `copy_to_output` REFUSES a source longer than hidclass's buffer
rather than truncating it — so a pad declaring a shorter report would have failed every
read and looked dead. Report length is now per-identity; it returns 64 for all four
pre-existing pads, so their behaviour is provably unchanged.

NOT BUILT AND NOT RUN ON WINDOWS — no box was reachable. The Rust codec and its tests
pass on macOS; the driver, the INF and the report descriptor have never been compiled,
infverif'd, or seen by a real pad. The descriptor is constructed rather than captured,
which matters because we claim a real Microsoft VID/PID and SDL/Steam/Windows carry
stock mappings keyed off it — diff it against a capture before shipping.
2026-08-09 12:11:04 +02:00
284 changed files with 27969 additions and 3155 deletions
+20
View File
@@ -184,6 +184,26 @@ jobs:
working-directory: clients/android
run: ./gradlew :kit:testDebugUnitTest --stacktrace
# The cross-client contract in `clients/shared/console-vectors.json` — the console palette
# table, the settings section names and the screen-transition motion, each of which exists in
# three hand-written copies (here, pf-console-ui, the Apple client). The other two check it
# from their own suites; this is Android's side.
#
# FILTERED, not a plain `:app:testDebugUnitTest`: that task also runs the ~20 Roborazzi
# screenshot scenes, which are a release-artifact job (android-screenshots.yml, gated to v*
# tags) and have no business adding a minute to every push. The filter is what lets the
# contract gate here without dragging the rest of the app suite in with it.
- name: console parity vectors + app-module logic tests
working-directory: clients/android
run: >-
./gradlew :app:testDebugUnitTest
--tests 'io.unom.punktfunk.ConsoleVectorsTest'
--tests 'io.unom.punktfunk.HomeTilesTest'
--tests 'io.unom.punktfunk.GamepadSettingsLayoutTest'
--tests 'io.unom.punktfunk.ConsoleSubScreenRowsTest'
--tests 'io.unom.punktfunk.ConsoleSubScreenRoutesTest'
--stacktrace
- name: assembleDebug (cargo-ndk → jniLibs → APK)
working-directory: clients/android
env:
+15
View File
@@ -280,6 +280,21 @@ jobs:
done
echo "OK: $(echo "$DEPS" | grep -E '^libav|^libsw' | tr '\n' ' ')"
# 0.26.0-1 setcap'd `cap_sys_nice=ep` on the host from this package's .INSTALL scriptlet and
# killed desktop streaming on every KDE box — with a green board, because nothing here ever
# looked at what the built package would DO. The lesson recorded then was "verify the
# PACKAGE, never the board"; this is that, and pacman is the channel where it matters most,
# since capabilities live in the scriptlet rather than in package metadata.
#
# Host must carry NOTHING, the worker exactly cap_sys_nice=ep. `--self-test` runs first so a
# guard that has quietly lost the ability to fail takes the job down rather than approving a
# release. (Only the host package is checked: the client/web/scripting packages ship neither
# binary and the script skips them by itself.)
- name: Assert the capability matrix (Arch package)
run: |
bash scripts/ci/assert-cap-matrix.sh --self-test
bash scripts/ci/assert-cap-matrix.sh "$GITHUB_WORKSPACE"/dist/punktfunk-host-*.pkg.tar.zst
# The optional HDR gamescope companion (packaging/gamescope) — a separate pkgbase with a
# completely different dependency set, published into the same repo so `pacman -S
# punktfunk-gamescope` is all an Arch/SteamOS box needs for 10-bit BT.2020 PQ.
+20 -1
View File
@@ -91,8 +91,27 @@ jobs:
# advisory, the same fail-on-vulnerability stance as cargo-audit above; triage a finding by
# bumping the dep (or, if genuinely unfixable + inapplicable, pinning a resolution and
# noting why here).
#
# web carries two ignores, the ONLY ones in a blocking tree — both image-size advisories
# (GHSA-w3rx-r6r6-pgpr ICNS, GHSA-5p2g-fcmc-qvqq JXL/HEIF infinite-loop DoS). They are
# unfixable AND unreachable:
# * unfixable — the vulnerable range is `<= 2.0.2` and 2.0.2 IS latest; upstream has
# published no patched release, so no override can clear them.
# * unreachable — image-size rides in under `@unom/ui @payloadcms/richtext-lexical
# … payload`, and @payloadcms/richtext-lexical is a PEER of @unom/ui that only its
# `./richtext` export needs. The console imports section/toast/button/card/dialog/
# form/*/material/tabs — never `./richtext` — so payload is auto-installed peer weight
# that no bundle, and no request path, ever touches.
# Drop these the moment image-size ships a fix, or @unom/ui marks that peer optional
# (peerDependenciesMeta) and the chain leaves web/bun.lock entirely — either one makes the
# bare `bun audit` green again. Scoped per-tree so sdk/plugin-kit stay strictly fail-on-any.
- name: bun audit
run: bun audit
run: |
if [ "${{ matrix.tree }}" = "web" ]; then
bun audit --ignore=GHSA-w3rx-r6r6-pgpr --ignore=GHSA-5p2g-fcmc-qvqq
else
bun audit
fi
# Kept OUT of the bun-audit matrix so this tree's known-advisory state can't normalize failure
# in a shipping tree. Non-blocking via a step-level `||` (NOT job-level continue-on-error, which
+18 -1
View File
@@ -310,8 +310,14 @@ jobs:
# with "there is no reactor running, must be called from the context of a Tokio 1.x runtime".
# It WAS listed here, which is why only the .deb shipped a crashing tray while the RPM and
# Arch packages — which already split it — were fine.
#
# punktfunk-encode-worker IS in this invocation: it is the capability-carrying PyroWave
# encode worker that ships next to the host in /usr/bin, and build-deb.sh only builds it
# if the artifact is missing — building it here keeps it on the same sccache pass as the
# host. Unlike the tray it shares the host's dependency graph by design (v1 accepts that
# the worker links the same FFmpeg), so feature unification here is harmless.
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
-p punktfunk-host
-p punktfunk-host -p punktfunk-encode-worker
- name: Build host .deb (FFmpeg bundled)
# BUNDLE_FFMPEG=1 copies the image's /opt/ffmpeg libav* into the package and repoints the
@@ -320,6 +326,17 @@ jobs:
run: |
VERSION="$VERSION" BUNDLE_FFMPEG=1 bash packaging/debian/build-deb.sh
# Read the capability matrix out of the BUILT .deb before it is published. dpkg carries no
# capability metadata — the postinst applies them — so this reads the postinst that will
# actually run on a user's box, plus the payload. 0.26.0-1 granted the host cap_sys_nice=ep
# from exactly that postinst and killed every KDE desktop session while every board stayed
# green: host must carry NOTHING, worker exactly cap_sys_nice=ep. `--self-test` first so a
# guard that can no longer fail takes the job down instead of waving the release through.
- name: Assert the capability matrix (host .deb)
run: |
bash scripts/ci/assert-cap-matrix.sh --self-test
bash scripts/ci/assert-cap-matrix.sh dist/punktfunk-host_*.deb
# punktfunk-gamescope for apt. Same reasoning as the RPM leg in rpm.yml: without a packaged
# build, a Debian/Ubuntu box has no route to the patched gamescope except compiling it, and a
# stock gamescope streams SDR, cursorless, and tells every game its display is 60 Hz.
+38 -4
View File
@@ -7,10 +7,24 @@
# Two tiers, because a full `nix flake check` builds the whole Rust workspace with crane and would
# run for an hour on every push:
#
# * eval — `nix flake check --no-build`: instantiates every package, app, check, devShell and
# the NixOS module without building them. Catches the failures that actually happen to
# this flake — a renamed file, a callPackage argument that no longer exists, a syntax
# error, a package attribute dropped from packages.nix.
# * eval — `nix flake check --no-build`: instantiates every package, app, check and devShell
# without building them. Catches the failures that actually happen to this flake — a
# renamed file, a callPackage argument that no longer exists, a syntax error, a package
# attribute dropped from packages.nix.
#
# ⚠ It does NOT, on its own, check the NixOS module. `nix flake check` handles
# `nixosModules` by forcing the value and asserting it is a lambda taking an open
# attribute set — nothing more (nix's own source: `// FIXME: if we have a 'nixpkgs'
# input, use it to check the module.`). MEASURED: a module setting a nonexistent
# OPTION, referencing a nonexistent `pkgs` attribute AND calling a nonexistent `lib`
# function passes clean, printing `checking NixOS module ... all checks passed!`. This
# header used to claim the module was covered here; it was not, for the module's whole
# life. It is covered NOW because `checks.<system>.nixos-module`
# (packaging/nix/module-check.nix) evaluates it against real nixpkgs and asserts on the
# rendered systemd units — and because those assertions are pure Nix, INSTANTIATING
# that check runs them, so `--no-build` is enough. Keep them pure: a shell script in
# the derivation body would only run under a full `nix flake check`, which builds the
# hour-long Rust packages.
# * bun — actually BUILDS punktfunk-web + punktfunk-scripting. These are the two derivations
# whose inputs churn constantly (every dependency bump moves a lockfile) and they cost
# minutes, not hours, because neither compiles Rust. This is the end-to-end proof that
@@ -22,6 +36,12 @@
# They are the expensive ones and their inputs are already gated by the `rust` job in ci.yml; build
# them by hand on a Nix box, or with the `build-rust` dispatch input below.
#
# ⚠ punktfunk-gamescope deserves the dispatch run more than it looks: `host.gamescopeHdr` DEFAULTS
# TRUE, so it is on the critical path of every `services.punktfunk.host.enable = true` build, while
# being the one package nothing here compiles. It patches whatever gamescope the pinned nixpkgs
# carries, so a nixpkgs bump — not a change of ours — is what breaks it, and the first person to
# find out would be an operator whose system rebuild fails. Run the dispatch after a flake.lock bump.
#
# ⚠ pull_request is deliberately present. flatpak.yml shipped with push-only triggers and manifest
# breakage reached main invisibly for weeks — do not "simplify" this workflow by dropping it.
# ⚠ The two path lists are duplicated on purpose: a YAML anchor would be tidier, but Gitea's
@@ -66,6 +86,10 @@ on:
description: "Also build punktfunk-host + punktfunk-client (slow: full Rust workspace)"
type: boolean
default: false
build-gamescope:
description: "Also build punktfunk-gamescope (patched gamescope from source; run after a flake.lock bump)"
type: boolean
default: false
jobs:
flake:
@@ -165,3 +189,13 @@ jobs:
if: ${{ github.event.inputs.build-rust == 'true' }}
run: |
"$NIX" build --print-build-logs .#punktfunk-host .#punktfunk-client
# The patched compositor. Separate from build-rust because its failure mode is different: it
# tracks nixpkgs' gamescope, not our Rust, so it wants a run after a flake.lock bump rather
# than after a code change. `gamescope.nix` fails loudly (an eval-time `throw` if nixpkgs no
# longer exposes a patchable derivation, a `+pfhdr` grep in installCheckPhase) — but only if
# something actually builds it.
- name: Build the patched gamescope (dispatch opt-in)
if: ${{ github.event.inputs.build-gamescope == 'true' }}
run: |
"$NIX" build --print-build-logs .#punktfunk-gamescope
+32 -1
View File
@@ -103,7 +103,11 @@ jobs:
# gamescope`.) Matches packaging/rpm/punktfunk.spec, which dropped its BuildRequires too.
dnf -y install gtk4-devel libadwaita-devel SDL3-devel
# sysext build (packaging/bazzite/build-sysext.sh): squashfs + SELinux labeling.
dnf -y install squashfs-tools cpio libselinux-utils selinux-policy-targeted
# libcap = setcap/getcap: the sysext is the ONLY place the image can acquire
# cap_sys_nice=ep on punktfunk-encode-worker (a merged /usr is read-only squashfs and no
# scriptlet ever runs), and it is also what the build's host-must-be-uncapped assertion
# and the capability-matrix CI leg read with. Without it the image ships the lever inert.
dnf -y install squashfs-tools cpio libselinux-utils selinux-policy-targeted libcap
# Fedora's own gamescope, for its RUNTIME libraries only — never shipped, never run. The
# sysext folds in our punktfunk-gamescope and verifies it by executing `--version`, and
# on a cache hit (the common case) nothing else in this job would have pulled libavif /
@@ -155,6 +159,20 @@ jobs:
RPM_GPG_PASSPHRASE: ${{ secrets.RPM_GPG_PASSPHRASE }}
run: bash packaging/rpm/sign-rpms.sh
# Read the file-capability matrix out of the BUILT rpm, before anything is signed or
# published. 0.26.0-1 shipped `%caps(cap_sys_nice=ep)` on the host through this very spec —
# on Fedora and, via rpm-ostree layering, on Bazzite — and every board was green while every
# KDE desktop session died in the field. The lesson recorded then was "verify the PACKAGE,
# never the board"; this is that. Host must carry NOTHING; the worker must carry exactly
# cap_sys_nice=ep. `--self-test` first, so a guard that has quietly stopped being able to
# fail takes the job down instead of waving the release through.
- name: Assert the capability matrix (rpm)
run: |
bash scripts/ci/assert-cap-matrix.sh --self-test
# Only the main host package carries binaries; -debuginfo/-debugsource and the
# client/web/scripting subpackages ship neither and are skipped by the script itself.
bash scripts/ci/assert-cap-matrix.sh dist/punktfunk-[0-9]*.rpm
- name: Publish to the Gitea RPM registry
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
@@ -312,6 +330,19 @@ jobs:
dist/punktfunk-web-"${PF_VERSION}-${PF_RELEASE}"*.rpm \
dist/punktfunk-scripting-"${PF_VERSION}-${PF_RELEASE}"*.rpm
# Read the capability matrix back OUT of the image that is about to be published — the one
# channel where getting it wrong is unrepairable, because a merged sysext's /usr is read-only
# squashfs and the only fix is a new image plus a feed republish. 0.26.0-1's Bazzite breakage
# was confirmed exactly this way, after the fact, by mounting the published .raw and running
# getcap on it. Doing it here means the .raw never reaches the feed.
#
# The script proves its own reader first (cap a file, squash it, unsquash it, read it back)
# so a runner that cannot see file capabilities FAILS the leg instead of blessing the image.
- name: Assert the capability matrix (sysext image)
run: |
bash scripts/ci/assert-cap-matrix.sh \
"dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw"
# The feed's SHA256SUMS is OpenPGP-signed with the same packages@unom.io key as the RPMs, and
# punktfunk-sysext(8) refuses a feed it can't verify — the checksums alone never proved
# anything, sitting on the same registry as the images they describe.
+360
View File
@@ -12,6 +12,366 @@ with the version table of the release you are moving to, then read **Breaking ch
---
## v0.27.1 — in development
### NixOS + KDE — session detection, the other half
🛑 **v0.27.0's NixOS session-detection fix did not reach a stock NixOS + Plasma 6 box.** It resolved
the nixpkgs wrapper decoration through `/proc/<pid>/exe` (below) — and on that exact box the kernel
refuses to let us read that link. Reading `/proc/<pid>/exe` is not gated on owning the process: it
goes through `cap_ptrace_access_check`, which requires the reader's effective set to be a superset
of the target's **permitted** set. NixOS's own Plasma module ships
`security.wrappers.kwin_wayland = { capabilities = "cap_sys_nice+ep"; }`, so KWin holds a capability
and the host — which must stay uncapped, because a capability is exactly what makes it
unidentifiable to KWin (v0.27.0, above) — gets `EACCES`. The two traps compose: the name *needs*
`exe` because nixpkgs wrapped the binary, and `exe` is *denied* because NixOS capped it. Detection
went straight back to `ActiveKind::None`, `wayland` to `-`, and every connect to
`no usable compositor`. It presents identically to the v0.27.0 bug, which is why a box that had been
worked around with a decoy process broke again the moment the decoy was removed.
Name resolution now falls through to `argv[0]` (`/proc/<pid>/cmdline`) when the kernel refuses `exe`.
That reads correctly for the same reason `ps` does: make-wrapper's wrapper `exec -a "$0"`s the hidden
binary, so `argv[0]` survives the decoration `comm` does not. Measured on Linux 6.x against a capped
target, for a file capability and for the ambient form `security.wrappers` uses, identically: the
`/proc/<pid>` directory keeps its real owner (so the uid filter was never the problem), `comm` and
`cmdline` stay readable, and only `exe` fails. `argv[0]` is consulted **last** and never overrides a
readable `exe` — it is the process's own claim about itself, and a same-uid process can set it to
anything; the worst a spoof achieves is aiming detection at a backend that then fails its own
availability probe. The `comm` fast path is still one read for every ordinary distro.
Also reached by the same rung: `gamescope` carries `cap_sys_nice` on a number of distros, so a
*wrapped and capped* gamescope was equally invisible to the foreign-gamescope probe.
## v0.27.0
87 commits since v0.26.0.
### Versions
| | v0.26.0 | v0.27.0 | Notes |
|---|---|---|---|
| Wire protocol | 2 | **2** | unchanged |
| C ABI | 17 | **18** | `punktfunk_connection_next_rumble_cmd2` **added**; nothing removed or changed |
| Workspace crate dirs | 26 | **27** | `crates/punktfunk-encode-worker` (39 members; two `tools/` crates deliberately *excluded*) |
| Virtual-display driver protocol | 6 | **6** | unchanged (minimum accepted still 3) |
| Windows virtual-gamepad channel | 3 | **3** | unchanged — three `device_type`s added additively |
| Plugin index schema | 1 | **1** | unchanged |
| `api/openapi.json` | 0.25.0 | **0.25.0** | unchanged — no management-API edits this release |
| gamescope patch level (`+pfhdrN`) | 4 | **5** | 6 patches → 7 (the PipeWire use-after-free); `pkgrel` resets 3 → 1 |
| `@punktfunk/host` (SDK) | 0.1.4 | **0.1.4** | unchanged |
| `@punktfunk/plugin-kit` | 0.4.0 | **0.4.0** | unchanged |
**`crates/pf-driver-proto` is no longer byte-identical to the previous release.** It was through
both v0.25.0 and v0.26.0, so if you ship the virtual-display driver or the gamepad channel and have
been skipping this crate, stop skipping it here. The change is purely additive — three `device_type`
constants, no field moved, no size changed.
### ⚠ Breaking changes
**None** for embedders or the wire. Every embedder, packager and plugin that works against v0.26.0
works against v0.27.0 unchanged; the C ABI moves, but by addition only (below).
Two things change shape for **packagers** and one **default** flips:
- **A second installed binary**, `punktfunk-encode-worker` — see the section below. It is the only
file that may carry `cap_sys_nice=ep`, and it must be a separate file.
- **`PUNKTFUNK_XBOX_BACKEND` now defaults to `hid`** on Windows, so an Xbox pad is built as a real
HID device rather than the XUSB companion. `=xusb` is the escape hatch.
- **NixOS `scripting.autoStart` now defaults ON**, matching every other packaging (detailed below).
### `punktfunk-encode-worker` — the GPU-priority capability moves off the host
0.26.0 left the PyroWave priority ladder wired and inert: it needs `CAP_SYS_NICE`, and 0.26.0-1
proved the host can never hold one — see **PyroWave on Linux — Wave 2**, PW1, under v0.26.0 below. A
capability-carrying process cannot be identified by KWin (`cap_ptrace_access_check` refuses
`/proc/<pid>/exe` to a reader whose effective set is not a superset of the target's **permitted**
set), so it never gets `zkde_screencast_unstable_v1` and every KDE desktop session dies. Neither
`prctl(PR_SET_DUMPABLE, 1)` nor systemd `AmbientCapabilities=` nor a NixOS `security.wrappers` entry
changes that — all three land the capability in the same permitted set.
The capability therefore moves to a process that fronts nothing. **`punktfunk-encode-worker`** is a
new workspace member and a new installed binary: it owns the priority-elevated Vulkan device for
PyroWave sessions, receives capture dmabufs over a `SOCK_SEQPACKET` pair from its parent, and returns
compressed access units. It connects to no compositor, no D-Bus and no network, so its
non-dumpability costs nothing and its blast radius is one socket to the host that spawned it.
🛑 **The invariant, for anyone packaging this:** the worker is a **separate file**. Never a hardlink
to `punktfunk-host` and never a subcommand of it — a shared inode shares the file capability, which
silently re-creates 0.26.0-1 on every KDE box. `punktfunk-host` carries no capability, on any
channel, ever.
- **The grants are re-targeted, not re-introduced.** Every channel that granted in 0.26.0-1 grants
again, at the worker: Arch `.install` (`post_install` **and** `post_upgrade` — a replaced binary is
a new inode), RPM `%caps(cap_sys_nice=ep)` in `%files` (never a `%post setcap`; this covers Fedora
and Bazzite layering), the Bazzite sysext staging tree pre-`mksquashfs` (which does record
`security.capability`), the deb `postinst`, the Deck installer, and NixOS
`security.wrappers.punktfunk-encode-worker`. Every #136 host-side removal stays verbatim, including
the sysext's host hard-fail.
- **The sysext assertion is amended, not removed** — host must be empty (hard fail), worker must
carry **exactly** `cap_sys_nice=ep`. A *missing* worker capability is not an error: the grant is
best-effort everywhere.
- **A new release-CI leg asserts the getcap matrix** on the built Arch package, the deb and the
mounted sysext raw. The 0.26.0-1 lesson was "verify the package, never the board"; this is that,
mechanized, and it is what would have caught the original break.
- **On NixOS the env override is load-bearing**, not a convenience: a file capability cannot live on
a read-only store path, so the module wraps the worker and sets `PUNKTFUNK_ENCODE_WORKER` to the
wrapper path in the unit. An ambient grant is fine *here* — the worker is not a KWin client. The
host's `ExecStart` stays on the plain store path (the #136 fix stands).
**Fallback ladder — no rung can kill a negotiated session.** Binary not found → spawn failure →
handshake timeout → protocol or workspace-version mismatch → socket EOF mid-session all fall back to
the **in-process encoder exactly as today**, at default priority, with one warning. Host and worker
are different files now, so the version check is load-bearing rather than decorative; they ship
lockstep in every channel. The in-process path stays compiled and tested — it is the floor, not dead
code. `PYROWAVE_QUEUE_PRIORITY` keeps its 0.26.0 grammar and is now forwarded **explicitly** in the
handshake rather than read from the worker's environment, which is sanitized at spawn; one env var
still means one thing on both platforms.
### NixOS — session detection, module defaults, and a CI gate that was never running
🛑 **The host could not detect any graphical session on NixOS, at all.** The live-session probe
matched `/proc/<pid>/comm` exactly against `kwin_wayland` / `gamescope` / `gnome-shell` /
`Hyprland`. `comm` is the kernel's name for the **executed file**, truncated to 15 bytes — not
`argv[0]` — and nixpkgs wraps essentially every graphical binary: `wrapProgram` moves the real ELF
aside to `.<name>-wrapped` and installs a wrapper that `exec -a "$0"`s it. So the kernel reports
`.kwin_wayland-w` while `ps` and `pgrep -a` show a perfectly ordinary `kwin_wayland`, because they
read argv. Every probe answered `ActiveKind::None` on a running desktop, and nothing downstream
could recover: `wayland` logged as `-`, a correct `WAYLAND_DISPLAY` changed nothing, `Auto` returned
the *detected* backend so a live KWin already in `available()` was never chosen, and a
`PUNKTFUNK_COMPOSITOR` pin turned the miss into a hard error through `pinned_at_a_dead_session`.
sway and river survived by accident — nixpkgs' wrapper execs a binary still called `sway`.
Names are now resolved through `/proc/<pid>/exe`, whose file name is untruncated, with the nixpkgs
decoration stripped. Stripping requires **both** the leading `.` and a trailing `-wrapped`, so
KWin's own real `kwin_wayland_wrapper` binary keeps its name instead of collapsing into
`kwin_wayland` and handing the probe the parent's PID. The `comm` fast path is unchanged for every
ordinary distro — one read, no readlink — and no name that matched before can stop matching. Also
applied to the foreign-gamescope probe, which had the same defect.
**Module changes** (`services.punktfunk`):
- **`host.desktopSession`** *(new, default `false`)* — binds the host to `graphical-session.target`,
the declarative form of the `punktfunk-host-desktop-session.conf` drop-in. Without it a
Plasma/GNOME restart leaves the host holding a Wayland socket and portal D-Bus connection that
died with the old compositor: it still listens, still answers, and every session after that fails
at capture. Off by default because an appliance may never reach that target and would be left
permanently stopped.
-**`scripting.autoStart` now defaults ON** *(behaviour change)*, matching the deb `postinst` and
RPM `%post`, which both `systemctl --global enable` the runner, and the sysext's baked-in
`default.target.wants` symlink. It was opt-in here on the reasoning that the runner is inert until
you add automation — untrue since the game-library scanners became plugins, so a NixOS host came
up with an empty library and no obvious cause. Opt out with `scripting.autoStart = false` or
`systemctl --user mask punktfunk-scripting`.
- **Three divergences from the shipped units, ported.** `punktfunk-web` gains
`StartLimitIntervalSec=0` (without it, 5 starts / 10 s against `RestartSec=2` gives up permanently
after ~10 s — exactly the window before the host's first `serve` writes the mgmt token, so a
console enabled before the host's first run stayed dead) and `Restart=always` rather than
`on-failure`. `punktfunk-scripting` gains the sandbox the deb/rpm unit has all along
(`NoNewPrivileges`, `ProtectSystem=strict`, `ReadWritePaths=%h /tmp`, restricted address families,
`PrivateTmp=no`) — it is the one unit that runs arbitrary operator TypeScript by design, and it
had been running strictly less confined on NixOS than anywhere else.
- A **warning** when the host is enabled and `xdg.portal.enable` is not.
🛑 **`nix flake check` does not check `nixosModules`** — worth knowing for anyone maintaining a
flake. It forces the value and asserts it is a lambda taking an open attribute set, and stops;
nix's source still carries `// FIXME: if we have a 'nixpkgs' input, use it to check the module.`
Measured: a module with a nonexistent option, a nonexistent `pkgs` attribute **and** a nonexistent
`lib` function passes, printing `checking NixOS module ... all checks passed!`. `nix.yml`'s header
claimed that leg covered the module; it never had. `checks.<system>.nixos-module`
(`packaging/nix/module-check.nix`) now evaluates it against real nixpkgs across four scenarios and
asserts on the rendered units, including a guard that the host's `ExecStart` stays on the plain
store path while the encode worker points at the wrapper. Its assertions are pure Nix, so
instantiation runs them and the existing `--no-build` leg is enough. `punktfunk-gamescope` gains a
`build-gamescope` dispatch input — it is on the critical path of every host build yet nothing
compiled it, and it tracks nixpkgs' gamescope, so a `flake.lock` bump is what breaks it.
### C ABI 17 → 18
**`punktfunk_connection_next_rumble_cmd2` is new.** The `0xCA` rumble plane carries the two Xbox
impulse-trigger motors (v3, below) and `punktfunk_connection_next_rumble_cmd`'s fixed out-params
have no room for them:
```c
PunktfunkStatus punktfunk_connection_next_rumble_cmd2(
PunktfunkConnection *c, uint16_t *pad, uint16_t *low, uint16_t *high,
uint16_t *left_trigger, uint16_t *right_trigger,
uint32_t *backstop_ms, uint32_t timeout_ms);
```
**Added, not widened.** `_cmd` keeps its signature *and* its values bit-identical for handle-only
traffic; all four rumble entry points remain exported. An exported parameter list is part of the
contract, and growing one in place breaks every out-of-tree embedder at once — with a
stack-corruption signature rather than a link error. This follows the existing
`next_rumble``next_rumble2` precedent.
**One behavioural delta on the old symbol**, documented in `abi.rs` and pinned by a test: against
a host driving the trigger motors, a `_cmd` caller now receives commands with `low == high == 0`
where the demux previously dropped the update entirely. They are idempotent handle stops — the
command as a whole is not silent, so redundant-stop suppression cannot fold them. Zero cost today:
nothing sources non-zero trigger levels yet.
**Render trigger levels only on a pad that has trigger motors.** Do not fold them into the handles —
impulse-trigger content is continuous, so folding it drones the handle motors flat-out. Query
`SDL_PROP_GAMEPAD_CAP_TRIGGER_RUMBLE_BOOLEAN` or `GCDeviceHaptics.supportedLocalities`.
🛑 **This delivery path is deliberately built ahead of its producer and nothing here claims
otherwise.** Exactly one backend can ever source these levels — the Windows HID Xbox pad's output
report `0x03` — because `XINPUT_VIBRATION` and evdev `FF_RUMBLE` both have two members. That
producer is reachable only through GameInput, which does not enumerate an `xinputhid`-promoted Xbox
pad at all (measured against a real Microsoft Elite, equally invisible there while classic XInput
reads it live). The wire, the engine and this entry point are exercised by synthetic levels only.
### Gamepads
- **`PUNKTFUNK_GAMEPAD_XBOXELITE = 11`** — a new `GamepadPref` wire byte, appended to
`Hello`/`Welcome`. The `Auto` sentinel in the round-trip test moved 11 → 12. An older peer
degrades an unknown byte to `Auto`, so this is graceful in both directions.
- **`XboxOne` is now a distinct HID identity on Windows** (`045E:02FD`, Bluetooth Xbox One S)
through the UMDF minidriver. It used to fold to `Xbox360` there, because the only Windows Xbox
backend was the XUSB companion, which presents one fixed 360 identity and cannot vary it.
- **Three new `pf_driver_proto::gamepad` device types**, contiguous and sharing one report
descriptor byte for byte (they are the same pad in HID terms; the descriptor is the report
*shape*, the identity is what the OS keys mappings off):
| const | value | identity |
|---|---|---|
| `DEVTYPE_XBOX` | 4 | `045E:0B13` Xbox Wireless Controller |
| `DEVTYPE_XBOX_ONE_S` | 5 | `045E:02FD` Xbox Wireless Controller (One S) |
| `DEVTYPE_XBOX_ELITE` | 6 | `045E:0B22` Xbox Elite Wireless Controller Series 2 |
⚠ The Xbox input report is **not** 64 bytes like its siblings — it is `XBOX_INPUT_REPORT_LEN`
(16). The driver serves per-identity report lengths, because hidclass sizes its buffer from the
descriptor and refuses an over-long source.
-**Elite paddles are not implemented.** `BTN_PADDLE1..4` still fold or drop exactly as on the
other Xbox classes. `DualSenseEdge` remains the only virtual pad with native back-button slots.
- **All three Xbox identities install `pfGamepadXbox`**, their own DDInstall section, which attaches
the `xinputhid` bus filter. Merging it back into the shared `pfGamepad` section is a one-line edit
that looks like tidying and would hand a DualSense, DualShock 4, Edge and Steam Deck to
Microsoft's Xbox translator. `only_the_xbox_identity_installs_the_xinputhid_section` asserts the
split in both directions.
**What actually promotes the pad — two registry values, and the pairing is the whole finding.**
`UpperFilters=xinputhid` is a `.HW` AddReg (hardware key); `DevicePropertyFlags=1` is a DDInstall
AddReg (software key). A one-value A/B on real hardware: removing `DevicePropertyFlags` alone
reverts everything — no `IG_00`, no XUSB interface, no XInput, no WGI entry — while `UpperFilters`
alone is completely inert. `1` = `BusDevice`, which Microsoft's own comment glosses as "a focused
bus filter driver for the IG_ problem". **This retracts an earlier in-tree conclusion that the
filter should never ship**: it was never broken, it had simply never been switched on.
⚠ Microsoft's allow-list contains `02D1, 02DD, 02E3, 02EA, 0B00, 0B0A, 0B13, 02FF` — neither `02FD`
nor `0B22` is on it, and promotion happens anyway, because it comes from our own AddReg.
### Wire (no version change)
**The `0xCA` rumble datagram gains a v3 form**, `PUNKTFUNK_RUMBLE_V3_LEN = 14`:
```
v1 7 B: [0xCA][u16 pad][u16 low][u16 high]
v2 10 B: … [u8 seq][u16 ttl_ms]
v3 14 B: … [u16 left_trigger][u16 right_trigger]
```
v3 is built *from* v2's bytes, so the prefix relationship is structural rather than a convention two
encoders must keep agreeing on, and every reader gates with `>=`. All four levels share one `seq`
and one TTL deliberately: they are one statement of the pad's feedback at one instant, so the entire
v2 apparatus — renewal cadence, stop burst, the client's seq gate, the lease clamp — governs the
triggers with no new code. The new `RumbleUpdate` fields are plain `u16`, not `Option`: on a
level-triggered plane "absent" must mean zero, because "absent → keep the previous value" is the
stuck-rumble bug in a new costume.
**The two trigger `enable`-mask bits remain conjecture.** Bits 2/3 (the handles) are measured;
bits 0/1 are inferred from field order and nothing else. No test asserts them. XInput cannot settle
this; it has two motors.
### Packaging
- **gamescope pin `8c676c39``5fb8dce4`** (3.16.25-1 → 3.16.25-11), all six patches rebased, plus
a **seventh**: the PipeWire use-after-free that aborted a session on every connect. The marker
moves `+pfhdr4`**`+pfhdr5`**, so `pkgrel` resets to 1.
- **Patch 0001 offers `xBGR_210LE` before `xRGB_210LE`.** ⚠ Deliberately *not* done by calling
upstream's `vulkan_get_rgb10_capture_format()` — that symbol landed after 3.16.25 and would break
`packaging/nix/gamescope.nix` with an opaque C++ error instead of a patch conflict.
- **Every `punktfunk-gamescope` RPM ever published was unsigned.** `Sign RPMs` runs right after
`Build RPM`, while the gamescope RPM is built ~90 steps later behind its own cache, so it missed
the signing pass entirely — and the repo file we ship carries `gpgcheck=1`. A second pass signs it
before publish, fail-closed on a tag.
-**The v0.26.0 gamescope gate failed the job at the *build* step**, which in `deb.yml` runs before
both the apt publish and the release attach — so a missing *extra* withheld the host `.deb` itself,
and the `.deb` published on v0.26.0 still carries the `CAP_SYS_NICE` grant. `rpm.yml` had the
identical latent bug. Both now warn at build/package time and gate as the **last** step of the job.
- **`driver uninstall --audio`** — a third Inno `[UninstallRun]` entry that removes the MEDIA-class
devnodes the host mints at runtime. Marker-matched, never name-matched: our instances are
name-identical to Steam's, and a `ROOT\` enumeration guard means a marker-shaped value on a real
sound card can never cost the user their hardware.
- **The sysext `post_merge` step re-runs when already current, plus a new `reapply` verb.** A sysext
upgrade is driven by the script from the **old** image, so a `post_merge` step added in a release
is executed by nobody, permanently, on exactly the installs that need it.
### Host
- **HDR capture offers `xBGR_210LE` before `xRGB_210LE`.** gamescope's capture textures are
mappable, hence linear-tiled, and NVIDIA does not implement linear-tiled STORAGE for
`A2R10G10B10_UNORM_PACK32` — so `imageStore` lands in XBGR order while the buffer is still
*labelled* `XRGB2101010`. Every mapping on both ends audits clean because the label was right and
only the content was wrong. Fixed host-side because the deployed gamescope cannot self-correct.
- **One NVENC open failure no longer kills every session on the box**, and the 10-bit capability
probe no longer wedges a direct-SDK host process-wide with `NV_ENC_ERR_INVALID_VERSION`.
- **`/api/v1/local/summary` reports the resolution the session actually got**, not the negotiated
one it was seeded with.
### Workspace
`crates/punktfunk-encode-worker` joins as a member (above). Two bring-your-own-hardware measurement
tools are added and **excluded** in the root manifest, so `cargo build --workspace` and CI never see
them: `tools/hid-descriptor-dump` (dumps and decodes a real HID report descriptor; pulls `hidapi`)
and `tools/win-input-matrix` (asks each Windows input API what it can see — ⚠ `wake_wgi()` is not
optional there: both WGI collections return a cache a console app has never started filling, so
without subscribing first they come back empty with real controllers attached).
### Host and client environment variables
- **`PUNKTFUNK_XBOX_BACKEND`** *(new, host, Windows)*`hid` (the new **default**) or `xusb` (the
escape hatch). The HID pad is now a superset of the XUSB companion: it keeps classic XInput while
gaining Steam, SDL, RawInput, DirectInput, `joy.cpl` and WGI, plus rumble, which XUSB could not
source at all. The escape hatch stays because promotion leans on Microsoft's inbox
`xinputhid.inf`; if a servicing update changes it, one env var restores the old behaviour with no
reinstall. An unrecognised value takes the **default**, not the opt-out, so a typo cannot silently
drop a user onto the path with no HID collection.
- **`PUNKTFUNK_GAMESCOPE_BIND`** *(new, host, Linux)* — unset = auto, `0` = never, `1` = force.
Governs whether the host binds the patched gamescope over the distribution's `/usr/bin/gamescope`
inside a session's mount namespace.
- **`PUNKTFUNK_ENCODE_WORKER`** *(new, host, Linux)* — where to find the encode worker. Resolution
order: this variable → alongside `/proc/self/exe``PATH`. `off` forces the in-process encoder,
the debug escape hatch that makes the A/B a one-line change. Load-bearing on NixOS (above).
- **`PYROWAVE_QUEUE_PRIORITY`** *(unchanged grammar, new consumer)* — the *intent*, forwarded to the
worker; the granted class comes back in the handshake and the host logs it centrally, so the
in-process INERT warning does not double-fire. When the worker is uncapped as well — an operator
stripped it, or the filesystem cannot store the capability — the same INERT wording fires, now
naming the worker binary rather than the host.
### Documentation
- `docs-site` **Running as a service → GPU scheduling priority** rewritten around the split: the
worker carries the capability, the host never does, and `setcap` on `punktfunk-host` is called out
as the thing an operator must never do, with the `zkde_screencast_unstable_v1` symptom spelled out
so anyone who already did it can self-diagnose. The anchor is unchanged, so existing links hold.
- `configuration.md` gains the `PUNKTFUNK_ENCODE_WORKER` row and rewrites `PYROWAVE_QUEUE_PRIORITY`
off "the packages deliberately do not grant this".
- The 0.26.0 user-facing notes describe a privilege that is deliberately not granted. That is the
record of what 0.26.0 shipped and is **not** rewritten; the new phrasing — granted to the worker,
never to the host — lives in `docs/releases/v0.27.0.md`.
- `install.md` **NixOS** documents `desktopSession`, and its `punktfunk-scripting` bullet no longer
claims the runner "ships disabled": that was true only of Arch and source installs — apt, dnf, the
Bazzite sysext and now the NixOS module all start it, because the library scanners are plugins.
`bazzite.md` carried the same stale claim and is corrected. **Running as a service → Restart the
host with your desktop** gains the NixOS one-liner beside the drop-in.
- `packaging/nix/README.md`: `desktopSession`, `gamescopeHdr`/`gamescopePackage` and the
`punktfunk` group added to the option tables; the "what the module configures" list gains the
`security.wrappers` entry, with the KWin-identification reasoning for why the capability is on the
worker and not the host; and a caveat recording that `nix flake check` does not check the module,
plus the two rules for editing `module-check.nix`.
---
## v0.26.0
52 commits since v0.25.0.
Generated
+47 -35
View File
@@ -994,7 +994,7 @@ dependencies = [
[[package]]
name = "cursor-probe"
version = "0.26.0"
version = "0.27.0"
dependencies = [
"anyhow",
"pf-capture",
@@ -1114,7 +1114,7 @@ dependencies = [
[[package]]
name = "display-disturb"
version = "0.26.0"
version = "0.27.0"
dependencies = [
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
]
@@ -2358,7 +2358,7 @@ dependencies = [
[[package]]
name = "latency-probe"
version = "0.26.0"
version = "0.27.0"
[[package]]
name = "lazy_static"
@@ -2463,7 +2463,7 @@ dependencies = [
[[package]]
name = "libvpl-sys"
version = "0.26.0"
version = "0.27.0"
dependencies = [
"bindgen",
"cmake",
@@ -2498,7 +2498,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.26.0"
version = "0.27.0"
dependencies = [
"punktfunk-core",
]
@@ -2988,7 +2988,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pf-bitstream"
version = "0.26.0"
version = "0.27.0"
dependencies = [
"cros-codecs",
"tracing",
@@ -2996,7 +2996,7 @@ dependencies = [
[[package]]
name = "pf-capture"
version = "0.26.0"
version = "0.27.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3017,7 +3017,7 @@ dependencies = [
[[package]]
name = "pf-client-core"
version = "0.26.0"
version = "0.27.0"
dependencies = [
"anyhow",
"ash",
@@ -3052,7 +3052,7 @@ dependencies = [
[[package]]
name = "pf-clipboard"
version = "0.26.0"
version = "0.27.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3070,7 +3070,7 @@ dependencies = [
[[package]]
name = "pf-console-ui"
version = "0.26.0"
version = "0.27.0"
dependencies = [
"anyhow",
"ash",
@@ -3078,6 +3078,7 @@ dependencies = [
"pf-presenter",
"punktfunk-core",
"sdl3",
"serde_json",
"skia-safe",
"tracing",
]
@@ -3091,7 +3092,7 @@ dependencies = [
[[package]]
name = "pf-dxvadec"
version = "0.26.0"
version = "0.27.0"
dependencies = [
"cros-codecs",
"pf-bitstream",
@@ -3101,7 +3102,7 @@ dependencies = [
[[package]]
name = "pf-encode"
version = "0.26.0"
version = "0.27.0"
dependencies = [
"anyhow",
"ash",
@@ -3118,6 +3119,8 @@ dependencies = [
"pf-zerocopy",
"punktfunk-core",
"pyrowave-sys",
"serde",
"serde_json",
"tracing",
"tracing-subscriber",
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
@@ -3125,7 +3128,7 @@ dependencies = [
[[package]]
name = "pf-frame"
version = "0.26.0"
version = "0.27.0"
dependencies = [
"anyhow",
"libc",
@@ -3137,7 +3140,7 @@ dependencies = [
[[package]]
name = "pf-gpu"
version = "0.26.0"
version = "0.27.0"
dependencies = [
"anyhow",
"pf-host-config",
@@ -3151,11 +3154,11 @@ dependencies = [
[[package]]
name = "pf-host-config"
version = "0.26.0"
version = "0.27.0"
[[package]]
name = "pf-inject"
version = "0.26.0"
version = "0.27.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3184,14 +3187,14 @@ dependencies = [
[[package]]
name = "pf-paths"
version = "0.26.0"
version = "0.27.0"
dependencies = [
"tracing",
]
[[package]]
name = "pf-presenter"
version = "0.26.0"
version = "0.27.0"
dependencies = [
"anyhow",
"ash",
@@ -3206,7 +3209,7 @@ dependencies = [
[[package]]
name = "pf-update"
version = "0.26.0"
version = "0.27.0"
dependencies = [
"serde",
"serde_json",
@@ -3214,7 +3217,7 @@ dependencies = [
[[package]]
name = "pf-update-check"
version = "0.26.0"
version = "0.27.0"
dependencies = [
"anyhow",
"base64",
@@ -3226,7 +3229,7 @@ dependencies = [
[[package]]
name = "pf-vaadec"
version = "0.26.0"
version = "0.27.0"
dependencies = [
"cros-codecs",
"pf-bitstream",
@@ -3235,7 +3238,7 @@ dependencies = [
[[package]]
name = "pf-vdisplay"
version = "0.26.0"
version = "0.27.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3268,7 +3271,7 @@ dependencies = [
[[package]]
name = "pf-vkdecode"
version = "0.26.0"
version = "0.27.0"
dependencies = [
"ash",
"cros-codecs",
@@ -3279,7 +3282,7 @@ dependencies = [
[[package]]
name = "pf-win-display"
version = "0.26.0"
version = "0.27.0"
dependencies = [
"anyhow",
"pf-paths",
@@ -3291,7 +3294,7 @@ dependencies = [
[[package]]
name = "pf-zerocopy"
version = "0.26.0"
version = "0.27.0"
dependencies = [
"anyhow",
"ash",
@@ -3514,7 +3517,7 @@ dependencies = [
[[package]]
name = "punktfunk-cli"
version = "0.26.0"
version = "0.27.0"
dependencies = [
"pf-client-core",
"punktfunk-core",
@@ -3525,7 +3528,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.26.0"
version = "0.27.0"
dependencies = [
"android_logger",
"jni",
@@ -3543,7 +3546,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-linux"
version = "0.26.0"
version = "0.27.0"
dependencies = [
"anyhow",
"async-channel",
@@ -3560,7 +3563,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-session"
version = "0.26.0"
version = "0.27.0"
dependencies = [
"anyhow",
"pf-client-core",
@@ -3575,7 +3578,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-windows"
version = "0.26.0"
version = "0.27.0"
dependencies = [
"async-channel",
"mdns-sd",
@@ -3594,7 +3597,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.26.0"
version = "0.27.0"
dependencies = [
"aes-gcm",
"bytes",
@@ -3624,9 +3627,18 @@ dependencies = [
"zeroize",
]
[[package]]
name = "punktfunk-encode-worker"
version = "0.27.0"
dependencies = [
"pf-encode",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "punktfunk-host"
version = "0.26.0"
version = "0.27.0"
dependencies = [
"aes",
"aes-gcm",
@@ -3711,7 +3723,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.26.0"
version = "0.27.0"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3725,7 +3737,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.26.0"
version = "0.27.0"
dependencies = [
"anyhow",
"ksni",
@@ -3748,7 +3760,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "pyrowave-sys"
version = "0.26.0"
version = "0.27.0"
dependencies = [
"bindgen",
"cmake",
+9 -1
View File
@@ -4,6 +4,9 @@ members = [
"crates/punktfunk-core",
"crates/punktfunk-host",
"crates/punktfunk-host/vendor/usbip-sim",
# The capability-carrying PyroWave encode worker. A SEPARATE binary by design — never a
# hardlink of, or a subcommand of, punktfunk-host (design/gpu-priority-capability-worker.md).
"crates/punktfunk-encode-worker",
"crates/punktfunk-tray",
"crates/pf-bitstream",
"crates/pf-bitstream/vendor/cros-codecs",
@@ -46,6 +49,11 @@ members = [
exclude = [
"packaging/linux/steam-deck-gadget/usbip-poc",
"clients/android/native/vendor/ndk",
# Bring-your-own-hardware measurement tools. `hid-descriptor-dump` pulls `hidapi`, a C library
# wanting libudev on Linux; `win-input-matrix` is Windows-only and asks the live input stacks
# what they can see. Neither belongs in `cargo build --workspace` or on a CI leg with no pad.
"tools/hid-descriptor-dump",
"tools/win-input-matrix",
]
# ndk 0.9.0 verbatim from crates.io plus ONE visibility change (and two warning fixes — an
@@ -57,7 +65,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.26.0"
version = "0.27.0"
edition = "2021"
rust-version = "1.82"
license = "MIT OR Apache-2.0"
+30 -1
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.25.0"
"version": "0.26.0"
},
"paths": {
"/api/v1/clients": {
@@ -4489,6 +4489,13 @@
],
"description": "The provider's own stable key for this title — the reconcile diff key, so the\nhost-assigned `id` stays stable across reconciles. Present iff `provider` is."
},
"icon": {
"type": [
"string",
"null"
],
"description": "Which brand mark a client should draw for this entry — see [`GameEntry::icon`]. A token\n(`steam`, `heroic`), never bytes and never a URL."
},
"id": {
"type": "string",
"description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)."
@@ -4555,6 +4562,13 @@
"$ref": "#/components/schemas/DetectHint",
"description": "How to recognize this title's process — see [`CustomEntry::detect`]."
},
"icon": {
"type": [
"string",
"null"
],
"description": "Which brand mark to draw — see [`GameEntry::icon`]. Hand-settable for the same reason `role`\nis: an operator's own \"Steam\" tile should be able to look like one."
},
"launch": {
"oneOf": [
{
@@ -5350,6 +5364,14 @@
"art": {
"$ref": "#/components/schemas/Artwork"
},
"icon": {
"type": [
"string",
"null"
],
"description": "Which brand mark to draw for this entry, as a **token** — `steam`, `heroic`, `playnite` —\nnever image bytes and never a URL. See [`is_icon_token`].\n\nIt exists for launcher tiles, which by design ship no cover art: a launcher's own icon is\nsquare, every client cover-crops a 2:3 poster, and the crop turns a mark into a strip — so\nuntil now those tiles were the launcher's name on a flat accent face. The token lets a client\ndraw the real mark from art it already ships, at whatever size its tile happens to be.\n\nA token rather than art on the wire because the host's art proxy serves *raster* bytes only\n([`art::local_art_bytes`] sniffs the container and refuses anything else, SVG very much\nincluded — it is script-capable XML and the console renders art in a browser). Sending the\nname of a mark instead of the mark keeps that refusal intact, keeps the glyph vector at every\ntile size, and lets it take the tile's ink.\n\nOrdinary titles may carry one too — nothing here is launcher-specific — but nothing sets it\nfor them: a game has real cover art, which is strictly better than a brand mark.",
"example": "steam"
},
"id": {
"type": "string",
"description": "Stable, store-qualified id: `steam:<appid>` or `custom:<id>`.",
@@ -6869,6 +6891,13 @@
"type": "string",
"description": "The provider's stable id for this title (the reconcile diff key)."
},
"icon": {
"type": [
"string",
"null"
],
"description": "Which brand mark to draw — see [`GameEntry::icon`]. This is the field a library plugin sets\non its `launchers(cfg)` tiles, and the whole reason the token exists."
},
"launch": {
"oneOf": [
{
@@ -0,0 +1,17 @@
Font Awesome Free — brand icons (steam, xbox in assets/launcher-icons/) are from
Font Awesome Free.
Copyright (c) Fonticons, Inc. (https://fontawesome.com)
Font Awesome Free icons are licensed under the Creative Commons Attribution 4.0
International license (CC BY 4.0), https://creativecommons.org/licenses/by/4.0/.
The icons are redistributed here as monochrome SVG path data with no
modifications beyond color normalization (fill="currentColor").
Per the Font Awesome Free license (https://fontawesome.com/license/free):
"Font Awesome Free is free, open source, and GPL friendly. You can use it for
commercial projects, open source projects, or really almost whatever you want.
Attribution is required by MIT, SIL OFL, and CC BY licenses."
Brand icons are trademarks of their respective owners and are used for
identification purposes only; their use does not imply endorsement.
@@ -0,0 +1,29 @@
Playnite — the `playnite` mark in assets/launcher-icons/ is the Playnite logo from the
Playnite source repository (media/playnite-logo-black.svg).
Copyright (c) 2020 Josef Nemec (https://github.com/JosefNemec/Playnite)
Licensed under the MIT License:
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in the
Software without restriction, including without limitation the rights to use, copy,
modify, merge, publish, distribute, sublicense, and/or sell copies of the Software,
and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Modifications: the colour was normalized to fill="currentColor"; the original viewBox
(0 0 1024 1024) and path geometry are unchanged.
Brand icons are trademarks of their respective owners and are used for identification
purposes only; their use does not imply endorsement.
@@ -0,0 +1,13 @@
Simple Icons — brand icons (lutris, heroic, epic, gog in assets/launcher-icons/) are
from Simple Icons
(https://simpleicons.org, https://github.com/simple-icons/simple-icons).
Upstream slugs: lutris, heroicgameslauncher, epicgames, gogdotcom.
The Simple Icons SVG path data is released under CC0 1.0 Universal (public domain
dedication), https://creativecommons.org/publicdomain/zero/1.0/ — no attribution
required; this notice is provided for provenance.
Brand icons are trademarks of their respective owners and are used for
identification purposes only; their use does not imply endorsement. See
https://github.com/simple-icons/simple-icons/blob/develop/DISCLAIMER.md.
+62
View File
@@ -0,0 +1,62 @@
# Launcher icon masters
The brand marks a **launcher tile** draws — the entries a library plugin publishes with
`role: "launcher"` (design D4), which open Steam Big Picture or Heroic or Playnite rather
than a game. One file per **icon token**, the value a plugin puts in an entry's `icon`
field and every client resolves against the set it ships.
| token | mark | emitted by | source |
|---|---|---|---|
| `steam` | Steam | punktfunk-plugin-steam (Big Picture + desktop) | Font Awesome Free brands (CC BY 4.0) |
| `lutris` | Lutris | punktfunk-plugin-lutris | Simple Icons (CC0 1.0) |
| `heroic` | Heroic Games Launcher | punktfunk-plugin-heroic | Simple Icons (CC0 1.0, slug `heroicgameslauncher`) |
| `playnite` | Playnite | punktfunk-plugin-playnite | JosefNemec/Playnite (MIT) |
| `epic` | Epic Games | punktfunk-plugin-epic — **dormant** | Simple Icons (CC0 1.0, slug `epicgames`) |
| `gog` | GOG.com | punktfunk-plugin-gog — **dormant** | Simple Icons (CC0 1.0, slug `gogdotcom`) |
| `xbox` | Xbox | punktfunk-plugin-xbox — **dormant** | Font Awesome Free brands (CC BY 4.0) |
The last three are **dormant on purpose**: those plugins carry a `launcher` config switch that
is off by default and whose `launcherEntries` returns nothing, because the host has no verified
`launcher_ui` activation for them yet — a tile would be a card that does nothing. Their marks
ship anyway so that turning one on stays the one-line plugin change those plugins promise,
instead of also needing a release of all six clients.
`steam` is the same mark as `assets/os-icons/steam.svg`, generated from that file rather than
re-sourced, so the SteamOS host badge and the Steam launcher tile can never drift apart.
## Why a token and not the icon itself
A plugin sends the **name** of a mark, never its bytes, and never a URL.
The obvious alternative — a plugin ships its own `icon.svg` and the host's art proxy serves it —
is closed by construction, and deliberately: `local_art_bytes` serves what the bytes *are*
(`sniff_image_type`, `crates/punktfunk-host/src/library/art.rs`), and SVG is not on that list
because it is script-capable XML and the web console renders library art in a browser. Widening
that sniff to admit SVG would trade a rendering nicety for a stored-XSS surface.
Sending a token instead keeps that refusal intact and buys three things a proxied image could
not have given us anyway: the glyph stays vector at every tile size a client picks, it takes the
tile's own ink instead of arriving pre-coloured, and it costs no fetch, no cache and no bytes on
a reconcile that is already body-limited.
The cost is that a **third-party** plugin cannot ship a mark no client bundles. Its tile falls
back to the launcher's name on an accent face — exactly what every launcher tile looked like
before this existed — and the fix is a pull request adding the master here.
All files are monochrome (`fill="currentColor"`), original per-icon viewBoxes preserved. Those
viewBoxes are not all square (`0 0 24 24`, `0 0 496 512`, `0 0 1024 1024`), so **a client must
letterbox rather than stretch** — a mark drawn to a square box is a squashed mark.
## Regenerating the per-client derivatives
`bash scripts/gen-launcher-icons.sh [token ...]` turns a master into the three baked forms (GTK
symbolic SVG, Windows PNG, Apple template PDF) and prints the path data for the three clients
that inline it (web console, Android, the in-session console UI). Adding a **new** token also
means adding it to each client's shipped-token list — the script prints that checklist too.
## Licensing
Attribution notices live in `LICENSES/` and are folded into `THIRD-PARTY-NOTICES.txt` by
`scripts/gen-third-party-notices.py`. The marks are trademarks of their respective owners; they
are used here nominatively — to *identify* the launcher a tile opens, the standard practice in
this ecosystem — and imply no affiliation or endorsement.
+2
View File
@@ -0,0 +1,2 @@
<!-- epic — from Simple Icons (CC0 1.0), slug `epicgames`. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M3.537 0C2.165 0 1.66.506 1.66 1.879V18.44a4.262 4.262 0 00.02.433c.031.3.037.59.316.92.027.033.311.245.311.245.153.075.258.13.43.2l8.335 3.491c.433.199.614.276.928.27h.002c.314.006.495-.071.928-.27l8.335-3.492c.172-.07.277-.124.43-.2 0 0 .284-.211.311-.243.28-.33.285-.621.316-.92a4.261 4.261 0 00.02-.434V1.879c0-1.373-.506-1.88-1.878-1.88zm13.366 3.11h.68c1.138 0 1.688.553 1.688 1.696v1.88h-1.374v-1.8c0-.369-.17-.54-.523-.54h-.235c-.367 0-.537.17-.537.539v5.81c0 .369.17.54.537.54h.262c.353 0 .523-.171.523-.54V8.619h1.373v2.143c0 1.144-.562 1.71-1.7 1.71h-.694c-1.138 0-1.7-.566-1.7-1.71V4.82c0-1.144.562-1.709 1.7-1.709zm-12.186.08h3.114v1.274H6.117v2.603h1.648v1.275H6.117v2.774h1.74v1.275h-3.14zm3.816 0h2.198c1.138 0 1.7.564 1.7 1.708v2.445c0 1.144-.562 1.71-1.7 1.71h-.799v3.338h-1.4zm4.53 0h1.4v9.201h-1.4zm-3.13 1.235v3.392h.575c.354 0 .523-.171.523-.54V4.965c0-.368-.17-.54-.523-.54zm-3.74 10.147a1.708 1.708 0 01.591.108 1.745 1.745 0 01.49.299l-.452.546a1.247 1.247 0 00-.308-.195.91.91 0 00-.363-.068.658.658 0 00-.28.06.703.703 0 00-.224.163.783.783 0 00-.151.243.799.799 0 00-.056.299v.008a.852.852 0 00.056.31.7.7 0 00.157.245.736.736 0 00.238.16.774.774 0 00.303.058.79.79 0 00.445-.116v-.339h-.548v-.565H7.37v1.255a2.019 2.019 0 01-.524.307 1.789 1.789 0 01-.683.123 1.642 1.642 0 01-.602-.107 1.46 1.46 0 01-.478-.3 1.371 1.371 0 01-.318-.455 1.438 1.438 0 01-.115-.58v-.008a1.426 1.426 0 01.113-.57 1.449 1.449 0 01.312-.46 1.418 1.418 0 01.474-.309 1.58 1.58 0 01.598-.111 1.708 1.708 0 01.045 0zm11.963.008a2.006 2.006 0 01.612.094 1.61 1.61 0 01.507.277l-.386.546a1.562 1.562 0 00-.39-.205 1.178 1.178 0 00-.388-.07.347.347 0 00-.208.052.154.154 0 00-.07.127v.008a.158.158 0 00.022.084.198.198 0 00.076.066.831.831 0 00.147.06c.062.02.14.04.236.061a3.389 3.389 0 01.43.122 1.292 1.292 0 01.328.17.678.678 0 01.207.24.739.739 0 01.071.337v.008a.865.865 0 01-.081.382.82.82 0 01-.229.285 1.032 1.032 0 01-.353.18 1.606 1.606 0 01-.46.061 2.16 2.16 0 01-.71-.116 1.718 1.718 0 01-.593-.346l.43-.514c.277.223.578.335.9.335a.457.457 0 00.236-.05.157.157 0 00.082-.142v-.008a.15.15 0 00-.02-.077.204.204 0 00-.073-.066.753.753 0 00-.143-.062 2.45 2.45 0 00-.233-.062 5.036 5.036 0 01-.413-.113 1.26 1.26 0 01-.331-.16.72.72 0 01-.222-.243.73.73 0 01-.082-.36v-.008a.863.863 0 01.074-.359.794.794 0 01.214-.283 1.007 1.007 0 01.34-.185 1.423 1.423 0 01.448-.066 2.006 2.006 0 01.025 0zm-9.358.025h.742l1.183 2.81h-.825l-.203-.499H8.623l-.198.498h-.81zm2.197.02h.814l.663 1.08.663-1.08h.814v2.79h-.766v-1.602l-.711 1.091h-.016l-.707-1.083v1.593h-.754zm3.469 0h2.235v.658h-1.473v.422h1.334v.61h-1.334v.442h1.493v.658h-2.255zm-5.3.897l-.315.793h.624zm-1.145 5.19h8.014l-4.09 1.348z"/></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

+2
View File
@@ -0,0 +1,2 @@
<!-- gog — from Simple Icons (CC0 1.0), slug `gogdotcom`. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M7.15 15.24H4.36a.4.4 0 0 0-.4.4v2c0 .21.18.4.4.4h2.8v1.32h-3.5c-.56 0-1.02-.46-1.02-1.03v-3.39c0-.56.46-1.02 1.03-1.02h3.48v1.32zM8.16 11.54c0 .58-.47 1.05-1.05 1.05H2.63v-1.35h3.78a.4.4 0 0 0 .4-.4V6.39a.4.4 0 0 0-.4-.4H4.39a.4.4 0 0 0-.41.4v2.02c0 .23.18.4.4.4H6v1.35H3.68c-.58 0-1.05-.46-1.05-1.04V5.68c0-.57.47-1.04 1.05-1.04H7.1c.58 0 1.05.47 1.05 1.04v5.86zM21.36 19.36h-1.32v-4.12h-.93a.4.4 0 0 0-.4.4v3.72h-1.33v-4.12h-.93a.4.4 0 0 0-.4.4v3.72h-1.33v-4.42c0-.56.46-1.02 1.03-1.02h5.61v5.44zM21.37 11.54c0 .58-.47 1.05-1.05 1.05h-4.48v-1.35h3.78a.4.4 0 0 0 .4-.4V6.39a.4.4 0 0 0-.4-.4h-2.03a.4.4 0 0 0-.4.4v2.02c0 .23.18.4.4.4h1.62v1.35H16.9c-.58 0-1.05-.46-1.05-1.04V5.68c0-.57.47-1.04 1.05-1.04h3.43c.58 0 1.05.47 1.05 1.04v5.86zM13.72 4.64h-3.44c-.58 0-1.04.47-1.04 1.04v3.44c0 .58.46 1.04 1.04 1.04h3.44c.57 0 1.04-.46 1.04-1.04V5.68c0-.57-.47-1.04-1.04-1.04m-.3 1.75v2.02a.4.4 0 0 1-.4.4h-2.03a.4.4 0 0 1-.4-.4V6.4c0-.22.17-.4.4-.4H13c.23 0 .4.18.4.4zM12.63 13.92H9.24c-.57 0-1.03.46-1.03 1.02v3.39c0 .57.46 1.03 1.03 1.03h3.39c.57 0 1.03-.46 1.03-1.03v-3.39c0-.56-.46-1.02-1.03-1.02m-.3 1.72v2a.4.4 0 0 1-.4.4v-.01H9.94a.4.4 0 0 1-.4-.4v-1.99c0-.22.18-.4.4-.4h2c.22 0 .4.18.4.4zM23.49 1.1a1.74 1.74 0 0 0-1.24-.52H1.75A1.74 1.74 0 0 0 0 2.33v19.34a1.74 1.74 0 0 0 1.75 1.75h20.5A1.74 1.74 0 0 0 24 21.67V2.33c0-.48-.2-.92-.51-1.24m0 20.58a1.23 1.23 0 0 1-1.24 1.24H1.75A1.23 1.23 0 0 1 .5 21.67V2.33a1.23 1.23 0 0 1 1.24-1.24h20.5a1.24 1.24 0 0 1 1.24 1.24v19.34z"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+2
View File
@@ -0,0 +1,2 @@
<!-- heroic — from Simple Icons (CC0 1.0), slug `heroicgameslauncher`. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M11.999 0 11.997 0a.891.891 0 0 0-.36.075C8.964 1.253 6.29 2.434 3.618 3.613A.893.893 0 0 0 3.1 4.619l3.146 14.646c.043.197.15.375.307.504l4.88 4.027a.895.895 0 0 0 1.131.006l5-4.031a.895.895 0 0 0 .315-.516L20.9 4.614a.895.895 0 0 0-.515-1L12.358.074A.892.892 0 0 0 12 0zm0 .35v.003c.114 0 .228.023.334.07l7.42 3.27a.827.827 0 0 1 .476.924l-2.793 13.535a.83.83 0 0 1-.289.478l-4.623 3.725a.826.826 0 0 1-1.045-.006l-4.513-3.723a.829.829 0 0 1-.281-.465L3.775 4.622a.83.83 0 0 1 .476-.931L11.665.42a.832.832 0 0 1 .334-.07zm-.045 1.954L10.28 5.202h-.002l1.211 11.301.512.409.512-.409 1.117-11.3zM9.003 16.261l-.584 1.068.584 1.07 2.295-.38.47-.69-.47-.671zm5.996 0-2.295.397-.47.671.47.69 2.295.38.584-1.07zm-2.998 1.488-.51.444-.281 2.168.789.55.793-.55-.295-2.168z"/></svg>

After

Width:  |  Height:  |  Size: 957 B

+2
View File
@@ -0,0 +1,2 @@
<!-- lutris — from Simple Icons (CC0 1.0), slug `lutris`. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="m21.231 18.89.001-.002c-1.293 3.243-5.218 5.232-9.447 5.105C5.3 23.993 0 18.48 0 11.906S5.276.001 11.785.001c1.793 0 3.493.406 5.015 1.13.081-.177.271-.544.451-.557.238-.017.374.137.526.309.154.172.46.429.46.429s1.393-.481 2.955.377c1.563.858 1.783 1.116 2.09 1.716.152.301.195.829.2 1.282a.796.796 0 0 0-.07-.003c-.496 0-.96.455-.96 1.08 0 .263.082.496.215.678l-.01.007a1.505 1.505 0 0 0-.132.01 18.704 18.704 0 0 0-.389-.142 2.53 2.53 0 0 1-.82-.472 1.402 1.402 0 0 0-1.196-2.112c-.383 0-.73.156-.982.41-.472-.271-1.174-.482-2.527-.565l-.407-.011c-2.282.012-3.611.279-5.979 1.301-.603.283-1.206.615-1.785 1.001-.423.3-.639.67-.709 1.137a1.326 1.326 0 0 0 1.23 1.373h.042c1.27.06 2.039 1.99 2.063 2.497.004.05.004.023.003.08-.032.727-.37 1.267-1.088 1.246a1.231 1.231 0 0 1-.976-.494c-.063-.077-.103-.172-.159-.254-.666-1.081-1.732-1.36-2.771-1.523-.438-.068-1.073-.122-1.31.25a8.28 8.28 0 0 0-.577 3.063c-.02 5.036 4.041 9.118 9.026 9.118 2.575 0 5.349-.952 6.993-2.7l-.035.03c-1.772 1.473-4.66 1.941-6.027 1.941-4.302 0-7.818-3.232-7.818-7.578 0-1.276.288-2.396.814-3.36.495.183.947.483 1.28 1.022a.24.24 0 0 0 .013.021c.064.092.111.197.182.284.424.524.881.658 1.342.68h.01c.43.013.768-.12 1.024-.342.347-.3.55-.79.577-1.382v-.014c.002-.085 0-.053-.004-.112-.024-.376-.333-1.318-.906-2.027-.266-.331-.587-.607-.95-.774l.12-.074c.756-.457 2.364-.977 4.592-.638 1.13.173 2.055.419 3.483.879 1.657.534 2.579 1.279 3.854 1.427.15.017.301.018.45.003.41 1.129.634 2.35.634 3.621 0 2.068-.59 3.995-1.611 5.62zm1.947-12.274s-.115.201-.364.322c-.103.05-.282-.075-.45.1-.359.726.516 1.332.923 1.315.408-.017.73-.432.712-.793-.017-.558-.82-.944-.82-.944zm.234-1.432c.255 0 .462.26.462.58 0 .32-.207.58-.462.58-.254 0-.46-.26-.46-.58 0-.32.206-.58.46-.58zm-3.292-.951c.492 0 .89.403.89.9a.895.895 0 0 1-.89.898.895.895 0 0 1-.89-.899c0-.496.399-.899.89-.899z"/></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+2
View File
@@ -0,0 +1,2 @@
<!-- playnite — from JosefNemec/Playnite media/playnite-logo-black.svg (MIT). See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" fill="currentColor"><path d="M966.686,623.899c-9.773-81.666-29.323-161.25-54.514-239.447c-13.759-42.709-30.419-84.189-56.091-121.452 c-31.701-46.014-74.789-72.958-130.812-78.579c-29.631-2.973-57.785,4.118-85.677,12.35 c-61.172,18.056-123.359,25.124-186.493,14.903c-30.919-5.006-61.308-13.526-91.743-21.225 c-76.445-19.338-145.323,4.995-191.165,69.261c-11.441,16.04-21.194,33.543-29.78,51.312 c-25.091,51.925-40.443,107.249-54.53,162.924c-18.822,74.393-33.019,149.491-33.664,226.571c0,7.184-0.342,14.386,0.061,21.547 c1.557,27.727,4.354,55.289,16.045,80.97c15.334,33.68,45.905,46.725,79.471,31.198c18.291-8.461,36.293-19.857,50.766-33.743 c24.597-23.598,46.616-49.934,69.125-75.64c17.934-20.481,39.086-35.301,66.115-40.203c15.779-2.862,31.802-6.006,47.736-6.118 c87.888-0.62,175.783-0.602,263.673-0.278c51.4,0.189,93.314,19.382,124.091,62.134c12.518,17.388,27.83,32.889,42.78,48.371 c18.598,19.259,38.974,36.431,64.412,46.39c32.967,12.907,62.547,1.677,77.882-30.198c3.965-8.242,6.963-17.122,9.155-26.017 C976.198,727.534,972.874,675.607,966.686,623.899z M315.471,527.643c-44.289,0.213-80.733-36.32-80.847-81.045 c-0.115-45.048,35.472-81.194,80.197-81.458c44.521-0.263,80.718,35.897,80.884,80.801 C395.871,490.671,359.773,527.429,315.471,527.643z M708.857,319.301c21.859,0.06,39.486,17.884,39.471,39.91 c-0.015,22.133-17.489,39.677-39.523,39.682c-22.045,0.005-39.456-17.53-39.444-39.724 C669.372,337.125,687.089,319.241,708.857,319.301z M622.269,486.36c-21.542,0.085-39.7-18.08-39.808-39.822 c-0.108-21.888,17.617-39.622,39.62-39.641c22.066-0.018,39.759,17.552,39.718,39.442 C661.758,468.205,643.909,486.275,622.269,486.36z M708.967,573.333c-21.823,0.096-39.537-17.668-39.611-39.721 c-0.074-22.079,17.523-39.992,39.338-40.044c21.715-0.052,39.597,17.908,39.645,39.816 C748.386,555.477,730.883,573.237,708.967,573.333z M795.752,486.362c-21.764,0.155-39.671-17.882-39.651-39.938 c0.021-22.15,17.628-39.639,39.793-39.525c22.091,0.114,39.527,17.993,39.155,40.152 C834.686,468.733,817.216,486.209,795.752,486.362z"/></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+2
View File
@@ -0,0 +1,2 @@
<!-- steam — from Font Awesome Free 5 brands (CC BY 4.0); the same mark as assets/os-icons/steam.svg. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512" fill="currentColor"><path d="M496 256c0 137-111.2 248-248.4 248-113.8 0-209.6-76.3-239-180.4l95.2 39.3c6.4 32.1 34.9 56.4 68.9 56.4 39.2 0 71.9-32.4 70.2-73.5l84.5-60.2c52.1 1.3 95.8-40.9 95.8-93.5 0-51.6-42-93.5-93.7-93.5s-93.7 42-93.7 93.5v1.2L176.6 279c-15.5-.9-30.7 3.4-43.5 12.1L0 236.1C10.2 108.4 117.1 8 247.6 8 384.8 8 496 119 496 256zM155.7 384.3l-30.5-12.6a52.79 52.79 0 0 0 27.2 25.8c26.9 11.2 57.8-1.6 69-28.4 5.4-13 5.5-27.3.1-40.3-5.4-13-15.5-23.2-28.5-28.6-12.9-5.4-26.7-5.2-38.9-.6l31.5 13c19.8 8.2 29.2 30.9 20.9 50.7-8.3 19.9-31 29.2-50.8 21zm173.8-129.9c-34.4 0-62.4-28-62.4-62.3s28-62.3 62.4-62.3 62.4 28 62.4 62.3-27.9 62.3-62.4 62.3zm.1-15.6c25.9 0 46.9-21 46.9-46.8 0-25.9-21-46.8-46.9-46.8s-46.9 21-46.9 46.8c.1 25.8 21.1 46.8 46.9 46.8z"/></svg>

After

Width:  |  Height:  |  Size: 956 B

+2
View File
@@ -0,0 +1,2 @@
<!-- xbox — from Font Awesome Free 6 brands (CC BY 4.0), `fa-xbox`. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="currentColor"><path d="M369.9 318.2c44.3 54.3 64.7 98.8 54.4 118.7-7.9 15.1-56.7 44.6-92.6 55.9-29.6 9.3-68.4 13.3-100.4 10.2-38.2-3.7-76.9-17.4-110.1-39-27.9-18.2-34.2-25.7-34.2-40.6 0-29.9 32.9-82.3 89.2-142.1 32-33.9 76.5-73.7 81.4-72.6 9.4 2.1 84.3 75.1 112.3 109.5zM188.6 143.8c-29.7-26.9-58.1-53.9-86.4-63.4-15.2-5.1-16.3-4.8-28.7 8.1-29.2 30.4-53.5 79.7-60.3 122.4-5.4 34.2-6.1 43.8-4.2 60.5 5.6 50.5 17.3 85.4 40.5 120.9 9.5 14.6 12.1 17.3 9.3 9.9-4.2-11-.3-37.5 9.5-64 14.3-39 53.9-112.9 120.3-194.4zm311.6 63.5c-16.9-80-67.5-130.3-74.6-130.3-7.3 0-24.2 6.5-36 13.9-23.3 14.5-41 31.4-64.3 52.8 42.4 53.3 102.2 139.4 122.9 202.3 6.8 20.7 9.7 41.1 7.4 52.3-1.7 8.5-1.7 8.5 1.4 4.6 6.1-7.7 19.9-31.3 25.4-43.5 7.4-16.2 15-40.2 18.6-58.7 4.3-22.5 3.9-70.8-.8-93.4zM141.3 43c47.7-2.5 109.7 34.5 114.3 35.4 .7 .1 10.4-4.2 21.6-9.7 63.9-31.1 94-25.8 107.4-25.2-63.9-39.3-152.7-50-233.9-11.7-23.4 11.1-24 11.9-9.4 11.2z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+4
View File
@@ -144,6 +144,10 @@ dependencies {
testImplementation("androidx.compose.ui:ui-test-junit4")
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
// set whose every method throws "Stub!", so a plain JVM unit test cannot parse with it. Same
// dependency, same reason, as the kit module's deeplink-vectors test.
testImplementation("org.json:json:20250107")
testImplementation("org.robolectric:robolectric:4.16.1")
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.64.0")
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.64.0")
@@ -0,0 +1,350 @@
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
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import io.unom.punktfunk.models.PendingTrust
// The prompts that say the SAME thing in both interfaces.
//
// Every one of these existed twice — a Material `AlertDialog` in ConnectDialogs.kt and a console
// glass card in GamepadDialogs.kt — with the two copies maintained by hand. Predictably they
// drifted, and always in the direction of the console losing something: "Pair with PIN…" lost its
// ellipsis, "if no prompt appears when you tap Allow" became "after Allow", and the speed test
// stopped telling console users which layer Apply would write to at all.
//
// What is shared here is the DESCRIPTION of a prompt — a title, a list of [DialogAction]s and a
// body — and what stays per-interface is only how that description is drawn. That split is the
// whole point: a copy change now lands in both places because there is only one place.
//
// ⚠ Deliberately NOT unified, and they belong apart: the PIN ceremony (a numeric keyboard field
// and an editable device name on touch; four D-pad digit slots on the console — different input
// models, not different skins), Add/Edit Host (a bottom sheet and a full screen with its own
// on-screen keyboard), and the host action list (an anchored dropdown vs a modal stack, and the
// touch one grows a row per profile).
/**
* One prompt, drawn as whichever interface is running.
*
* [actions] is ordered PRIMARY FIRST the console stacks them in that order with the cursor on
* the first, and the touch renderer lifts that same first action into `confirmButton` and lays the
* rest out beside it. One order, two idioms, no per-dialog bookkeeping.
*
* The two renderers cannot be one tree: an `AlertDialog` composes into its own platform window
* while [ConsoleModal] is a plain Box in the calling tree which is also why the console one
* needs a `BackHandler` and the caller's `navActive` gate while the touch one needs neither.
*/
@Composable
fun PunktfunkDialog(
gamepadUi: Boolean,
title: String,
onDismiss: () -> Unit,
actions: List<DialogAction>,
/**
* False pins the prompt open against a stray tap outside it for a dialog sitting over work
* in flight, where a mis-tap would abandon it. Console-side there is no outside to tap, so
* this only reaches the touch renderer.
*/
dismissOnOutsideTap: Boolean = true,
body: @Composable ColumnScope.() -> Unit,
) {
if (gamepadUi) {
GamepadDialog(title = title, onDismiss = onDismiss, actions = actions, body = body)
return
}
val primary = actions.firstOrNull { it.primary } ?: actions.firstOrNull()
val rest = actions.filter { it !== primary }
AlertDialog(
onDismissRequest = onDismiss,
properties = DialogProperties(dismissOnClickOutside = dismissOnOutsideTap),
title = { Text(title) },
text = { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { body() } },
confirmButton = {
primary?.let { a ->
TextButton(onClick = a.onClick, enabled = a.enabled) { Text(a.label) }
}
},
dismissButton = {
if (rest.isNotEmpty()) {
Row {
rest.forEach { a ->
TextButton(onClick = a.onClick, enabled = a.enabled) { Text(a.label) }
}
}
}
},
)
}
/** A prompt's body paragraph, dimmed to sit under the title in either interface. */
@Composable
private fun PromptText(text: String, gamepadUi: Boolean) {
val ink = LocalGamepadInk.current
Text(
text,
style = MaterialTheme.typography.bodyMedium,
color = if (gamepadUi) ink.fg(0.7f) else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
/** First connection to a host that advertised pair=optional: offer TOFU, but pitch PIN pairing. */
@Composable
fun TrustNewHostPrompt(
gamepadUi: Boolean,
pt: PendingTrust,
onTrust: () -> Unit,
onPairInstead: () -> Unit,
onDismiss: () -> Unit,
) {
PunktfunkDialog(
gamepadUi = gamepadUi,
title = "Trust this host?",
onDismiss = onDismiss,
actions = listOf(
DialogAction("Trust (TOFU)", primary = true, onClick = onTrust),
DialogAction("Pair with PIN…", onClick = onPairInstead),
DialogAction("Cancel", onClick = onDismiss),
),
) {
PromptText("First connection to ${pt.host}:${pt.port}.", gamepadUi)
pt.advertisedFp?.let { PromptText("Fingerprint ${it.take(16)}", gamepadUi) }
PromptText(
"This host allows trust-on-first-use, but that can't tell an impostor from the real " +
"host. Pairing with a PIN is stronger — it proves both sides.",
gamepadUi,
)
}
}
/** The pinned fingerprint no longer matches — force re-pairing (never a silent re-trust). */
@Composable
fun FingerprintChangedPrompt(
gamepadUi: Boolean,
pt: PendingTrust,
onRepair: () -> Unit,
onDismiss: () -> Unit,
) {
PunktfunkDialog(
gamepadUi = gamepadUi,
title = "Host identity changed",
onDismiss = onDismiss,
actions = listOf(
DialogAction("Re-pair", primary = true, onClick = onRepair),
DialogAction("Cancel", onClick = onDismiss),
),
) {
PromptText(
"The pinned fingerprint for ${pt.host} no longer matches what it now advertises. " +
"This can mean a host reinstall — or an impostor. Re-pair with the host's PIN to " +
"continue.",
gamepadUi,
)
}
}
/**
* A fresh pair=required (or manual/unknown-policy) host: offer the two ways in. "Request access" is
* the no-PIN path connect and wait for the operator to click Approve in the host's console;
* "Use a PIN…" switches to the SPAKE2 ceremony.
*/
@Composable
fun RequestAccessPrompt(
gamepadUi: Boolean,
pt: PendingTrust,
onRequestAccess: () -> Unit,
onUsePin: () -> Unit,
onDismiss: () -> Unit,
) {
PunktfunkDialog(
gamepadUi = gamepadUi,
title = "Pairing required",
onDismiss = onDismiss,
actions = listOf(
DialogAction("Request access", primary = true, onClick = onRequestAccess),
DialogAction("Use a PIN…", onClick = onUsePin),
DialogAction("Cancel", onClick = onDismiss),
),
) {
PromptText("${pt.host}:${pt.port} requires pairing before it will stream.", gamepadUi)
PromptText(
"Request access and approve this device in the host's console (or web UI) — no PIN " +
"needed. Or pair with the 4-digit PIN the host displays.",
gamepadUi,
)
}
}
/**
* The no-PIN "request access" wait: the connect is parked on the host until the operator approves
* this device. Cancel returns the UI immediately the caller trips the per-attempt flag so a late
* approval is torn down silently (see ConnectScreen.requestAccess) and resumes discovery.
*
* Outside taps are ignored: a connect is parked on the host, and a stray tap beside the card is not
* a decision to abandon it.
*/
@Composable
fun AwaitingApprovalPrompt(gamepadUi: Boolean, hostLabel: String, onCancel: () -> Unit) {
val ink = LocalGamepadInk.current
PunktfunkDialog(
gamepadUi = gamepadUi,
title = "Waiting for approval",
onDismiss = onCancel,
actions = listOf(DialogAction("Cancel", primary = true, onClick = onCancel)),
dismissOnOutsideTap = false,
) {
val deviceName = Build.MODEL ?: "this device"
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
color = if (gamepadUi) ink.fg else MaterialTheme.colorScheme.primary,
)
Text(
"Approve this device on $hostLabel.",
color = if (gamepadUi) ink.fg else MaterialTheme.colorScheme.onSurface,
)
}
PromptText(
"Open the host's console (or web UI) and approve “$deviceName”. It connects " +
"automatically once you approve — no PIN needed.",
gamepadUi,
)
}
}
/**
* Android 17+ Local Network Protection rationale: ACCESS_LOCAL_NETWORK was denied, so discovery and
* every connect are dead offer the system prompt again and a settings deep link (a permanently-
* denied request returns instantly without ever showing the prompt, so "Allow" alone isn't enough).
*/
@Composable
fun LocalNetworkPrompt(
gamepadUi: Boolean,
onAllow: () -> Unit,
onSettings: () -> Unit,
onDismiss: () -> Unit,
) {
PunktfunkDialog(
gamepadUi = gamepadUi,
title = "Allow local network access",
onDismiss = onDismiss,
actions = listOf(
DialogAction("Allow", primary = true, onClick = onAllow),
DialogAction("Open settings", onClick = onSettings),
DialogAction("Not now", onClick = onDismiss),
),
) {
PromptText(
"Android blocks Punktfunk from talking to devices on your network, so it can't find " +
"or reach any host until you allow it.",
gamepadUi,
)
PromptText(
"If no prompt appears after you allow it, enable “Nearby devices” for Punktfunk in " +
"system settings.",
gamepadUi,
)
}
}
/**
* The link measurement and what to do with the result. A TV box on a powerline adapter is exactly
* the machine whose link is worth measuring, so this belongs on the couch surface too and so
* does [speedTestTargetNote], which the console used to omit, leaving a console user to guess
* which layer Apply would write to.
*/
@Composable
fun SpeedTestPrompt(
gamepadUi: Boolean,
hostName: String,
target: SpeedTestTarget,
phase: SpeedTestPhase,
onApply: (toProfile: Boolean) -> Unit,
onDismiss: () -> Unit,
) {
val ink = LocalGamepadInk.current
val done = phase as? SpeedTestPhase.Done
PunktfunkDialog(
gamepadUi = gamepadUi,
title = "Network speed test",
onDismiss = onDismiss,
// Measuring bursts traffic for two seconds; a tap outside must not abandon it midway.
dismissOnOutsideTap = phase !is SpeedTestPhase.Measuring,
actions = buildList {
if (done != null) {
add(
DialogAction(
when (target) {
SpeedTestTarget.Global -> "Apply"
is SpeedTestTarget.Profile -> "Apply to “${target.profile.name}"
is SpeedTestTarget.Ask -> "Set in “${target.profile.name}"
},
primary = true,
) { onApply(true) },
)
if (target is SpeedTestTarget.Ask) {
add(DialogAction("Set as default") { onApply(false) })
}
}
add(DialogAction("Close", primary = done == null, onClick = onDismiss))
},
) {
PromptText(hostName, gamepadUi)
when (phase) {
SpeedTestPhase.Connecting -> PromptText("Connecting…", gamepadUi)
SpeedTestPhase.Measuring ->
PromptText(
"Measuring — the host is bursting test traffic for two seconds.",
gamepadUi,
)
is SpeedTestPhase.Failed -> Text(
phase.message,
style = MaterialTheme.typography.bodyMedium,
color = if (gamepadUi) ink.danger else MaterialTheme.colorScheme.error,
)
is SpeedTestPhase.Done -> {
Text(
"%.0f Mbit/s measured · %.1f %% loss".format(phase.measuredMbps, phase.lossPct),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = if (gamepadUi) ink.fg else MaterialTheme.colorScheme.onSurface,
)
PromptText(
"Recommended bitrate: %.0f Mbit/s".format(phase.recommendedMbps),
gamepadUi,
)
PromptText(speedTestTargetNote(target), gamepadUi)
}
}
}
}
/** One line saying which layer an Apply will write to, and why that one. */
private fun speedTestTargetNote(target: SpeedTestTarget): String = when (target) {
SpeedTestTarget.Global ->
"This host uses the default settings, so the bitrate goes there."
is SpeedTestTarget.Profile ->
"This host streams with “${target.profile.name}”, which sets its own bitrate — " +
"that override is what it actually reads."
is SpeedTestTarget.Ask ->
"This host streams with “${target.profile.name}”, which currently inherits the default " +
"bitrate. Setting it in the profile affects only this host's profile; setting it as " +
"the default affects everything that inherits it."
}
@@ -1,9 +1,6 @@
package io.unom.punktfunk
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -14,6 +11,9 @@ import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
@@ -21,14 +21,17 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
@@ -37,8 +40,10 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
@@ -52,6 +57,8 @@ import io.unom.punktfunk.kit.SessionEndReason
import io.unom.punktfunk.kit.security.KnownHostStore
import io.unom.punktfunk.models.ActiveSession
import io.unom.punktfunk.models.Tab
import kotlin.math.roundToInt
import kotlinx.coroutines.launch
@Composable
fun App(forceGamepadUi: Boolean = false) {
@@ -211,8 +218,9 @@ fun App(forceGamepadUi: Boolean = false) {
Spacer(Modifier.weight(1f))
}
// The rail handles its own insets; the content pane insets itself (the screens
// don't, since they used to rely on the Scaffold's padding).
Box(Modifier.weight(1f).fillMaxHeight().systemBarsPadding()) { tabContent(true) }
// don't, since they used to rely on the Scaffold's padding). Cutout included:
// a tablet in landscape puts its punch on exactly this pane's leading edge.
Box(Modifier.weight(1f).fillMaxHeight().consoleSafeArea()) { tabContent(true) }
}
} else {
Scaffold(
@@ -245,8 +253,21 @@ fun App(forceGamepadUi: Boolean = false) {
*/
val LocalGamepadPalette = compositionLocalOf { GamepadPalette.named("violet") }
/** Which console screen the gamepad shell is showing. */
private enum class GamepadScreen { Home, Settings, Library }
/**
* Which console screen the gamepad shell is showing, and how deep it sits Home is the root, and
* everything reachable from it is one level in. The DEPTH is what decides whether a change is a
* push or a pop, and therefore which way the screens travel.
*/
private enum class GamepadScreen(val depth: Int) {
Home(0),
Settings(1),
Library(1),
// Reached FROM Settings, not from Home, so they sit a level deeper again — which is precisely
// what makes Settings → Controllers travel like a push and the way back like a pop. Give one of
// these depth 1 and the transition would read as a sideways swap between two peers.
Controllers(2),
Licenses(2),
}
/**
* The console (gamepad) shell the Android mirror of the Apple client's ContentView gamepad branch:
@@ -270,6 +291,12 @@ fun GamepadShell(
val context = LocalContext.current
var screen by remember { mutableStateOf(GamepadScreen.Home) }
var libraryHost by remember { mutableStateOf<io.unom.punktfunk.kit.security.KnownHost?>(null) }
// Where the settings screen was when a sub-screen took over. The shell's AnimatedContent
// discards a screen's `remember`s the moment it stops being the target, so a trip out to the
// Controllers view and back would otherwise land on the Stream tab's first row — the couch
// equivalent of a browser losing your scroll position on Back. Held here because this is the
// only thing that outlives the screen.
var settingsPlace by remember { mutableStateOf<GpSettingsPlace?>(null) }
// Consume the "come back to this library" intent once, on entry. Keyed on the id so a second
// game exit re-fires it; the parent clears it immediately, so a manual Back stays backed out.
@@ -297,12 +324,50 @@ fun GamepadShell(
val fitDensity = screenWidthPx / CONSOLE_TV_MIN_WIDTH_DP
val consoleDensity = if (isTv && fitDensity < baseDensity.density) fitDensity else baseDensity.density
// The console's screen transition, and the desktop console's contract rather than a plain
// cross-fade (see ConsoleMotion for the numbers and where they come from): a PUSH slides the
// incoming screen up out of a fade while the outgoing one recedes; a POP runs it backwards, the
// leaving screen sliding down and the revealed one growing back. Direction comes from the
// screens' nav DEPTH, so Settings → Home pops even though nothing tracks a stack.
//
// Each slot's controller nav is gated on being the CURRENT target (`s == screen`), so mid-
// transition only the incoming screen drives the pad. All screens pin their legend at the same
// ConsoleLegendInset, so it reads as fixed while the content behind it moves.
val animated = animationsEnabled()
CompositionLocalProvider(LocalDensity provides Density(consoleDensity, baseDensity.fontScale)) {
// Cross-fade between console screens so switches are smooth. Each slot's controller nav is gated
// on being the CURRENT target (`s == screen`), so during the fade only the incoming screen drives
// the pad. All screens pin their legend at the same ConsoleLegendInset, so it reads as fixed while
// the content behind it fades.
Crossfade(targetState = screen, animationSpec = tween(240), label = "consoleScreen") { s ->
// Measured INSIDE the console's own density, not the device's: on a TV the console UI runs at a
// reduced density to shrink the 10-foot layout, and a slide sized in device pixels would travel
// further than every other dp in the same animation.
val slidePx = with(LocalDensity.current) { ConsoleMotion.PUSH_SLIDE.toPx() }.roundToInt()
AnimatedContent(
targetState = screen,
transitionSpec = {
if (!animated) {
// Reduce-motion: no travel, no scale — just a fast cross-fade, the same courtesy
// the frozen backdrop pays.
fadeIn(tween(ConsoleMotion.REDUCED_MS)) togetherWith
fadeOut(tween(ConsoleMotion.REDUCED_MS))
} else if (targetState.depth > initialState.depth) {
(
fadeIn(ConsoleMotion.ease()) +
slideInVertically(ConsoleMotion.ease()) { slidePx } +
scaleIn(ConsoleMotion.ease(), initialScale = ConsoleMotion.ENTER_SCALE)
) togetherWith (
fadeOut(ConsoleMotion.ease()) +
scaleOut(ConsoleMotion.ease(), targetScale = ConsoleMotion.EXIT_SCALE)
)
} else {
(
fadeIn(ConsoleMotion.ease(), initialAlpha = ConsoleMotion.REVEAL_ALPHA) +
scaleIn(ConsoleMotion.ease(), initialScale = ConsoleMotion.EXIT_SCALE)
) togetherWith (
fadeOut(ConsoleMotion.ease()) +
slideOutVertically(ConsoleMotion.ease()) { slidePx }
)
}
},
label = "consoleScreen",
) { s ->
when (s) {
GamepadScreen.Home -> ConnectScreen(
settings = settings,
@@ -318,7 +383,23 @@ fun GamepadShell(
GamepadScreen.Settings -> GamepadSettingsScreen(
initial = settings,
onChange = onSettingsChange,
onBack = { screen = GamepadScreen.Home },
// Leaving for HOME forgets the place: coming back in from the carousel should start
// at the top of the first section, exactly as it always has. Only a sub-screen's
// Back is a return.
onBack = { screen = GamepadScreen.Home; settingsPlace = null },
navActive = s == screen,
resume = settingsPlace,
onPlace = { settingsPlace = it },
onOpenControllers = { screen = GamepadScreen.Controllers },
onOpenLicenses = { screen = GamepadScreen.Licenses },
)
GamepadScreen.Controllers -> ConsoleControllersScreen(
gamepadSetting = settings.gamepad,
onBack = { screen = GamepadScreen.Settings },
navActive = s == screen,
)
GamepadScreen.Licenses -> ConsoleLicensesScreen(
onBack = { screen = GamepadScreen.Settings },
navActive = s == screen,
)
GamepadScreen.Library -> libraryHost?.let { host ->
@@ -337,3 +418,128 @@ fun GamepadShell(
/** Minimum effective dp width the console UI targets on a TV (bigger → the 10-foot UI shrinks). */
private const val CONSOLE_TV_MIN_WIDTH_DP = 1180f
// --- Showing a TOUCH-written screen on the console's field -------------------------------------
//
// Two screens (Controllers, Licenses) exist once and are shown in both interfaces. They live beside
// the shell rather than in `GamepadChrome.kt` because they are about the SHELL's job — putting a
// screen that was written for one interface onto the other's field — rather than about the console's
// own material.
/**
* Re-inks a screen written against the TOUCH theme so it can be shown on the console's field.
*
* `ControllersScreen` alone pulls `MaterialTheme.colorScheme` at 27 explicit sites, plus implicitly
* through every `OutlinedCard`, `Switch`, `OutlinedButton` and `LinearProgressIndicator` it draws.
* Dropped into the shell those keep the touch palette light-grey body text with no background of
* its own, which over the six PALE console palettes (`GamepadPalette`, `light = true`) is grey on
* pastel: technically painted, in practice unreadable. That is the same class of bug as the console
* dialogs that spent a release rendering dark ink on a dark card.
*
* The fix is deliberately ONE derived colour scheme rather than 27 call-site branches:
* * a call-site branch cannot reach the IMPLICIT pulls at all a `Switch`'s track and an
* `OutlinedCard`'s border are resolved inside Material, not here;
* * two colours per site is exactly the shape that drifts, and it would leave the touch screen
* carrying console vocabulary it has no use for.
*
* The alternative give the console presentation an opaque backdrop and let the touch theme read on
* its own ground was rejected because it splits the screen's material in two: an opaque touch-grey
* slab under a palette-inked header and legend, with a visible seam between them, on a field whose
* whole point is that one look runs through it.
*
* The base scheme follows the field's lightness, so anything not overridden here (a container role
* some Material component reaches for) still lands on the right side of the contrast line.
*/
@Composable
internal fun ConsoleInkedTheme(content: @Composable () -> Unit) {
val ink = LocalGamepadInk.current
val scheme = remember(ink) {
val base = if (ink.isLight) lightColorScheme() else darkColorScheme()
base.copy(
primary = ink.accent,
onPrimary = ink.onAccent,
// A card becomes a PANE over the aurora rather than a slab on top of it: the console's
// own glass fill, so an OutlinedCard here is cut from the material the settings rows are.
surface = ink.glass,
onSurface = ink.fg,
surfaceVariant = ink.fg(0.12f),
onSurfaceVariant = ink.fg(0.68f),
outline = ink.fg(0.30f),
outlineVariant = ink.fg(0.16f),
// Nothing here paints a background — the aurora is the ground — but a component that
// resolves `background` (or the content colour for it) must still land on the palette.
background = Color.Transparent,
onBackground = ink.fg,
)
}
// The typography and shapes are the app's, not Material's defaults: this swaps the INK, not the
// brand typeface. And `LocalContentColor` has to be provided by hand — outside a Surface or a
// Scaffold it defaults to BLACK, which is how an unstyled `Text` would vanish into a dark field.
MaterialTheme(
colorScheme = scheme,
typography = MaterialTheme.typography,
shapes = MaterialTheme.shapes,
) {
CompositionLocalProvider(LocalContentColor provides ink.fg, content = content)
}
}
/**
* The console's scroll route for a screen that is a WALL of content rather than a list of focusable
* rows.
*
* Compose only scrolls a container to keep a FOCUSED child visible, so a screen whose body holds no
* focusable nodes (the licenses notices are one enormous `Text`) simply cannot be scrolled by a
* controller: the D-pad has nothing to move to. These screens therefore drive the scroll state
* directly up/down steps, the shoulders page.
*
* Returned as a plain function so a screen's nav callbacks read `scroll(-1, page = false)` rather
* than each screen minting its own coroutine + viewport arithmetic (which is how the two would end
* up scrolling at different speeds).
*/
@Composable
internal fun rememberConsoleScroller(scroll: ScrollState): (dir: Int, page: Boolean) -> Unit {
val scope = rememberCoroutineScope()
val animated = animationsEnabled()
return remember(scroll, animated) {
{ dir, page ->
val delta = consoleScrollDelta(scroll.viewportSize.toFloat(), page, dir)
if (delta != 0f) {
scope.launch {
// Auto-repeat fires every 150 ms while a direction is held, so each animation is
// short enough to have landed (or nearly) before the next one cancels it —
// otherwise a held D-pad crawls, each step restarting from where the last was
// interrupted.
if (animated) {
scroll.animateScrollBy(
delta,
ConsoleMotion.ease(
if (page) ConsoleMotion.TRANSITION_MS else ConsoleMotion.FOCUS_MS,
),
)
} else {
scroll.scrollBy(delta)
}
}
}
}
}
}
/**
* How far one console scroll press travels: [dir] is -1 (up/left) or +1 (down/right), [page] picks
* the shoulders' full page over a D-pad step. Zero while the viewport is unmeasured a first press
* that arrived before layout must do nothing rather than fling the content by zero-times-nothing.
*/
internal fun consoleScrollDelta(viewportPx: Float, page: Boolean, dir: Int): Float =
if (viewportPx <= 0f) 0f else viewportPx * (if (page) CONSOLE_PAGE else CONSOLE_STEP) * dir
/**
* A page keeps a band of what you were reading on screen rather than jumping a clean screenful the
* overlap every reader has used since the printed page, and the difference between "I moved down"
* and "where was I".
*/
private const val CONSOLE_PAGE = 0.88f
/** A D-pad step is about a quarter screen, so holding the direction walks the wall rather than flicking it. */
private const val CONSOLE_STEP = 0.28f
@@ -123,130 +123,6 @@ internal fun AddHostSheet(
}
}
/** First connection to a host that advertised pair=optional: offer TOFU, but pitch PIN pairing. */
@Composable
internal fun TrustNewHostDialog(
pt: PendingTrust,
onTrust: () -> Unit,
onPairInstead: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Trust this host?") },
text = {
Column {
Text("First connection to ${pt.host}:${pt.port}.")
pt.advertisedFp?.let { Text("Fingerprint ${it.take(16)}") }
Text(
"This host allows trust-on-first-use, but that can't tell an impostor " +
"from the real host. Pairing with a PIN is stronger — it proves both sides.",
)
}
},
confirmButton = {
TextButton(onClick = onTrust) { Text("Trust (TOFU)") }
},
dismissButton = {
Row {
TextButton(onClick = onPairInstead) { Text("Pair with PIN…") }
TextButton(onClick = onDismiss) { Text("Cancel") }
}
},
)
}
/**
* Android 17+ Local Network Protection rationale: ACCESS_LOCAL_NETWORK was denied, so discovery and
* every connect are dead offer the system prompt again and a settings deep link (a permanently-
* denied request returns instantly without ever showing the prompt, so "Allow" alone isn't enough).
*/
@Composable
internal fun LocalNetworkDialog(onAllow: () -> Unit, onSettings: () -> Unit, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Allow local network access") },
text = {
Text(
"Android blocks Punktfunk from talking to devices on your network, so it can't " +
"find or reach any host until you allow it. If no prompt appears when you tap " +
"Allow, enable “Nearby devices” for Punktfunk in system settings.",
)
},
confirmButton = {
TextButton(onClick = onAllow) { Text("Allow") }
},
dismissButton = {
Row {
TextButton(onClick = onSettings) { Text("Open settings") }
TextButton(onClick = onDismiss) { Text("Not now") }
}
},
)
}
/** The pinned fingerprint no longer matches — force re-pairing (never a silent re-trust). */
@Composable
internal fun FingerprintChangedDialog(
pt: PendingTrust,
onRepair: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Host identity changed") },
text = {
Text(
"The pinned fingerprint for ${pt.host} no longer matches what it now " +
"advertises. This can mean a host reinstall — or an impostor. Re-pair " +
"with the host's PIN to continue.",
)
},
confirmButton = {
TextButton(onClick = onRepair) { Text("Re-pair") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
}
/**
* A fresh pair=required (or manual/unknown-policy) host: offer the two ways in. "Request access" is
* the no-PIN path connect and wait for the operator to click Approve in the host's console;
* "Use a PIN…" switches to the SPAKE2 ceremony.
*/
@Composable
internal fun RequestAccessDialog(
pt: PendingTrust,
onRequestAccess: () -> Unit,
onUsePin: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Pairing required") },
text = {
Column {
Text("${pt.host}:${pt.port} requires pairing before it will stream.")
Text(
"Request access and approve this device in the host's console (or web " +
"UI) — no PIN needed. Or pair with the 4-digit PIN the host displays.",
)
}
},
confirmButton = {
TextButton(onClick = onRequestAccess) { Text("Request access") }
},
dismissButton = {
Row {
TextButton(onClick = onUsePin) { Text("Use a PIN…") }
TextButton(onClick = onDismiss) { Text("Cancel") }
}
},
)
}
/**
* The SPAKE2 PIN ceremony dialog. Runs [NativeBridge.nativePair] off the UI thread itself (the
* pin/name/error state is dialog-local); on success hands the host's verified fingerprint to
@@ -318,41 +194,6 @@ internal fun PairPinDialog(
)
}
/**
* The no-PIN "request access" wait: the connect is parked on the host until the operator approves
* this device. Cancel returns the UI immediately the caller trips the per-attempt flag so a late
* approval is torn down silently (see ConnectScreen.requestAccess) and resumes discovery.
*/
@Composable
internal fun AwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) {
AlertDialog(
onDismissRequest = onCancel,
title = { Text("Waiting for approval") },
text = {
val deviceName = Build.MODEL ?: "this device"
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
Text("Approve this device on $hostLabel.")
}
Text(
"Open the host's console (or web UI) and approve “$deviceName”. It connects " +
"automatically once you approve — no PIN needed.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = onCancel) { Text("Cancel") }
},
)
}
/**
* Edit a saved host: name, address, port, the Wake-on-LAN MAC, and the per-host settings the record
* owns shared clipboard (a trust decision about THIS machine, so it was never really a global).
@@ -467,103 +308,3 @@ internal fun EditHostDialog(
},
)
}
/**
* The network speed test, as a dialog: it narrates while it measures, then offers to apply the
* recommendation to the layer the tested host actually reads bitrate from see [SpeedTestTarget]
* for why that is the interesting part. The apply buttons name their destination, so the write is
* never a surprise.
*/
@Composable
internal fun SpeedTestDialog(
hostName: String,
target: SpeedTestTarget,
phase: SpeedTestPhase,
onApply: (toProfile: Boolean) -> Unit,
onDismiss: () -> Unit,
) {
val done = phase as? SpeedTestPhase.Done
AlertDialog(
// Measuring can't be cancelled mid-burst (the host is already sending), so a stray tap
// outside shouldn't look like it did something.
onDismissRequest = { if (done != null || phase is SpeedTestPhase.Failed) onDismiss() },
title = { Text("Network speed test") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(hostName, style = MaterialTheme.typography.titleMedium)
when (phase) {
SpeedTestPhase.Connecting, SpeedTestPhase.Measuring -> Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
Text(
if (phase == SpeedTestPhase.Connecting) {
"Connecting…"
} else {
"Measuring — the host is bursting test traffic for two seconds."
},
)
}
is SpeedTestPhase.Failed -> Text(
phase.message,
color = MaterialTheme.colorScheme.error,
)
is SpeedTestPhase.Done -> Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
"%.0f Mbit/s measured · %.1f %% loss".format(
phase.measuredMbps,
phase.lossPct,
),
style = MaterialTheme.typography.bodyLarge,
)
Text(
"Recommended bitrate: %.0f Mbit/s".format(phase.recommendedMbps),
style = MaterialTheme.typography.bodyLarge,
)
Text(
speedTestTargetNote(target),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
},
confirmButton = {
if (done != null) {
TextButton(onClick = { onApply(true) }) {
Text(
when (target) {
SpeedTestTarget.Global -> "Apply"
is SpeedTestTarget.Profile -> "Apply to “${target.profile.name}"
is SpeedTestTarget.Ask -> "Set in “${target.profile.name}"
},
)
}
}
},
dismissButton = {
Row {
// The both-are-defensible case: the user picks the layer, we don't guess.
if (done != null && target is SpeedTestTarget.Ask) {
TextButton(onClick = { onApply(false) }) { Text("Set as default") }
}
TextButton(onClick = onDismiss) { Text("Close") }
}
},
)
}
/** One line saying which layer an Apply will write to, and why that one. */
private fun speedTestTargetNote(target: SpeedTestTarget): String = when (target) {
SpeedTestTarget.Global ->
"This host uses the default settings, so the bitrate goes there."
is SpeedTestTarget.Profile ->
"This host streams with “${target.profile.name}”, which sets its own bitrate — " +
"that override is what it actually reads."
is SpeedTestTarget.Ask ->
"This host streams with “${target.profile.name}”, which currently inherits the default " +
"bitrate. Setting it in the profile affects only this host's profile; setting it as " +
"the default affects everything that inherits it."
}
@@ -0,0 +1,339 @@
package io.unom.punktfunk
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import io.unom.punktfunk.components.EmptyHostsState
import io.unom.punktfunk.components.HostCard
import io.unom.punktfunk.components.HostMenuItem
import io.unom.punktfunk.components.SectionLabel
import io.unom.punktfunk.kit.discovery.DiscoveredHost
import io.unom.punktfunk.kit.security.KnownHost
import io.unom.punktfunk.models.HostStatus
/**
* The touch home: the saved/discovered host grid with the Add-host FAB over it everything
* `ConnectScreen` draws when the console UI is off, and the counterpart of [buildHomeTiles] +
* `GamepadHome` when it is on.
*
* Pure display: every action arrives as a callback, because they all end in state the screen owns
* (a dial in flight, the trust prompt, the host store). What this file DOES own is the arrangement
* which sections exist, in what order, and which actions a given card offers and the two rules
* that are easy to get wrong from the outside: a pinned card is a shortcut and so withholds the
* host's destructive actions, and every card in a section reserves the profile chip's space as soon
* as one of them needs it.
*/
@Composable
internal fun ConnectGrid(
savedHosts: List<KnownHost>,
/** Every live advert — the OS mark prefers it over the stored one, and "searching…" reads it. */
discovered: List<DiscoveredHost>,
/** Adverts with no saved record behind them, de-duped by the caller (it needs them too). */
discoveredUnsaved: List<DiscoveredHost>,
/** Saved hosts answering the QUIC probe, "address:port" — the routed half of "online". */
reachable: Set<String>,
profiles: List<StreamProfile>,
pinsFor: (KnownHost) -> List<StreamProfile>,
connecting: Boolean,
/** A confirmation ("75 Mbit/s set in …"); [status] is the failure line. Never the same thing. */
notice: String?,
status: String?,
lnpGranted: Boolean,
/** Raise the local-network-permission prompt — the banner's "Allow…" and the wake guard. */
onAskLocalNetwork: () -> Unit,
/**
* Dial a saved host. The second argument is `connect`'s one-off profile reference: null follows
* the host's binding (a plain tap), a profile id forces that profile, and the empty string
* forces the global defaults a real, different action on a bound host, which is why it has to
* survive as a value rather than collapsing into "unset".
*/
onConnect: (KnownHost, String?) -> Unit,
onConnectDiscovered: (DiscoveredHost) -> Unit,
onForget: (KnownHost) -> Unit,
onEdit: (KnownHost) -> Unit,
onWake: (KnownHost) -> Unit,
onSpeedTest: (KnownHost) -> Unit,
onCopyLink: (KnownHost, StreamProfile?) -> Unit,
onTogglePin: (KnownHost, StreamProfile) -> Unit,
onRescan: () -> Unit,
onAddHost: () -> Unit,
) {
// The profile rows a card's overflow menu grows. With no profiles at all it stays empty — a
// user who never wants this feature sees no new clutter anywhere but the settings scope chips.
// "Connect with" is a ONE-OFF on every card: it never rebinds the host, which is why rebinding
// lives in the Edit sheet instead.
fun hostMenu(kh: KnownHost, pin: StreamProfile?): List<HostMenuItem> = buildList {
if (pin == null) {
add(HostMenuItem("Network speed test") { onSpeedTest(kh) })
}
add(HostMenuItem("Copy link") { onCopyLink(kh, pin) })
if (profiles.isEmpty()) return@buildList
if (pin != null) {
add(HostMenuItem("Unpin card", startsSection = true) { onTogglePin(kh, pin) })
}
add(
HostMenuItem("Connect with: Default settings", startsSection = true) {
// The empty reference is "force the defaults", not "unset" — on a bound host that
// is a real, different action from a plain tap.
onConnect(kh, "")
},
)
profiles.forEach { p ->
add(HostMenuItem("Connect with: ${p.name}") { onConnect(kh, p.id) })
}
if (pin == null) {
profiles.forEachIndexed { i, p ->
val pinned = p.id in kh.pinnedProfileIds
add(
HostMenuItem(
if (pinned) "Unpin card: ${p.name}" else "Pin as card: ${p.name}",
startsSection = i == 0,
) { onTogglePin(kh, p) },
)
}
}
}
// The saved-hosts grid: each host's own card, then one card per profile it has pinned, so a
// pinned combination is a plain one-click connect instead of a trip through a menu.
val savedCards = savedHosts.flatMap { kh ->
listOf(HostCardEntry(kh, null)) + pinsFor(kh).map { HostCardEntry(kh, it) }
}
// Cards in one grid row must be the same height (the grid won't stretch them), so as soon as
// ANY saved card carries a profile chip, they all reserve its space. Nobody who doesn't use
// profiles ever sees the gap.
val anyProfileChip = savedCards.any { it.pin != null || it.host.profileId != null }
Box(Modifier.fillMaxSize()) {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 160.dp),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item(span = { GridItemSpan(maxLineSpan) }) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Spacer(Modifier.height(8.dp))
Text("Punktfunk", style = MaterialTheme.typography.headlineLarge)
Text(
"stream a remote desktop",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(24.dp))
notice?.let {
Surface(
color = MaterialTheme.colorScheme.secondaryContainer,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(),
) {
Text(
it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
)
}
Spacer(Modifier.height(16.dp))
}
status?.let {
// In-flight progress (connecting / waking) is the full-screen ConnectOverlay's
// job now, so `status` only ever carries a result/error here — a filled error
// container reads as a real failure banner, not just red text lost in the layout.
Surface(
color = MaterialTheme.colorScheme.errorContainer,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(),
) {
Text(
it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
)
}
Spacer(Modifier.height(16.dp))
}
}
}
if (!lnpGranted) {
// Local network access denied: discovery can't ever find anything and every connect
// would time out — say so at the top, with the fix one tap away, instead of letting
// the screen look idle/broken.
item(span = { GridItemSpan(maxLineSpan) }) {
Surface(
color = MaterialTheme.colorScheme.errorContainer,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(),
) {
Column(
Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
"Local network access is off",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onErrorContainer,
)
Text(
"Android blocks Punktfunk from finding or reaching hosts until you allow it.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
textAlign = TextAlign.Center,
)
TextButton(onClick = onAskLocalNetwork) { Text("Allow…") }
}
}
Spacer(Modifier.height(12.dp))
}
}
if (savedHosts.isEmpty() && discoveredUnsaved.isEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
EmptyHostsState()
}
}
if (savedHosts.isNotEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
SectionLabel("Saved hosts")
}
items(savedCards, key = { it.key }) { entry ->
val kh = entry.host
val pin = entry.pin
val bound = kh.profileId?.let { id -> profiles.firstOrNull { it.id == id } }
HostCard(
name = kh.name,
address = "${kh.address}:${kh.port}",
status = if (kh.paired) HostStatus.PAIRED else HostStatus.TOFU,
online = kh.isOnline(discovered, reachable),
// Live advert preferred (the store lags a discovery tick), else stored.
os = discovered.firstOrNull { kh.matches(it) && it.os.isNotEmpty() }?.os
?: kh.os,
enabled = !connecting,
// A pinned card connects with ITS profile; the host's own card follows the
// binding, which is exactly what its chip says it will do.
onConnect = { onConnect(kh, pin?.id) },
// Edit / Forget / Wake live on the host's own card only: a pinned card is a
// shortcut, not a second host, and offering destructive host actions on it
// would blur exactly that.
onForget = if (pin != null) null else ({ onForget(kh) }),
onEdit = if (pin != null) null else ({ onEdit(kh) }),
// Explicit wake-only: offered when the host is offline and we have a MAC. The
// screen runs it through the WakeController so it shows the "Waking…" overlay
// and waits for the host to come online (matched by fingerprint, so a new DHCP
// address on a cold boot still counts as "up") rather than firing a single
// silent packet.
onWake = if (pin == null && kh.mac.isNotEmpty() && !kh.isOnline(discovered, reachable)) {
({ onWake(kh) })
} else {
null
},
profileLabel = pin?.name ?: bound?.name,
profileProminent = pin != null,
accent = accentColor(pin?.accent ?: bound?.accent),
menuItems = hostMenu(kh, pin),
reserveProfileSlot = anyProfileChip,
)
}
}
if (discoveredUnsaved.isNotEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
Spacer(Modifier.height(12.dp))
SectionLabel("Discovered on the network")
}
items(discoveredUnsaved, key = { "disc-${it.host}-${it.port}" }) { dh ->
HostCard(
name = dh.name,
address = "${dh.host}:${dh.port}",
status = if (dh.pairingRequired) HostStatus.PAIRING else HostStatus.TOFU,
online = true, // in the discovered list ⇒ live on mDNS right now
os = dh.os,
enabled = !connecting,
onConnect = { onConnectDiscovered(dh) },
onForget = null,
)
}
}
// Active-discovery hint: discovery runs whenever this screen is up, so while it's
// scanning but nothing's turned up yet (and we're not mid-connect), show it's working
// rather than looking idle/empty. Suppressed while local network access is denied —
// a spinner would be a lie there (the browse can't receive anything); the banner above
// owns that state.
// Scan again is offered whether or not anything turned up: the case that sends people
// here is ONE expected host missing, not an empty list, and a browse that quietly went
// deaf (blocked when it started, or backed off to its hour-long re-query) looks
// exactly like a network without that host on it.
if (lnpGranted && !connecting) {
item(span = { GridItemSpan(maxLineSpan) }) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
if (discovered.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Spacer(Modifier.width(8.dp))
Text(
"Searching the local network…",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(8.dp))
}
TextButton(onClick = onRescan) { Text("Scan again") }
}
}
}
item(span = { GridItemSpan(maxLineSpan) }) {
Spacer(Modifier.height(96.dp))
}
}
ExtendedFloatingActionButton(
onClick = onAddHost,
icon = { Icon(Icons.Filled.Add, contentDescription = null) },
text = { Text("Add host") },
expanded = !connecting,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(20.dp),
)
}
}
@@ -1,5 +1,6 @@
package io.unom.punktfunk
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
@@ -29,8 +30,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@@ -204,7 +205,10 @@ internal fun ConnectTakeover(
) {
GamepadAuroraBackground(Modifier.fillMaxSize())
Column(
Modifier.padding(horizontal = 40.dp).widthIn(max = 460.dp),
// The backdrop runs full-bleed; the COPY keeps clear of the bars and the cutout. In
// landscape a hole punch is a side inset deeper than this 40 dp gutter, so centred text
// would otherwise sit under the camera.
Modifier.consoleSafeArea().padding(horizontal = 40.dp).widthIn(max = 460.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
@@ -239,7 +243,19 @@ internal fun ConnectTakeover(
add(PadGlyph.hint('B', copy.cancelLabel, onClick = onCancel))
if (timedOut) add(PadGlyph.hint('A', "Try Again", onClick = onRetry))
}
GamepadHintBar(hints, Modifier.align(Alignment.BottomCenter).padding(bottom = 28.dp))
// The SAME bottom-start spot every console screen pins its legend at — this takeover sat
// its pill at bottom-CENTRE, so pressing Connect made the one piece of chrome that is
// supposed to read as fixed jump halfway across the screen (second on-glass verdict).
val landscape =
LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
Box(
Modifier
.align(Alignment.BottomStart)
.consoleLegendInsets(landscape)
.padding(ConsoleLegendInset),
) {
GamepadHintBar(hints)
}
}
}
@@ -0,0 +1,195 @@
package io.unom.punktfunk
import androidx.compose.runtime.Composable
import io.unom.punktfunk.kit.security.ClientIdentity
import io.unom.punktfunk.kit.security.KnownHost
import io.unom.punktfunk.models.PendingTrust
/**
* Everything `ConnectScreen` puts ON TOP of whichever home it drew the trust and pairing
* ceremony, the parked "Waiting for approval…", the console's host options, the speed test, the
* edit form, the local-network rationale, and finally the connect takeover.
*
* They live together because their ORDER is the contract: this is a stack of siblings in one tree,
* so the last one drawn is the one on top, and [ConnectOverlay] is last on purpose a dial can
* start from any of the prompts above it, and its takeover has to cover the prompt that started it.
*
* Only the state each prompt reads comes in; every action goes back out as a callback, because they
* all end in the connect/pair engine or in the host store, which the screen owns. Nothing in here
* decides anything it decides only what is visible.
*/
@Composable
internal fun ConnectPrompts(
gamepadUi: Boolean,
/** The client identity — the PIN ceremony needs it to run SPAKE2; null while it is still minting. */
identity: ClientIdentity?,
profiles: List<StreamProfile>,
isOnline: (KnownHost) -> Boolean,
// ---- trust / pairing --------------------------------------------------------------------
pendingTrust: PendingTrust?,
/** Dismiss (null) or re-aim the SAME decision at another kind — "Pair with PIN…" does that. */
onPendingTrustChange: (PendingTrust?) -> Unit,
/** Trust-on-first-use accepted: dial with no pin. Offered only for a `pair=optional` host. */
onTrustNew: (PendingTrust) -> Unit,
/** The PIN ceremony completed with this host fingerprint — save as paired, then dial. */
onPaired: (PendingTrust, String) -> Unit,
onRequestAccess: (PendingTrust) -> Unit,
// ---- the parked no-PIN request ----------------------------------------------------------
/** Non-null while a "request access" connect sits parked on the host awaiting approval. */
awaitingHostName: String?,
onCancelApproval: () -> Unit,
// ---- console host options (Up on a saved carousel tile) ---------------------------------
optionsTarget: HostCardEntry?,
onDismissOptions: () -> Unit,
libraryEnabled: Boolean,
onOpenLibrary: (KnownHost) -> Unit,
onWake: (KnownHost) -> Unit,
onSpeedTest: (KnownHost) -> Unit,
onCopyLink: (KnownHost, StreamProfile?) -> Unit,
onEditHost: (KnownHost) -> Unit,
onForgetHost: (KnownHost) -> Unit,
onTogglePin: (KnownHost, StreamProfile) -> Unit,
// ---- speed test --------------------------------------------------------------------------
speedTest: HostCardEntry?,
/** Which layer Apply writes to. Resolved by the caller (it holds the store); set with [speedTest]. */
speedTestTarget: SpeedTestTarget?,
speedTestPhase: SpeedTestPhase,
/** true = write the measured bitrate to the profile, false = to the global default. */
onApplySpeedTest: (Boolean) -> Unit,
onDismissSpeedTest: () -> Unit,
// ---- edit host ---------------------------------------------------------------------------
editTarget: KnownHost?,
/** A MAC from the live advert, for a host whose own is not learned yet. */
editSuggestedMacs: List<String>,
onSaveHost: (KnownHost) -> Unit,
onDismissEdit: () -> Unit,
// ---- local network permission ------------------------------------------------------------
lnpPrompt: Boolean,
onAllowLocalNetwork: () -> Unit,
onOpenSystemSettings: () -> Unit,
onDismissLnpPrompt: () -> Unit,
// ---- the connect takeover ----------------------------------------------------------------
connectingHostName: String?,
waker: WakeController,
onCancelConnect: () -> Unit,
) {
pendingTrust?.let { pt ->
// Same trust/pairing logic, console-styled + controller-navigable in gamepad mode.
val onPair = { onPendingTrustChange(pt.copy(kind = PendingTrust.Kind.PAIR)) }
// Three of the four say the same thing in both interfaces, so they are ONE prompt that
// knows which one is running. Only the PIN ceremony genuinely differs — a keyboard field
// against four D-pad digit slots is a different input model, not a different skin.
when (pt.kind) {
PendingTrust.Kind.TRUST_NEW -> TrustNewHostPrompt(
gamepadUi, pt,
onTrust = { onTrustNew(pt) },
onPairInstead = onPair,
onDismiss = { onPendingTrustChange(null) },
)
PendingTrust.Kind.FP_CHANGED ->
FingerprintChangedPrompt(gamepadUi, pt, onPair) { onPendingTrustChange(null) }
PendingTrust.Kind.REQUEST_ACCESS -> RequestAccessPrompt(
gamepadUi, pt,
onRequestAccess = { onRequestAccess(pt) },
onUsePin = onPair,
onDismiss = { onPendingTrustChange(null) },
)
PendingTrust.Kind.PAIR -> {
val onSavePaired = { fp: String -> onPaired(pt, fp) }
if (gamepadUi) {
GamepadPairPinDialog(pt, identity, onSavePaired) { onPendingTrustChange(null) }
} else {
PairPinDialog(pt, identity, onSavePaired) { onPendingTrustChange(null) }
}
}
}
}
awaitingHostName?.let { hostLabel ->
AwaitingApprovalPrompt(gamepadUi, hostLabel = hostLabel, onCancel = onCancelApproval)
}
// Console host options (Up on a saved carousel tile): Wake / Edit / Forget.
optionsTarget?.let { entry ->
val kh = entry.host
val pin = entry.pin
val offline = !isOnline(kh)
GamepadHostOptionsDialog(
hostName = kh.name,
canWake = kh.mac.isNotEmpty() && offline,
onWake = { onDismissOptions(); onWake(kh) },
// A saved host always has a library (it's a knownHost) → offer it when the setting's on,
// so a TV remote reaches the library here instead of via the Y face button.
onLibrary = if (libraryEnabled && pin == null) {
{ onDismissOptions(); onOpenLibrary(kh) }
} else {
null
},
onSpeedTest = if (pin == null) {
{ onDismissOptions(); onSpeedTest(kh) }
} else {
null
},
onCopyLink = { onDismissOptions(); onCopyLink(kh, pin) },
onEdit = { onDismissOptions(); onEditHost(kh) },
onForget = { onForgetHost(kh); onDismissOptions() },
onDismiss = onDismissOptions,
// A pin's only action: unpinning touches neither the host nor the profile.
onUnpin = pin?.let { p -> { onTogglePin(kh, p); onDismissOptions() } },
profileName = pin?.name,
)
}
if (speedTest != null && speedTestTarget != null) {
SpeedTestPrompt(
gamepadUi, speedTest.host.name, speedTestTarget, speedTestPhase,
onApplySpeedTest, onDismissSpeedTest,
)
}
editTarget?.let { kh ->
if (gamepadUi) {
// Console edit: the same field list + on-screen keyboard as Add-Host, seeded from the
// host with an extra MAC row; the action SAVES instead of connecting.
GamepadAddHostScreen(
onAdd = { _, _, _ -> },
onDismiss = onDismissEdit,
editHost = kh,
suggestedMacs = editSuggestedMacs,
onSave = onSaveHost,
// Shared clipboard and the profile binding — the two host decisions that used to
// exist only in the touch edit sheet, which a TV box has no way to reach.
profiles = profiles,
)
} else {
EditHostDialog(
target = kh,
suggestedMacs = editSuggestedMacs,
profiles = profiles,
onSave = onSaveHost,
onDismiss = onDismissEdit,
)
}
}
if (lnpPrompt) {
// Android 17+ local-network-permission rationale: re-request (a permanently-denied request
// returns instantly without a system prompt — hence the settings deep link alongside).
LocalNetworkPrompt(
gamepadUi,
onAllow = onAllowLocalNetwork,
onSettings = onOpenSystemSettings,
onDismiss = onDismissLnpPrompt,
)
}
// Topmost: the full-screen connect takeover — instant "Connecting…" feedback on any dial, flowing
// seamlessly into the "Waking…" wait if the host turns out to be asleep. Rides over both the touch
// grid and the console home.
ConnectOverlay(
connectingHostName = connectingHostName,
waker = waker,
gamepadUi = gamepadUi,
onCancelConnect = onCancelConnect,
)
}
@@ -11,31 +11,6 @@ import android.os.Build
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -45,19 +20,11 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
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.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import io.unom.punktfunk.components.EmptyHostsState
import io.unom.punktfunk.components.HostCard
import io.unom.punktfunk.components.HostMenuItem
import io.unom.punktfunk.components.SectionLabel
import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.discovery.DiscoveredHost
@@ -73,7 +40,6 @@ import io.unom.punktfunk.kit.security.KnownHost
import io.unom.punktfunk.kit.security.KnownHostStore
import io.unom.punktfunk.kit.security.obtainIdentity
import io.unom.punktfunk.models.ActiveSession
import io.unom.punktfunk.models.HostStatus
import io.unom.punktfunk.models.PendingTrust
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.Dispatchers
@@ -108,6 +74,20 @@ private class ConnectAttempt(val hostName: String) {
val cancelled = AtomicBoolean(false)
}
/**
* The connect screen discovery, trust and the dial itself, under either interface.
*
* What is left in this file is the STATE and the engine: the mDNS browse and the permission that
* gates it, the identity, the host and profile stores, the trust decision, the dial and its wake
* fallback, and the `punktfunk://` router. What was drawn from that state now lives beside it —
* `buildHomeTiles` (the console carousel's contents), `ConnectGrid` (the touch home) and
* `ConnectPrompts` (everything modal, plus the connect takeover). They hold no state of their own,
* which is why they could leave: each one takes what it displays and hands back what was pressed.
*
* The engine did NOT leave, and shouldn't until it has somewhere to live: it closes over ~20 locals
* that a dozen callbacks read and write, and hoisting it means inventing a state holder a second
* refactor, and a second thing to get wrong.
*/
@Composable
fun ConnectScreen(
settings: Settings,
@@ -651,52 +631,6 @@ fun ConnectScreen(
}
}
// The profile rows a card's overflow menu grows. With no profiles at all it stays empty — a
// user who never wants this feature sees no new clutter anywhere but the settings scope chips.
// "Connect with" is a ONE-OFF on every card: it never rebinds the host, which is why rebinding
// lives in the Edit sheet instead.
fun hostMenu(kh: KnownHost, pin: StreamProfile?): List<HostMenuItem> = buildList {
if (pin == null) {
add(HostMenuItem("Network speed test") { startSpeedTest(HostCardEntry(kh, null)) })
}
add(HostMenuItem("Copy link") { copyLink(kh, pin) })
if (profiles.isEmpty()) return@buildList
if (pin != null) {
add(HostMenuItem("Unpin card", startsSection = true) { togglePin(kh, pin) })
}
add(
HostMenuItem("Connect with: Default settings", startsSection = true) {
// The empty reference is "force the defaults", not "unset" — on a bound host that
// is a real, different action from a plain tap.
connect(kh.address, kh.port, oneOffProfile = "")
},
)
profiles.forEach { p ->
add(HostMenuItem("Connect with: ${p.name}") { connect(kh.address, kh.port, oneOffProfile = p.id) })
}
if (pin == null) {
profiles.forEachIndexed { i, p ->
val pinned = p.id in kh.pinnedProfileIds
add(
HostMenuItem(
if (pinned) "Unpin card: ${p.name}" else "Pin as card: ${p.name}",
startsSection = i == 0,
) { togglePin(kh, p) },
)
}
}
}
// The saved-hosts grid: each host's own card, then one card per profile it has pinned, so a
// pinned combination is a plain one-click connect instead of a trip through a menu.
val savedCards = savedHosts.flatMap { kh ->
listOf(HostCardEntry(kh, null)) + profileStore.pinsFor(kh).map { HostCardEntry(kh, it) }
}
// Cards in one grid row must be the same height (the grid won't stretch them), so as soon as
// ANY saved card carries a profile chip, they all reserve its space. Nobody who doesn't use
// profiles ever sees the gap.
val anyProfileChip = savedCards.any { it.pin != null || it.host.profileId != null }
// ---- punktfunk:// routing (design/client-deep-links.md §3) --------------------------------
//
// The invariant: a URL may only ever do what a click on an existing card could do, MINUS trust
@@ -782,77 +716,61 @@ fun ConnectScreen(
var showManualSheet by remember { mutableStateOf(false) }
// Wake a saved host on demand — the touch card's Wake item and the console options dialog run
// the same action. Through the WakeController, so it shows the "Waking…" overlay and waits for
// the host to come back rather than firing one silent packet at it.
fun wakeHost(kh: KnownHost) {
// The magic packet is UDP broadcast — LNP-blocked like everything else.
if (!lnpGranted) {
lnpPrompt = true
return
}
waker.start(
hostName = kh.name,
connectsAfter = false,
macs = kh.mac,
lastIp = kh.address,
// "Back up" is mDNS presence ONLY — narrower than the [isOnline] that decides whether to
// OFFER Wake, which also counts a QUIC probe answer. Matched through `matches`, so a
// cold boot onto a new DHCP address still ends the wait.
isOnline = { discovered.any { kh.matches(it) } },
onOnline = {},
)
}
fun forgetHost(kh: KnownHost) {
knownHostStore.remove(kh)
savedHosts = knownHostStore.all()
}
if (gamepadUi) {
// Console mode: the host carousel (saved → discovered → Add Host), driven by the pad. Shares
// every action above; the trailing Add Host tile opens the same manual-entry sheet.
val tiles = buildList {
savedHosts.forEach { kh ->
val bound = kh.profileId?.let { id -> profiles.firstOrNull { it.id == id } }
add(
HomeTile(
id = "saved-${kh.id}",
title = kh.name,
// The binding is what a press will actually do, so the tile says so — the
// console can't edit profiles, but it must never lie about which one it uses.
subtitle = bound?.let { "${kh.address}:${kh.port} · ${it.name}" }
?: "${kh.address}:${kh.port}",
filled = true,
online = kh.isOnline(discovered, reachable),
paired = kh.paired,
knownHost = kh,
activate = { connect(kh.address, kh.port) },
),
)
// Pinned host+profile combinations, right after their host: one focus-and-press
// each, which is the affordance a controller surface does well (menus are not).
profileStore.pinsFor(kh).forEach { p ->
add(
HomeTile(
id = "pin-${kh.id}-${p.id}",
title = kh.name,
subtitle = p.name,
filled = true,
online = kh.isOnline(discovered, reachable),
paired = kh.paired,
knownHost = kh,
pinnedProfileId = p.id,
activate = { connect(kh.address, kh.port, oneOffProfile = p.id) },
),
)
}
}
discoveredUnsaved.forEach { dh ->
add(
HomeTile(
id = "disc-${dh.host}:${dh.port}",
title = dh.name,
subtitle = "${dh.host}:${dh.port}",
online = true,
activate = { connect(dh.host, dh.port, dh) },
),
)
}
add(
HomeTile(
id = "add",
title = "Add Host",
subtitle = "Register a host by address",
isAdd = true,
activate = { showManualSheet = true },
),
)
}
GamepadHome(
tiles = tiles,
tiles = buildHomeTiles(
savedHosts = savedHosts,
profiles = profiles,
pinsFor = profileStore::pinsFor,
discoveredUnsaved = discoveredUnsaved,
isOnline = { it.isOnline(discovered, reachable) },
onConnect = { kh, oneOff -> connect(kh.address, kh.port, oneOffProfile = oneOff) },
onConnectDiscovered = { dh -> connect(dh.host, dh.port, dh) },
onAddHost = { showManualSheet = true },
),
libraryEnabled = settings.libraryEnabled,
controllerName = io.unom.punktfunk.kit.Gamepad.firstPad()?.name,
// Stop the carousel from consuming the pad while a sheet/dialog/overlay owns the screen,
// while a connect is in flight (else a second A launches a concurrent connect that leaks a
// handle — the touch grid guards the same way with enabled=!connecting), or while the whole
// console home is cross-fading out.
// ⚠ `speedTest` belongs in this list and was missing. It LOOKED covered by `!connecting`,
// and is — right up until the measurement finishes: `startSpeedTest` clears `connecting`
// before its Done/Failed card is dismissed, so from that moment the card AND the
// carousel underneath both consumed the pad. One A then dismissed the card and started
// a connect. Every other modal on this screen is named here for exactly this reason.
navActive = navGate && !connecting && !showManualSheet && pendingTrust == null &&
awaiting == null && editTarget == null && optionsTarget == null &&
waker.waking == null && !lnpPrompt,
speedTest == null && waker.waking == null && !lnpPrompt,
onActivate = { it.activate() },
onOpenLibrary = { it.knownHost?.let(onOpenLibrary) },
onOpenSettings = onOpenSettings,
@@ -863,239 +781,35 @@ fun ConnectScreen(
},
)
} else {
Box(Modifier.fillMaxSize()) {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 160.dp),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item(span = { GridItemSpan(maxLineSpan) }) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Spacer(Modifier.height(8.dp))
Text("Punktfunk", style = MaterialTheme.typography.headlineLarge)
Text(
"stream a remote desktop",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(24.dp))
notice?.let {
Surface(
color = MaterialTheme.colorScheme.secondaryContainer,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(),
) {
Text(
it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
)
}
Spacer(Modifier.height(16.dp))
}
status?.let {
// In-flight progress (connecting / waking) is the full-screen ConnectOverlay's
// job now, so `status` only ever carries a result/error here — a filled error
// container reads as a real failure banner, not just red text lost in the layout.
Surface(
color = MaterialTheme.colorScheme.errorContainer,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(),
) {
Text(
it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
)
}
Spacer(Modifier.height(16.dp))
}
}
}
if (!lnpGranted) {
// Local network access denied: discovery can't ever find anything and every connect
// would time out — say so at the top, with the fix one tap away, instead of letting
// the screen look idle/broken.
item(span = { GridItemSpan(maxLineSpan) }) {
Surface(
color = MaterialTheme.colorScheme.errorContainer,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(),
) {
Column(
Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
"Local network access is off",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onErrorContainer,
)
Text(
"Android blocks Punktfunk from finding or reaching hosts until you allow it.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
textAlign = TextAlign.Center,
)
TextButton(onClick = { lnpPrompt = true }) { Text("Allow…") }
}
}
Spacer(Modifier.height(12.dp))
}
}
if (savedHosts.isEmpty() && discoveredUnsaved.isEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
EmptyHostsState()
}
}
if (savedHosts.isNotEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
SectionLabel("Saved hosts")
}
items(savedCards, key = { it.key }) { entry ->
val kh = entry.host
val pin = entry.pin
val bound = kh.profileId?.let { id -> profiles.firstOrNull { it.id == id } }
HostCard(
name = kh.name,
address = "${kh.address}:${kh.port}",
status = if (kh.paired) HostStatus.PAIRED else HostStatus.TOFU,
online = kh.isOnline(discovered, reachable),
// Live advert preferred (the store lags a discovery tick), else stored.
os = discovered.firstOrNull { kh.matches(it) && it.os.isNotEmpty() }?.os
?: kh.os,
enabled = !connecting,
// A pinned card connects with ITS profile; the host's own card follows the
// binding, which is exactly what its chip says it will do.
onConnect = {
if (pin != null) {
connect(kh.address, kh.port, oneOffProfile = pin.id)
} else {
connect(kh.address, kh.port)
}
},
// Edit / Forget / Wake live on the host's own card only: a pinned card is a
// shortcut, not a second host, and offering destructive host actions on it
// would blur exactly that.
onForget = if (pin != null) {
null
} else {
{
knownHostStore.remove(kh)
savedHosts = knownHostStore.all()
}
},
onEdit = if (pin != null) null else ({ editTarget = kh }),
// Explicit wake-only: offered when the host is offline and we have a MAC. Runs
// through the WakeController so it shows the "Waking…" overlay and waits for
// the host to come online (matched by fingerprint, so a new DHCP address on a
// cold boot still counts as "up") rather than firing a single silent packet.
onWake = if (pin == null && kh.mac.isNotEmpty() && !kh.isOnline(discovered, reachable)) {
{
// The magic packet is UDP broadcast — LNP-blocked like everything else.
if (!lnpGranted) {
lnpPrompt = true
} else {
waker.start(
hostName = kh.name,
connectsAfter = false,
macs = kh.mac,
lastIp = kh.address,
isOnline = { discovered.any { kh.matches(it) } },
onOnline = {},
)
}
}
} else {
null
},
profileLabel = pin?.name ?: bound?.name,
profileProminent = pin != null,
accent = accentColor(pin?.accent ?: bound?.accent),
menuItems = hostMenu(kh, pin),
reserveProfileSlot = anyProfileChip,
)
}
}
if (discoveredUnsaved.isNotEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
Spacer(Modifier.height(12.dp))
SectionLabel("Discovered on the network")
}
items(discoveredUnsaved, key = { "disc-${it.host}-${it.port}" }) { dh ->
HostCard(
name = dh.name,
address = "${dh.host}:${dh.port}",
status = if (dh.pairingRequired) HostStatus.PAIRING else HostStatus.TOFU,
online = true, // in the discovered list ⇒ live on mDNS right now
os = dh.os,
enabled = !connecting,
onConnect = { connect(dh.host, dh.port, dh) },
onForget = null,
)
}
}
// Active-discovery hint: discovery runs whenever this screen is up, so while it's
// scanning but nothing's turned up yet (and we're not mid-connect), show it's working
// rather than looking idle/empty. Suppressed while local network access is denied —
// a spinner would be a lie there (the browse can't receive anything); the banner above
// owns that state.
// Scan again is offered whether or not anything turned up: the case that sends people
// here is ONE expected host missing, not an empty list, and a browse that quietly went
// deaf (blocked when it started, or backed off to its hour-long re-query) looks
// exactly like a network without that host on it.
if (lnpGranted && !connecting) {
item(span = { GridItemSpan(maxLineSpan) }) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
if (discovered.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Spacer(Modifier.width(8.dp))
Text(
"Searching the local network…",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(8.dp))
}
TextButton(onClick = { discovery.restart() }) { Text("Scan again") }
}
}
}
item(span = { GridItemSpan(maxLineSpan) }) {
Spacer(Modifier.height(96.dp))
}
}
ExtendedFloatingActionButton(
onClick = { showManualSheet = true },
icon = { Icon(Icons.Filled.Add, contentDescription = null) },
text = { Text("Add host") },
expanded = !connecting,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(20.dp),
ConnectGrid(
savedHosts = savedHosts,
discovered = discovered,
discoveredUnsaved = discoveredUnsaved,
reachable = reachable,
profiles = profiles,
pinsFor = profileStore::pinsFor,
connecting = connecting,
notice = notice,
status = status,
lnpGranted = lnpGranted,
onAskLocalNetwork = { lnpPrompt = true },
onConnect = { kh, oneOff -> connect(kh.address, kh.port, oneOffProfile = oneOff) },
onConnectDiscovered = { dh -> connect(dh.host, dh.port, dh) },
onForget = { kh -> forgetHost(kh) },
onEdit = { kh -> editTarget = kh },
onWake = { kh -> wakeHost(kh) },
onSpeedTest = { kh -> startSpeedTest(HostCardEntry(kh, null)) },
onCopyLink = { kh, pin -> copyLink(kh, pin) },
onTogglePin = { kh, p -> togglePin(kh, p) },
onRescan = { discovery.restart() },
onAddHost = { showManualSheet = true },
)
}
}
// Add Host stayed behind while the other modals moved into ConnectPrompts: its form fields are
// remembered HERE, on purpose, so a half-typed address survives the sheet being dismissed and
// reopened. Moving the block without moving that state would quietly change what a dismiss
// costs; moving both is a separate decision from this one.
if (showManualSheet) {
if (gamepadUi) {
// Console add-host: field list + on-screen controller keyboard. "Add" connects (which
@@ -1123,148 +837,81 @@ fun ConnectScreen(
}
}
pendingTrust?.let { pt ->
// Same trust/pairing logic, console-styled + controller-navigable in gamepad mode.
val onPair = { pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) }
val onSavePaired = { fp: String ->
// Which layer a measurement would land in. Resolved here, not in the prompt: it is a question
// for the profile store, and the Apply button and the caption above it must agree on the answer.
val speedTestTarget = speedTest?.let { SpeedTestTarget.resolve(it.host, it.pin?.id, profileStore) }
// Prefill a not-yet-learned MAC from the host's live advert, mirroring Apple's
// `discovery.hosts.first { host.matches($0) }?.macAddresses`.
val editSuggestedMacs =
editTarget?.let { kh -> discovered.firstOrNull { kh.matches(it) }?.mac } ?: emptyList()
// Everything that floats above whichever home was drawn, in one place and in one order — see
// ConnectPrompts.kt. It decides nothing: each action below lands right back in the engine above.
ConnectPrompts(
gamepadUi = gamepadUi,
identity = identity,
profiles = profiles,
isOnline = { it.isOnline(discovered, reachable) },
pendingTrust = pendingTrust,
onPendingTrustChange = { pendingTrust = it },
onTrustNew = { pt ->
pendingTrust = null
doConnect(pt.host, pt.port, pt.name, null, pt.profile, pt.launch)
},
onPaired = { pt, fp ->
knownHostStore.trust(pt.host, pt.port, pt.name, fp, paired = true)
savedHosts = knownHostStore.all()
pendingTrust = null
doConnect(pt.host, pt.port, pt.name, fp, pt.profile, pt.launch)
}
when (pt.kind) {
PendingTrust.Kind.TRUST_NEW ->
if (gamepadUi) GamepadTrustNewDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null, pt.profile, pt.launch) }, onPair, { pendingTrust = null })
else TrustNewHostDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null, pt.profile, pt.launch) }, onPair, { pendingTrust = null })
PendingTrust.Kind.FP_CHANGED ->
if (gamepadUi) GamepadFingerprintChangedDialog(pt, onPair, { pendingTrust = null })
else FingerprintChangedDialog(pt, onPair, { pendingTrust = null })
PendingTrust.Kind.REQUEST_ACCESS ->
if (gamepadUi) GamepadRequestAccessDialog(pt, { pendingTrust = null; requestAccess(pt) }, onPair, { pendingTrust = null })
else RequestAccessDialog(pt, { pendingTrust = null; requestAccess(pt) }, onPair, { pendingTrust = null })
PendingTrust.Kind.PAIR ->
if (gamepadUi) GamepadPairPinDialog(pt, identity, onSavePaired, { pendingTrust = null })
else PairPinDialog(pt, identity, onSavePaired, { pendingTrust = null })
}
}
awaiting?.let { req ->
val onCancel = {
req.cancelled.set(true)
},
onRequestAccess = { pt -> pendingTrust = null; requestAccess(pt) },
awaitingHostName = awaiting?.target?.name,
onCancelApproval = {
awaiting?.cancelled?.set(true)
awaiting = null
connecting = false
discovery.start() // the request may still be pending on the host; keep scanning
}
if (gamepadUi) GamepadAwaitingApprovalDialog(req.target.name, onCancel)
else AwaitingApprovalDialog(hostLabel = req.target.name, onCancel = onCancel)
}
// Console host options (Up on a saved carousel tile): Wake / Edit / Forget.
optionsTarget?.let { entry ->
val kh = entry.host
val pin = entry.pin
val offline = !kh.isOnline(discovered, reachable)
GamepadHostOptionsDialog(
hostName = kh.name,
canWake = kh.mac.isNotEmpty() && offline,
onWake = {
optionsTarget = null
// The magic packet is UDP broadcast — LNP-blocked like everything else.
if (!lnpGranted) {
lnpPrompt = true
} else {
waker.start(
hostName = kh.name, connectsAfter = false, macs = kh.mac, lastIp = kh.address,
isOnline = { discovered.any { kh.matches(it) } },
onOnline = {},
)
}
},
// A saved host always has a library (it's a knownHost) → offer it when the setting's on,
// so a TV remote reaches the library here instead of via the Y face button.
onLibrary = if (settings.libraryEnabled && pin == null) {
{ optionsTarget = null; onOpenLibrary(kh) }
} else {
null
},
onSpeedTest = if (pin == null) {
{ optionsTarget = null; startSpeedTest(HostCardEntry(kh, null)) }
} else {
null
},
onCopyLink = { optionsTarget = null; copyLink(kh, pin) },
onEdit = { optionsTarget = null; editTarget = kh },
onForget = {
knownHostStore.remove(kh)
savedHosts = knownHostStore.all()
optionsTarget = null
},
onDismiss = { optionsTarget = null },
// A pin's only action: unpinning touches neither the host nor the profile.
onUnpin = pin?.let { p -> { togglePin(kh, p); optionsTarget = null } },
profileName = pin?.name,
)
}
speedTest?.let { entry ->
val target = SpeedTestTarget.resolve(entry.host, entry.pin?.id, profileStore)
val dismiss = { speedTest = null }
val apply: (Boolean) -> Unit = { toProfile ->
},
optionsTarget = optionsTarget,
onDismissOptions = { optionsTarget = null },
libraryEnabled = settings.libraryEnabled,
onOpenLibrary = onOpenLibrary,
onWake = { kh -> wakeHost(kh) },
onSpeedTest = { kh -> startSpeedTest(HostCardEntry(kh, null)) },
onCopyLink = { kh, pin -> copyLink(kh, pin) },
onEditHost = { kh -> editTarget = kh },
onForgetHost = { kh -> forgetHost(kh) },
onTogglePin = { kh, p -> togglePin(kh, p) },
speedTest = speedTest,
speedTestTarget = speedTestTarget,
speedTestPhase = speedTestPhase,
onApplySpeedTest = { toProfile ->
val done = speedTestPhase as? SpeedTestPhase.Done
if (done != null) {
if (done != null && speedTestTarget != null) {
val where = applySpeedTestResult(
done.recommendedKbps, target, toProfile, profileStore, settings, onSettingsChange,
done.recommendedKbps, speedTestTarget, toProfile, profileStore, settings,
onSettingsChange,
)
profiles = profileStore.all()
notice = "%.0f Mbit/s set in %s".format(done.recommendedMbps, where)
}
speedTest = null
}
if (gamepadUi) {
GamepadSpeedTestDialog(entry.host.name, target, speedTestPhase, apply, dismiss)
} else {
SpeedTestDialog(entry.host.name, target, speedTestPhase, apply, dismiss)
}
}
editTarget?.let { kh ->
// Prefill a not-yet-learned MAC from the host's live advert, mirroring Apple's
// `discovery.hosts.first { host.matches($0) }?.macAddresses`.
val suggested = discovered.firstOrNull { kh.matches(it) }?.mac ?: emptyList()
val onSaveHost: (KnownHost) -> Unit = { updated ->
},
onDismissSpeedTest = { speedTest = null },
editTarget = editTarget,
editSuggestedMacs = editSuggestedMacs,
onSaveHost = { updated ->
knownHostStore.save(updated)
savedHosts = knownHostStore.all()
editTarget = null
}
if (gamepadUi) {
// Console edit: the same field list + on-screen keyboard as Add-Host, seeded from the
// host with an extra MAC row; the action SAVES instead of connecting.
GamepadAddHostScreen(
onAdd = { _, _, _ -> },
onDismiss = { editTarget = null },
editHost = kh,
suggestedMacs = suggested,
onSave = onSaveHost,
)
} else {
EditHostDialog(
target = kh,
suggestedMacs = suggested,
profiles = profiles,
onSave = onSaveHost,
onDismiss = { editTarget = null },
)
}
}
if (lnpPrompt) {
// Android 17+ local-network-permission rationale: re-request (a permanently-denied request
// returns instantly without a system prompt — hence the settings deep link alongside).
val onAllow = {
},
onDismissEdit = { editTarget = null },
lnpPrompt = lnpPrompt,
onAllowLocalNetwork = {
lnpPrompt = false
localNetLauncher.launch(Manifest.permission.ACCESS_LOCAL_NETWORK)
}
val onSettings = {
},
onOpenSystemSettings = {
lnpPrompt = false
context.startActivity(
Intent(
@@ -1272,21 +919,10 @@ fun ConnectScreen(
Uri.fromParts("package", context.packageName, null),
),
)
}
if (gamepadUi) {
GamepadLocalNetworkDialog(onAllow = onAllow, onSettings = onSettings, onDismiss = { lnpPrompt = false })
} else {
LocalNetworkDialog(onAllow = onAllow, onSettings = onSettings, onDismiss = { lnpPrompt = false })
}
}
// Topmost: the full-screen connect takeover — instant "Connecting…" feedback on any dial, flowing
// seamlessly into the "Waking…" wait if the host turns out to be asleep. Rides over both the touch
// grid and the console home.
ConnectOverlay(
},
onDismissLnpPrompt = { lnpPrompt = false },
connectingHostName = attempt?.hostName,
waker = waker,
gamepadUi = gamepadUi,
onCancelConnect = { cancelConnect() },
)
}
@@ -1295,8 +931,11 @@ fun ConnectScreen(
* One entry in the saved-hosts grid: a host's own card ([pin] null), or one of its pinned
* host+profile cards. Pins are additive presentation state on the host record never duplicated
* host entries, which would fork pairing, trust and renames (design §5.2a).
*
* The console reuses it deliberately: its options dialog acts on a host-or-pin exactly as the touch
* card's overflow menu does, and one currency for "which card is this" keeps the two from drifting.
*/
private data class HostCardEntry(val host: KnownHost, val pin: StreamProfile?) {
internal data class HostCardEntry(val host: KnownHost, val pin: StreamProfile?) {
val key: String get() = "card-${host.id}-${pin?.id ?: "primary"}"
}
@@ -1305,7 +944,7 @@ private data class HostCardEntry(val host: KnownHost, val pin: StreamProfile?) {
* as a multicast-reception hedge on OEMs that filter multicast without it, but discovery (raw mDNS via
* the native core + MulticastLock) does not depend on it.
*/
fun hasNearbyPermission(context: Context): Boolean =
internal fun hasNearbyPermission(context: Context): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(context, Manifest.permission.NEARBY_WIFI_DEVICES) ==
PackageManager.PERMISSION_GRANTED
@@ -1317,7 +956,7 @@ fun hasNearbyPermission(context: Context): Boolean =
* QUIC dial surfaces as a silent handshake timeout and the mDNS browse receives nothing. Unlike
* [hasNearbyPermission] this is load-bearing nothing on the connect screen works without it.
*/
fun hasLocalNetworkPermission(context: Context): Boolean =
internal fun hasLocalNetworkPermission(context: Context): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.CINNAMON_BUN ||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_LOCAL_NETWORK) ==
PackageManager.PERMISSION_GRANTED
@@ -1327,7 +966,7 @@ fun hasLocalNetworkPermission(context: Context): Boolean =
* fingerprint when both carry it (so it survives a DHCP address change), else by address:port.
* Mirrors the Apple client's `StoredHost.matches`; de-dupes "Discovered" against "Saved hosts".
*/
private fun KnownHost.matches(dh: DiscoveredHost): Boolean {
internal fun KnownHost.matches(dh: DiscoveredHost): Boolean {
val advFp = dh.fingerprint?.lowercase()
if (!advFp.isNullOrEmpty() && fpHex.isNotEmpty() && fpHex.lowercase() == advFp) return true
return address == dh.host && port == dh.port
@@ -1337,6 +976,9 @@ private fun KnownHost.matches(dh: DiscoveredHost): Boolean {
* True when a saved host is reachable RIGHT NOW: advertising on mDNS OR answering the QUIC probe
* (a host reached over a routed network Tailscale/VPN never advertises but is reachable). The
* display-side companion to dial-first: presence no longer means "on this LAN".
*
* `internal`, not private: the touch grid draws the same pip in its own file now, and the console's
* tile builder is handed this as a lambda so it never has to know what "reachable" is made of.
*/
private fun KnownHost.isOnline(discovered: List<DiscoveredHost>, reachable: Set<String>): Boolean =
internal fun KnownHost.isOnline(discovered: List<DiscoveredHost>, reachable: Set<String>): Boolean =
discovered.any { matches(it) } || reachable.contains("$address:$port")
@@ -1,6 +1,7 @@
package io.unom.punktfunk
import android.content.Context
import android.content.res.Configuration
import android.hardware.input.InputManager
import android.os.Build
import android.os.CombinedVibration
@@ -11,12 +12,15 @@ import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ScrollState
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.ColumnScope
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -39,11 +43,15 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import io.unom.punktfunk.kit.DsDevice
import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.Sc2Capture
@@ -55,10 +63,145 @@ import kotlinx.coroutines.delay
* case where a pad "doesn't work" adapters and BT-to-USB dongles often enumerate with a different
* identity than the physical pad, or not as a gamepad at all, and punktfunk only forwards devices
* Android classifies as gamepad/joystick. This screen makes that visible on the device itself.
*
* This is the TOUCH entry point; [ConsoleControllersScreen] shows the same body on the console's
* field. Both drive [ControllersBody] the screen exists once, and the support answer it gives has
* to be the same one whichever interface asked.
*/
@Composable
fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
BackHandler(onBack = onBack)
var testing by remember { mutableStateOf(false) }
ControllersBody(
gamepadSetting = gamepadSetting,
scroll = rememberScrollState(),
testing = testing,
onTestingChange = { testing = it },
// 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.
observeInput = true,
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 24.dp),
) {
Text("Controllers", style = MaterialTheme.typography.headlineMedium)
}
}
/**
* The same screen on the console's field the couch route to it, which a TV box has no other way to
* reach (there is no touch interface to fall back to there, which is exactly why this matters).
*
* Navigation, and how the pad is shared with the test:
* * up/down scrolls, the shoulders page the body is cards and prose with no focusable rows, and
* Compose only scrolls to keep a FOCUSED child visible (see [rememberConsoleScroller]);
* * A starts the input test, which is the one thing on this screen a controller can act on;
* * while the test runs it OWNS the pad that is the whole point of it so this screen's nav
* 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) {
BackHandler(onBack = onBack)
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
val hazeState = remember { HazeState() }
val scroll = rememberScrollState()
val scrollBy = rememberConsoleScroller(scroll)
var testing by remember { mutableStateOf(false) }
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true
GamepadNavEffect2D(
// Off while the test runs: both want the same single probe slot, and the test is the one
// the user just asked for. The identity check in each teardown (here and in the body) is
// what makes the handover safe in either direction.
active = navActive && !testing,
onDirection = { dir ->
when (dir) {
NavDir.UP -> scrollBy(-1, false)
NavDir.DOWN -> scrollBy(1, false)
// Nothing on this screen steps sideways; paging is the shoulders' job.
NavDir.LEFT, NavDir.RIGHT -> {}
}
},
onActivate = { testing = true },
onShoulder = { delta -> scrollBy(delta, true) },
)
Box(Modifier.fillMaxSize()) {
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
// The calm backdrop, full-bleed under the bars and the cutout: this is a screen to READ,
// and the aurora is ambience. Only the content takes the safe area.
GamepadFormBackground(Modifier.fillMaxSize())
// The body is written against the touch theme; on the console field it has to be inked
// from the palette or it is grey-on-pastel over the six pale palettes.
ConsoleInkedTheme {
Column(Modifier.fillMaxSize().consoleSafeArea()) {
ControllersBody(
gamepadSetting = gamepadSetting,
scroll = scroll,
testing = testing,
onTestingChange = { testing = it },
// 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
// proved it is seen — by moving the cursor here.
observeInput = testing,
contentPadding = PaddingValues(
start = ConsoleEdgeInset,
end = ConsoleEdgeInset,
// Clears the floating legend zone, like every other console list.
bottom = ConsoleLegendClearance,
),
) {
ConsoleHeader("Connected controllers", horizontalInset = false)
}
}
}
}
Box(
Modifier
.align(Alignment.BottomStart)
.consoleLegendInsets(landscape)
.padding(ConsoleLegendInset),
) {
GamepadHintBar(
if (testing) {
// The rule, stated at the moment it applies: while the test runs, B is a BUTTON
// UNDER TEST like any other — it lights its own chip — so only a hold ends the
// test, after which B is the universal Back again. Tappable as the touch hatch.
listOf(PadGlyph.hint('B', "Hold to finish") { testing = false })
} else {
listOfNotNull(
GamepadHint('↕', PadGlyph.Arrow, "Scroll"),
// Advertised only where they exist — a TV remote has no shoulders, and
// claiming otherwise is both a lie and the reason a narrow legend overflows.
GamepadHint('⇄', PadGlyph.Arrow, "Page").takeIf { padIsGamepad },
PadGlyph.hint('A', "Test inputs") { testing = true },
PadGlyph.hint('B', "Done", onClick = onBack),
)
},
hazeState = hazeState,
)
}
}
}
/**
* The screen itself, shared by both interfaces. [contentPadding] and [heading] are where they
* differ: the touch screen pads for a thumb and titles with the Material headline, the console pads
* to the shared edge inset, clears its floating legend, and titles with [ConsoleHeader].
*
* [observeInput] decides whether this body installs the shared MainActivity probes at all see the
* two call sites, and [ConsoleControllersScreen] for why they cannot both be on at once.
*/
@Composable
private fun ControllersBody(
gamepadSetting: Int,
scroll: ScrollState,
testing: Boolean,
onTestingChange: (Boolean) -> Unit,
observeInput: Boolean,
contentPadding: PaddingValues,
heading: @Composable () -> Unit,
) {
val context = LocalContext.current
val activity = context as? MainActivity
@@ -84,17 +227,31 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
// Live input test. While `testing`, the MainActivity probes consume pad events (so they show up
// here instead of driving focus navigation); holding B releases, since the pad can no longer
// reach the Switch. Events are observed (not consumed) even when the test is off, so the
// "last input" line works while browsing.
var testing by remember { mutableStateOf(false) }
// reach the Switch.
val held = remember { mutableStateMapOf<Int, Boolean>() }
val axes = remember { mutableStateMapOf<String, Float>() }
var lastInput by remember { mutableStateOf<String?>(null) }
var bHeld by remember { mutableStateOf(false) }
// The hold has lasted long enough; the test ends when B is let go (see the probe).
var holdSatisfied by remember { mutableStateOf(false) }
// The probes below are built ONCE per `observeInput` and then read these for the life of that
// installation. `testing` and the callback arrive as parameters now, so capturing them plainly
// would freeze the values they had when the probe was made — the test would consume nothing.
val consuming by rememberUpdatedState(testing)
// The console's refusal thud, on whatever actuator the driving pad or this device has.
val haptics by rememberUpdatedState(rememberConsoleHaptics())
DisposableEffect(Unit) {
activity?.padKeyProbe = probe@{ event ->
DisposableEffect(observeInput) {
// Stable probe refs, and a teardown that releases the slot only if WE still hold it — the
// rule GamepadNavEffect2D follows. Without it this screen's dispose nulls whatever is in the
// slot: during the console shell's push/pop BOTH screens are briefly composed, so leaving
// here would kill the pad navigation the arriving screen had just installed. The same
// teardown also runs when this screen hands the pad to its own input test and back.
val keyProbe: (KeyEvent) -> Boolean = probe@{ event ->
if (!Gamepad.isPad(event.device)) return@probe false
// Read ONCE, up front: the test can end inside this very event, and the release that
// ended it still has to be swallowed here — see the B branch below.
val consume = consuming
when (event.action) {
KeyEvent.ACTION_DOWN -> {
held[event.keyCode] = true
@@ -102,13 +259,34 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
}
KeyEvent.ACTION_UP -> {
held[event.keyCode] = false
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_B) bHeld = false
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_B) {
bHeld = false
if (consume) {
if (event.eventTime - event.downTime >= HOLD_TO_FINISH_MS) {
// The hold ends the test HERE, on the release, and NOT the moment
// the 1.2 s elapsed: end it a moment earlier and this release falls
// through unconsumed to the activity's B→BACK remap, which takes the
// whole screen with it. Finishing the test and leaving the screen on
// one press is not what "hold B to finish" says.
onTestingChange(false)
held.clear()
} else {
// A short B is not swallowed either. While the test owns the pad, B
// is a BUTTON UNDER TEST — it lights its chip like every other — so
// a tap can't also mean "leave", and in the console B is otherwise
// the universal back. The press gets the boundary thud instead, the
// same answer a refused step gets on the settings screen: heard, and
// it means something else here.
haptics.boundary()
}
}
}
}
}
lastInput = "${event.device?.name}: ${KeyEvent.keyCodeToString(event.keyCode)}"
testing
consume
}
activity?.padMotionProbe = probe@{ event ->
val motionProbe: (MotionEvent) -> Boolean = probe@{ event ->
if (!Gamepad.isPad(event.device)) return@probe false
axes["LX"] = event.getAxisValue(MotionEvent.AXIS_X)
axes["LY"] = event.getAxisValue(MotionEvent.AXIS_Y)
@@ -124,31 +302,43 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
)
axes["HX"] = event.getAxisValue(MotionEvent.AXIS_HAT_X)
axes["HY"] = event.getAxisValue(MotionEvent.AXIS_HAT_Y)
testing
consuming
}
if (observeInput) {
activity?.padKeyProbe = keyProbe
activity?.padMotionProbe = motionProbe
}
onDispose {
activity?.padKeyProbe = null
activity?.padMotionProbe = null
activity?.let { a ->
if (a.padKeyProbe === keyProbe) a.padKeyProbe = null
if (a.padMotionProbe === motionProbe) a.padMotionProbe = null
}
}
}
// Hold-B-to-exit: with events consumed, the pad can't reach the Switch — a 1.2 s hold ends the
// test instead (touch still works). A short tap cancels the effect before the delay fires.
LaunchedEffect(bHeld) {
// test instead (touch still works). This half only ANSWERS the hold once it is long enough; the
// release is what ends the test (see the probe). Letting go early cancels the effect before the
// delay fires, so nothing is announced.
LaunchedEffect(bHeld, testing) {
if (bHeld && testing) {
delay(1_200)
testing = false
held.clear()
delay(HOLD_TO_FINISH_MS)
holdSatisfied = true
// A hold with no answer at the moment it lands is a hold you keep holding. Say it in
// both channels a couch user has: a pulse in the hands, a changed line on the screen.
haptics.confirm()
} else {
holdSatisfied = false
}
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 24.dp),
.verticalScroll(scroll)
.padding(contentPadding),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
Text("Controllers", style = MaterialTheme.typography.headlineMedium)
heading()
// Capture-side detection, re-checked on USB hot-plug. The SC2 is never an InputDevice
// (lizard mode is kb/mouse; the capture claims even those away) so it's enumerated from
@@ -212,13 +402,19 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
Column(Modifier.weight(1f)) {
Text("Test inputs", style = MaterialTheme.typography.bodyLarge)
Text(
if (testing) "Controller input stays on this screen — hold B to finish"
else "Show button presses and stick motion live",
when {
holdSatisfied -> "Release B to finish"
testing -> "Controller input stays on this screen — hold B to finish"
else -> "Show button presses and stick motion live"
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(checked = testing, onCheckedChange = { testing = it; if (!it) held.clear() })
Switch(
checked = testing,
onCheckedChange = { on -> onTestingChange(on); if (!on) held.clear() },
)
}
if (testing) {
ButtonGrid(held)
@@ -680,3 +876,11 @@ private val TEST_BUTTONS = listOf(
/** Axis bars shown in the test view, in display order. */
private val AXIS_LABELS = listOf("LX", "LY", "RX", "RY", "LT", "RT", "HX", "HY")
/**
* How long B must be held to end the input test and, below that, how long a press still counts as
* a tap that gets answered rather than ignored. One constant, because a hold that ends at 1.2 s
* while the "you tapped" answer stops at some other number leaves a window where a press does
* nothing at all.
*/
private const val HOLD_TO_FINISH_MS = 1_200L
@@ -3,6 +3,7 @@ package io.unom.punktfunk
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -18,10 +19,9 @@ 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.systemBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
@@ -31,6 +31,12 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.toggleableState
import androidx.compose.ui.state.ToggleableState
import androidx.compose.ui.text.input.KeyboardType
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
@@ -47,7 +53,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
@@ -69,6 +74,20 @@ private const val KB_ROWS = 5
private class Field(val id: String, val label: String, val value: String, val placeholder: String)
/**
* A non-text row of the EDIT form a switch or a stepped choice, driven like a settings row rather
* than opening the keyboard. Add-host mode has none: they all edit properties a host only has once
* it is saved.
*/
private class ExtraRow(
val label: String,
val value: String,
/** Non-null = draw a [ConsoleSwitch] instead of the value text. */
val toggled: Boolean?,
val adjust: (Int) -> Unit,
val activate: () -> Unit,
)
@Composable
fun GamepadAddHostScreen(
onAdd: (name: String, address: String, port: Int) -> Unit,
@@ -78,6 +97,11 @@ fun GamepadAddHostScreen(
editHost: KnownHost? = null,
suggestedMacs: List<String> = emptyList(),
onSave: ((KnownHost) -> Unit)? = null,
/**
* The profile catalog, for the edit form's binding row. Empty (the default) simply omits that
* row which is also what a device with no profiles yet gets.
*/
profiles: List<StreamProfile> = emptyList(),
) {
val ink = LocalGamepadInk.current
val context = LocalContext.current
@@ -89,6 +113,15 @@ fun GamepadAddHostScreen(
var address by remember { mutableStateOf(editHost?.address ?: "") }
var port by remember { mutableStateOf(editHost?.port?.toString() ?: "9777") }
var mac by remember { mutableStateOf(editHost?.mac?.ifEmpty { suggestedMacs }?.joinToString(", ") ?: "") }
// The two host properties the console could not reach at all until now. `copy` preserved them,
// so nothing was ever LOST — but a couch-only user (a TV box has no touch interface to fall
// back to) could never decide either one, which the touch edit sheet has always offered.
var clipboard by remember(editHost) { mutableStateOf(editHost?.clipboardSync ?: true) }
// Filtered through the live catalog, so a binding to a since-deleted profile reads as unset
// rather than as a name nothing can resolve — the same guard the touch sheet applies.
var boundId by remember(editHost, profiles) {
mutableStateOf(editHost?.profileId?.takeIf { id -> profiles.any { it.id == id } })
}
val canAdd = address.isNotBlank() && (port.toIntOrNull() ?: 0) > 0
fun commit() {
if (isEdit && editHost != null && onSave != null) {
@@ -98,6 +131,8 @@ fun GamepadAddHostScreen(
address = address.trim(),
port = port.toIntOrNull() ?: editHost.port,
mac = KnownHostStore.parseMacs(mac),
clipboardSync = clipboard,
profileId = boundId,
),
)
} else {
@@ -135,7 +170,43 @@ fun GamepadAddHostScreen(
add(Field("port", "Port", port, "9777"))
if (isEdit) add(Field("mac", "Wake MAC", mac, "auto-filled when the host is seen"))
}
val actionIndex = fields.size // the Save/Add action sits just after the last field
// The switch/choice rows, between the text fields and the action. Only in EDIT mode: both edit
// properties a host only has once it has been saved.
val extras = buildList {
if (isEdit) {
add(
ExtraRow(
label = "Shared clipboard",
value = if (clipboard) "On" else "Off",
toggled = clipboard,
// Directional = state-targeted, so holding a direction can't oscillate — the
// same rule the settings toggles and the pin picker use.
adjust = { d -> clipboard = d > 0 },
activate = { clipboard = !clipboard },
),
)
if (profiles.isNotEmpty()) {
// "Default settings" is the absence of a binding, not a profile, so it leads the
// ring as a null rather than being faked as an entry in the catalog.
val options = listOf<StreamProfile?>(null) + profiles
val idx = options.indexOfFirst { it?.id == boundId }.coerceAtLeast(0)
fun stepTo(delta: Int) {
val n = ((idx + delta) % options.size + options.size) % options.size
boundId = options[n]?.id
}
add(
ExtraRow(
label = "Profile",
value = options[idx]?.name ?: "Default settings",
toggled = null,
adjust = { d -> stepTo(d) },
activate = { stepTo(1) },
),
)
}
}
}
val actionIndex = fields.size + extras.size // the Save/Add action sits after everything
fun openKeyboard(id: String) { editing = id; kbRow = 1; kbCol = 0 }
fun closeKeyboard() { editing = null }
@@ -152,11 +223,13 @@ fun GamepadAddHostScreen(
"address" -> c != ' '
else -> true
}
/** The focused row's extra, or null when the cursor is on a text field or the action. */
fun focusedExtra(): ExtraRow? = extras.getOrNull(focus - fields.size)
fun activateField() {
if (focus == actionIndex) {
if (canAdd) commit() else { focus = 1; openKeyboard("address") }
} else {
openKeyboard(fields[focus].id)
when {
focus == actionIndex -> if (canAdd) commit() else { focus = 1; openKeyboard("address") }
focus < fields.size -> openKeyboard(fields[focus].id)
else -> focusedExtra()?.activate()
}
}
fun pressKey() {
@@ -179,7 +252,11 @@ fun GamepadAddHostScreen(
when (dir) {
NavDir.UP -> if (focus > 0) focus--
NavDir.DOWN -> if (focus < actionIndex) focus++
else -> {}
// Left/right step the switch and the profile ring, exactly as they step a
// settings row. On a text field or the action they still do nothing — there is
// no value there to walk.
NavDir.LEFT -> focusedExtra()?.adjust(-1)
NavDir.RIGHT -> focusedExtra()?.adjust(1)
}
} else {
when (dir) {
@@ -214,15 +291,17 @@ fun GamepadAddHostScreen(
// visible (stacked, the keyboard covered the whole short screen). The legend is NOT put
// under the keyboard here — it floats at the same fixed bottom-left spot as everywhere.
Row(
Modifier.fillMaxSize().systemBarsPadding().padding(start = ConsoleEdgeInset, end = 20.dp, top = 8.dp, bottom = 8.dp),
Modifier.fillMaxSize().consoleSafeArea().padding(start = ConsoleEdgeInset, end = 20.dp, top = 8.dp, bottom = 8.dp),
horizontalArrangement = Arrangement.spacedBy(18.dp),
) {
Column(
Modifier.weight(1f).fillMaxHeight().verticalScroll(rememberScrollState()),
Modifier.weight(1f).fillMaxHeight().widthIn(max = 620.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
ConsoleHeader(title, horizontalInset = false)
fields.forEachIndexed { i, f -> FieldRow(f, focused = false, editing = editing == f.id) { onFieldClick(i) } }
extras.forEachIndexed { i, e -> ExtraRowView(e, focused = false) { onFieldClick(fields.size + i) } }
AddActionRow(actionLabel, enabled = canAdd, focused = false) { onAddClick() }
Spacer(Modifier.height(64.dp)) // clear the floating legend at bottom-left
}
@@ -236,9 +315,11 @@ fun GamepadAddHostScreen(
} else {
// Portrait (or landscape not typing): the FORM SCROLLS so the Add button is never
// compressed by the keyboard; the keyboard sits below it; the legend floats (fixed).
Column(Modifier.fillMaxSize().systemBarsPadding().padding(horizontal = ConsoleEdgeInset)) {
Column(Modifier.fillMaxSize().consoleSafeArea().padding(horizontal = ConsoleEdgeInset)) {
Column(
Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()),
// Same 620 dp cap as the settings rows: a field stretched across a wide
// landscape phone is a ribbon, not an input.
Modifier.weight(1f).widthIn(max = 620.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
ConsoleHeader(title, horizontalInset = false)
@@ -251,6 +332,11 @@ fun GamepadAddHostScreen(
)
}
fields.forEachIndexed { i, f -> FieldRow(f, focused = focus == i && editing == null, editing = editing == f.id) { onFieldClick(i) } }
extras.forEachIndexed { i, e ->
ExtraRowView(e, focused = focus == fields.size + i && editing == null) {
onFieldClick(fields.size + i)
}
}
AddActionRow(actionLabel, enabled = canAdd, focused = focus == actionIndex && editing == null) { onAddClick() }
Spacer(Modifier.height(72.dp)) // last field clears the floating legend when scrolled
}
@@ -268,7 +354,7 @@ fun GamepadAddHostScreen(
// open or not), so opening the keyboard never relocates it below the keys. Backdrop-blurred.
Box(
Modifier.align(Alignment.BottomStart)
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
.consoleLegendInsets(landscape)
.padding(ConsoleLegendInset),
) {
GamepadHintBar(
@@ -315,7 +401,7 @@ private fun TvAddHostForm(
Column(
Modifier
.fillMaxSize()
.systemBarsPadding()
.consoleSafeArea()
.padding(horizontal = 56.dp, vertical = 36.dp)
.widthIn(max = 720.dp)
.verticalScroll(rememberScrollState()),
@@ -366,14 +452,18 @@ private fun rowCols(row: Int): Int = if (row < KB_ACTIONS_ROW) KB_CHAR_ROWS[row]
private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -> Unit) {
val ink = LocalGamepadInk.current
val visuals = animateConsoleFocus(active = focused || editing, editing = editing)
val shape = RoundedCornerShape(14.dp)
// The caret keeps its slot and only fades, like the settings rows' chevrons. Appending it on
// `editing` shoved the whole value left the instant the keyboard opened — the same
// layout-moves-under-focus bug the settings detail line had, one screen over.
val caretAlpha by animateFloatAsState(
if (editing) 1f else 0f,
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
label = "caret",
)
Row(
modifier = Modifier
.fillMaxWidth()
.graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
.clip(shape)
.background(visuals.background)
.border(1.dp, visuals.border, shape)
.consoleGlass(ConsoleShape.Row, visuals)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -387,7 +477,61 @@ private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (editing) Text(" |", color = ink.accent)
Text(" |", color = ink.accent, modifier = Modifier.graphicsLayer { alpha = caretAlpha })
}
}
/**
* A switch or stepped-choice row of the edit form. Deliberately the settings screen's row in
* miniature same glass, same end-aligned value slot, same `ConsoleSwitch` because it IS a
* settings row: it edits a stored property with left/right, and a user who has met one has met
* both.
*/
@Composable
private fun ExtraRowView(row: ExtraRow, focused: Boolean, onClick: () -> Unit) {
val ink = LocalGamepadInk.current
val visuals = animateConsoleFocus(active = focused)
Row(
modifier = Modifier
.fillMaxWidth()
.consoleGlass(ConsoleShape.Row, visuals)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onClick,
)
.semantics(mergeDescendants = true) {
role = if (row.toggled != null) Role.Switch else Role.Button
contentDescription = "${row.label}, ${row.value}"
row.toggled?.let {
toggleableState = if (it) ToggleableState.On else ToggleableState.Off
}
}
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
row.label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = ink.fg,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
if (row.toggled != null) {
ConsoleSwitch(on = row.toggled, focused = focused)
} else {
Text(
row.value,
style = MaterialTheme.typography.bodyMedium,
color = ink.fg(if (focused) 1f else 0.6f),
textAlign = TextAlign.End,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@@ -395,19 +539,15 @@ private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -
private fun AddActionRow(label: String, enabled: Boolean, focused: Boolean, onClick: () -> Unit) {
val ink = LocalGamepadInk.current
val visuals = animateConsoleFocus(active = focused)
val shape = RoundedCornerShape(14.dp)
val labelColor by animateColorAsState(
if (enabled) ink.accent else ink.fg(0.35f),
tween(160),
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
label = "addLabel",
)
Box(
modifier = Modifier
.fillMaxWidth()
.graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
.clip(shape)
.background(visuals.background)
.border(1.dp, visuals.border, shape)
.consoleGlass(ConsoleShape.Row, visuals)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick)
.padding(vertical = 14.dp),
contentAlignment = Alignment.Center,
@@ -430,14 +570,16 @@ private fun KeyboardGrid(
onKey: (Int, Int) -> Unit,
) {
val ink = LocalGamepadInk.current
val shape = RoundedCornerShape(20.dp)
val shape = ConsoleShape.Keyboard
val gap = if (compact) 5.dp else 7.dp
Column(
Modifier
.fillMaxWidth()
.widthIn(max = 640.dp)
.clip(shape)
.background(Color(0x1FFFFFFF))
// Palette glass, lifted a touch above a row's: the keyboard is a slab the keys sit on,
// and a hardcoded white wash was the one surface a pale palette couldn't recolour.
.background(ink.glass.copy(alpha = (ink.glass.alpha * 1.5f).coerceAtMost(1f)))
.border(1.dp, ink.fg(0.12f), shape)
.padding(start = 12.dp, end = 12.dp, top = if (compact) 8.dp else 12.dp, bottom = 12.dp + bottomInset),
verticalArrangement = Arrangement.spacedBy(gap),
@@ -467,11 +609,13 @@ private fun Keycap(label: String, focused: Boolean, compact: Boolean, modifier:
tween(90),
label = "keyBg",
)
val fg by animateColorAsState(if (focused) Color.Black else ink.fg, tween(90), label = "keyFg")
// `onAccent`, not black: a pale palette's accent can be light enough that black-on-it is the
// unreadable combination, and the palette already resolved which way that goes.
val fg by animateColorAsState(if (focused) ink.onAccent else ink.fg, tween(90), label = "keyFg")
Box(
modifier = modifier
.height(if (compact) 34.dp else 44.dp)
.clip(RoundedCornerShape(9.dp))
.clip(ConsoleShape.Keycap)
.background(bg)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick),
contentAlignment = Alignment.Center,
@@ -0,0 +1,348 @@
package io.unom.punktfunk
import android.graphics.RuntimeShader
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.withInfiniteAnimationFrameMillis
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ShaderBrush
import java.util.Locale
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.max
import kotlin.math.sin
// The living console backdrop, in two renderings of ONE design.
//
// On API 33+ this is the desktop console's actual field: `pf-console-ui`'s `mesh_sksl`
// (library.rs) ported to AGSL — a 4×4 bicubic colour mesh warped by four drifting interior points,
// swayed ±8° in hue, vignetted and scrimmed. AGSL is the SkSL subset Android 13 ships, so the
// shader body is very nearly the same source, and `GamepadPalette.meshColors` is literally the same
// 16-cell table the Rust samples. Below 33 (`RuntimeShader` is 33+) the field falls back to four
// drifting radial blobs sampled from the same palette ramp — an approximation of the same look, and
// the honest one: emulating a mesh gradient with bitmaps would cost more than it bought.
//
// Either way it is AMBIENCE, never content: it runs full-bleed under the cutout and the system bars,
// and every console screen's chrome floats over it.
/**
* The console backdrop. [calm] is what the FORM screens (settings, add-host) wear: the pools dim
* onto the ground so the glass rows keep real colour and luminance without the launcher's contrast.
* Motion is identical either way on purpose only the contrast differs, so moving between screens
* can't make the field jump.
*
* Honours the system's "remove animations" accessibility setting by freezing at a fixed phase, the
* same courtesy the Apple client pays Reduce Motion which doubles as the deterministic mode the
* screenshot harness captures in, since the phase is just a uniform.
*/
@Composable
fun GamepadAuroraBackground(modifier: Modifier = Modifier, calm: Boolean = false) {
val palette = LocalGamepadPalette.current
val animated = animationsEnabled()
// Compiled once per palette and cached process-wide: stepping the Background row recolours the
// field under the very row being stepped, and a shader compile per D-pad press would be felt on
// a TV box. A compile failure resolves null and takes the blob path — a vendor Skia that
// rejects the source must not take the console UI down with it.
val shader = if (Build.VERSION.SDK_INT >= 33) {
remember(palette.id) { meshShaderFor(palette) }
} else {
null
}
if (shader != null) {
MeshAurora(modifier, shader, calm, animated)
} else {
BlobAurora(modifier, palette, calm, animated)
}
}
/**
* The backdrop for the console FORM screens (settings, add-host) the launcher's own living field
* at `calm`, so no screen in the console UI is backed by a still image and the palette setting
* reaches every one of them. Mirrors the Apple client's GamepadFormBackground and the desktop's
* single `calm` uniform.
*/
@Composable
fun GamepadFormBackground(modifier: Modifier = Modifier) {
GamepadAuroraBackground(modifier, calm = true)
}
// --- The mesh field (API 33+) ---------------------------------------------------------------
/** The phase a frozen (reduce-motion / screenshot) field is drawn at — the desktop's t = 0. */
private const val FROZEN_PHASE = 0f
@RequiresApi(33)
@Composable
private fun MeshAurora(
modifier: Modifier,
shader: RuntimeShader,
calm: Boolean,
animated: Boolean,
) {
val ink = LocalGamepadInk.current
val palette = LocalGamepadPalette.current
val brush = remember(shader) { ShaderBrush(shader) }
// Real monotonic seconds, not a wrapping sweep: the four warp points and the hue sway run at
// mutually irrational rates (periods ~90130 s), so no loop length exists that would rejoin
// them seamlessly — which is exactly why the desktop feeds its shader elapsed time too. Frozen
// under reduce-motion, where it also makes the field deterministic for a screenshot.
val time by produceState(FROZEN_PHASE, animated) {
if (!animated) return@produceState
while (true) {
withInfiniteAnimationFrameMillis { value = it / 1000f }
}
}
val (gr, gg, gb) = palette.ground
Canvas(modifier) {
// Uniforms are set per draw, not per recomposition: `time` is read HERE, inside the draw
// scope, so a new frame invalidates the draw only — the composition never re-runs.
shader.setFloatUniform("u_res", size.width, size.height)
shader.setFloatUniform("u_tc", time, if (calm) 1f else 0f)
// The calm lift: the palette's ground scaled to 0.4, what the field flattens toward.
shader.setFloatUniform(
"u_lift",
(gr * 0.4).toFloat(), (gg * 0.4).toFloat(), (gb * 0.4).toFloat(), 0f,
)
// Where the vignette and scrims tend, and how hard — black at full strength on a dark
// field, white at well under half on a pale one (mixing a pastel toward white at the dark
// field's strength bleaches the chroma straight out of the gradient).
shader.setFloatUniform(
"u_scrim",
ink.shade.red, ink.shade.green, ink.shade.blue, ink.shadeScale,
)
drawRect(brush)
}
}
/**
* Compiled mesh shaders by palette id at most the 13 shipped palettes, so it is bounded by the
* table rather than by use. Touched only from the composition (main) thread.
*/
private val meshShaders = HashMap<String, RuntimeShader?>()
@RequiresApi(33)
private fun meshShaderFor(palette: GamepadPalette): RuntimeShader? =
meshShaders.getOrPut(palette.id) {
runCatching { RuntimeShader(meshAgsl(palette.meshColors)) }.getOrNull()
}
/**
* Format a shader constant. `Locale.ROOT` is not optional: `String.format` on a German-locale
* device emits `0,075`, which is a syntax error in the shader source and would take the whole
* backdrop out on exactly the devices it was authored on. `%f` also keeps a very small ramp value
* out of exponent notation, which SkSL would still parse but nobody would enjoy reading.
*/
private fun n(v: Double): String = String.format(Locale.ROOT, "%.6f", v)
/**
* The mesh gradient as AGSL, the palette baked into the source and resolution/time/calm/scrim left
* as uniforms the direct port of `pf-console-ui`'s `mesh_sksl`, kept structurally line-for-line
* with it so the two can be diffed. A smooth bicubic blend of the 16 colours (a separable
* cubic-Bézier basis in x then y, the fragment-shader analogue of SwiftUI's
* `MeshGradient(smoothsColors: true)`), four interior points driving a bounded domain warp, then
* the ±8° hue sway, an elliptical vignette and the vertical legibility scrim.
*/
private fun meshAgsl(colors: List<Triple<Double, Double, Double>>): String {
fun c(i: Int): String {
val (r, g, b) = colors[i]
return "float3(${n(r)}, ${n(g)}, ${n(b)})"
}
// The four interior-point domain-warp accumulators. SIG (0.30) sets how far each point's pull
// reaches; the warp is the weight-normalised average displacement, so |warp| ≤ max|amp|.
val warp = buildString {
for (p in GamepadPalette.MESH_INTERIOR) {
append(" q = uv - float2(${n(p.x)}, ${n(p.y)});\n")
append(" ww = exp(-dot(q, q) / (2.0 * 0.30 * 0.30));\n")
append(" d = float2(${n(p.amp)} * sin(tt * ${n(p.sx)} + ${n(p.phase)}),\n")
append(" ${n(p.amp)} * cos(tt * ${n(p.sy)} + ${n(p.phase)} * 1.3));\n")
append(" wsum += d * ww; wtot += ww;\n")
}
}
return """
uniform float2 u_res;
// x = seconds since this field started, y = the calm mix (0 launcher, 1 form).
uniform float2 u_tc;
// rgb = the palette's corner colour scaled for the calm lift; a is unused.
uniform float4 u_lift;
// rgb = what the vignette and scrims tend toward, a = how hard.
uniform float4 u_scrim;
// Cubic-Bézier basis over four control values — the smooth 4-point blend per axis.
float bz(float t, float a, float b, float c, float d) {
float u = 1.0 - t;
return u*u*u*a + 3.0*u*u*t*b + 3.0*u*t*t*c + t*t*t*d;
}
float3 bz3(float t, float3 a, float3 b, float3 c, float3 d) {
return float3(bz(t, a.r, b.r, c.r, d.r), bz(t, a.g, b.g, c.g, d.g), bz(t, a.b, b.b, c.b, d.b));
}
// Hue rotation about the grey axis (Rodrigues) — the ±8° warm/cool sway. The desktop's `cross(k,
// col)` is written out here: with k = (c, c, c) it collapses to c·(b-g, r-b, g-r), which needs no
// builtin at all — AGSL's function set is a subset of SkSL's and not worth betting the field on.
float3 hue(float3 col, float a) {
float c = 0.5773503;
float cs = cos(a); float sn = sin(a);
float3 kx = c * float3(col.b - col.g, col.r - col.b, col.g - col.r);
return col*cs + kx*sn + float3(c) * dot(float3(c), col) * (1.0 - cs);
}
half4 main(float2 xy) {
float tt = u_tc.x; float calm = u_tc.y;
float2 uv = xy / u_res;
// Interior control points wander → bounded domain warp (pools follow them).
float2 wsum = float2(0.0); float wtot = 0.0; float2 q; float ww; float2 d;
$warp
uv = clamp(uv - wsum / (wtot + 0.0001), 0.0, 1.0);
// Bicubic blend of the 16 mesh colours: cubic-Bézier in x per row, then in y.
float3 r0 = bz3(uv.x, ${c(0)}, ${c(1)}, ${c(2)}, ${c(3)});
float3 r1 = bz3(uv.x, ${c(4)}, ${c(5)}, ${c(6)}, ${c(7)});
float3 r2 = bz3(uv.x, ${c(8)}, ${c(9)}, ${c(10)}, ${c(11)});
float3 r3 = bz3(uv.x, ${c(12)}, ${c(13)}, ${c(14)}, ${c(15)});
float3 col = bz3(uv.y, r0, r1, r2, r3);
col = hue(col, sin(tt * 0.021) * 0.1396263);
// Calm: flatten the field toward its own corner colour — the pools dim and the corners lift,
// so a form screen keeps real colour under its glass rows while losing the launcher's
// contrast. Motion is untouched.
col = mix(col, col * 0.60 + u_lift.rgb, calm);
// Elliptical vignette: clear at r=0.25 → scrim·0.42 at r=1.15. Halved under calm — a
// launcher's cards sit in the pooled centre, but a form screen's rows run out toward the
// edges, where crushing them just eats the list.
float2 e = (xy / u_res - 0.5) * 2.0;
float vig = clamp((length(e) - 0.25) / 0.90, 0.0, 1.0) * mix(0.42, 0.21, calm) * u_scrim.a;
col = mix(col, u_scrim.rgb, vig);
// Vertical legibility scrim for the pinned heading + the floating legend.
float v = xy.y / u_res.y;
float s = v < 0.32 ? mix(0.38, 0.06, v / 0.32)
: v < 0.68 ? mix(0.06, 0.08, (v - 0.32) / 0.36)
: mix(0.08, 0.40, (v - 0.68) / 0.32);
col = mix(col, u_scrim.rgb, s * u_scrim.a);
return half4(half3(col), 1.0);
}
"""
}
// --- The blob field (API 2832 fallback) -----------------------------------------------------
/**
* One drifting blob of the fallback field: where it sits, how far it wanders, and how fast. Integer
* [sx]/[sy] keep the loop seamless at wrap. The COLOUR is the palette's, taken from its ramp at
* draw time, so the field always shows several of that palette's tones at once.
*/
private class AuroraBlob(
val baseX: Float,
val baseY: Float,
val driftX: Float,
val driftY: Float,
val sx: Int,
val sy: Int,
val phase: Float,
val radiusFrac: Float,
val alpha: Float,
)
private val auroraBlobs = listOf(
AuroraBlob(0.30f, 0.26f, 0.16f, 0.10f, 1, 1, 0.0f, 0.62f, 0.55f),
AuroraBlob(0.78f, 0.68f, 0.13f, 0.14f, 1, 2, 2.4f, 0.68f, 0.58f),
AuroraBlob(0.16f, 0.82f, 0.12f, 0.09f, 2, 1, 4.1f, 0.52f, 0.42f),
AuroraBlob(0.72f, 0.14f, 0.10f, 0.08f, 1, 3, 1.2f, 0.48f, 0.40f),
)
/**
* Soft blobs from the palette's ramp drifting over its ground on slow, seamless loops, finished
* with a centre-pooling vignette and top/bottom legibility scrims. What API 2832 sees in place of
* the mesh: the same colour families, the same "ambience, never content" role, and the same
* [GamepadPalette] setting recolours it.
*/
@Composable
private fun BlobAurora(
modifier: Modifier,
palette: GamepadPalette,
calm: Boolean,
animated: Boolean,
) {
val ink = LocalGamepadInk.current
val transition = rememberInfiniteTransition(label = "aurora")
// A full 0..2π sweep over ~96 s; integer per-blob multipliers make sin/cos continuous at the
// wrap so the field never visibly jumps when the animation restarts.
val swept by transition.animateFloat(
initialValue = 0f,
targetValue = (2 * PI).toFloat(),
animationSpec = infiniteRepeatable(tween(96_000, easing = LinearEasing), RepeatMode.Restart),
label = "angle",
)
val angle = if (animated) swept else 0f
val tones = palette.blobColors
val ground = palette.groundColor
// Where the scrims tend, and how hard. Mixing a PALE field toward white at the dark field's
// strength bleaches the chroma straight out of the gradient, so a pale palette gets under
// half — the same scrim strength the desktop console's shader carries.
val scrim = if (palette.light) ink.fg else Color.Black
val strength = if (palette.light) 0.45f else 1f
Canvas(modifier) {
drawRect(ground)
val span = max(size.width, size.height)
for ((i, b) in auroraBlobs.withIndex()) {
val cx = (b.baseX + b.driftX * sin(angle * b.sx + b.phase)) * size.width
val cy = (b.baseY + b.driftY * cos(angle * b.sy + b.phase)) * size.height
val r = span * b.radiusFrac
// Calm scales each blob's contribution rather than dimming the whole canvas: the
// ground stays put and only the pools come down to meet it, which is the same "lower
// the contrast, keep the colour" the desktop console's `calm` uniform does.
val alpha = if (calm) b.alpha * 0.62f else b.alpha
drawCircle(
brush = Brush.radialGradient(
colors = listOf(tones[i].copy(alpha = alpha), Color.Transparent),
center = Offset(cx, cy),
radius = r,
),
center = Offset(cx, cy),
radius = r,
// Additive only works over a DARK ground; over a pale one every blob
// saturates to white and the field turns grey. Pale palettes tint instead.
blendMode = if (palette.light) BlendMode.SrcOver else BlendMode.Plus,
)
}
// Cinematic vignette: pool light centre, settle the corners toward the scrim. Halved under
// calm: a launcher's cards sit in the pooled centre, but a form screen's rows run out
// toward the edges, where crushing them just eats the list.
drawRect(
Brush.radialGradient(
colors = listOf(
Color.Transparent,
scrim.copy(alpha = (if (calm) 0.22f else 0.44f) * strength),
),
center = Offset(size.width / 2, size.height / 2),
radius = span * 0.92f,
),
)
// Top/bottom legibility scrim for the pinned title + hint bar.
drawRect(
Brush.verticalGradient(
0.0f to scrim.copy(alpha = 0.40f * strength),
0.30f to scrim.copy(alpha = 0.05f * strength),
0.70f to scrim.copy(alpha = 0.06f * strength),
1.0f to scrim.copy(alpha = 0.42f * strength),
),
)
}
}
File diff suppressed because it is too large Load Diff
@@ -6,7 +6,6 @@ import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -18,7 +17,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
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.heightIn
import androidx.compose.foundation.layout.padding
@@ -45,7 +43,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
@@ -107,18 +104,13 @@ fun GamepadDialog(
// the focused button pulls itself into view (see DialogButton), so D-pad navigation always shows
// the current action even when the stack scrolls.
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
Box(
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)),
contentAlignment = Alignment.Center,
) {
ConsoleModal {
Column(
Modifier
.padding(24.dp)
.widthIn(max = 520.dp)
.heightIn(max = maxCardHeight)
.clip(RoundedCornerShape(24.dp))
.background(Color(0xF01A1730))
.border(1.dp, ink.fg(0.12f), RoundedCornerShape(24.dp))
.consoleCard()
.padding(28.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
@@ -150,7 +142,11 @@ private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enab
// that's scrolled out of a short window, pull it into view (no-op when already visible).
val intoView = remember { BringIntoViewRequester() }
LaunchedEffect(focused) { if (focused) intoView.bringIntoView() }
val shape = RoundedCornerShape(14.dp)
val focus by animateFloatAsState(
if (focused) 1f else 0f,
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
label = "btnFocus",
)
// Focus sweeps up/down the stack — cross-fade the fills so it glides instead of snapping.
val bg by animateColorAsState(
when {
@@ -158,32 +154,30 @@ private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enab
primary -> ink.accent(0.20f)
else -> ink.glass
},
tween(160),
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
label = "btnBg",
)
val fg by animateColorAsState(
when {
!enabled -> ink.fg(0.35f)
focused -> ink.fg
// On the accent, not on the field — a pale palette's accent decides this, not the ink.
focused -> ink.onAccent
primary -> ink.accent
else -> ink.fg(0.85f)
},
tween(160),
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
label = "btnFg",
)
val borderColor by animateColorAsState(
Color.White.copy(alpha = if (focused) 0.3f else 0.08f),
tween(160),
ink.fg(if (focused) 0.3f else 0.08f),
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
label = "btnBorder",
)
Box(
modifier = Modifier
.fillMaxWidth()
.bringIntoViewRequester(intoView)
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(shape)
.background(bg)
.border(1.dp, borderColor, shape)
.consoleGlass(ConsoleShape.Row, ConsoleFocusVisuals(scale, bg, borderColor, focus))
.clickable(
enabled = enabled,
interactionSource = remember { MutableInteractionSource() },
@@ -305,18 +299,13 @@ fun GamepadPinHostsDialog(
},
)
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
Box(
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)),
contentAlignment = Alignment.Center,
) {
ConsoleModal {
Column(
Modifier
.padding(24.dp)
.widthIn(max = 520.dp)
.heightIn(max = maxCardHeight)
.clip(RoundedCornerShape(24.dp))
.background(Color(0xF01A1730))
.border(1.dp, ink.fg(0.12f), RoundedCornerShape(24.dp))
.consoleCard()
.padding(28.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
@@ -368,15 +357,11 @@ private fun PinHostRow(label: String, on: Boolean, focused: Boolean, onClick: ()
// landscape window pulls itself into view.
val intoView = remember { BringIntoViewRequester() }
LaunchedEffect(focused) { if (focused) intoView.bringIntoView() }
val shape = RoundedCornerShape(14.dp)
Row(
Modifier
.fillMaxWidth()
.bringIntoViewRequester(intoView)
.graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
.clip(shape)
.background(visuals.background)
.border(1.dp, visuals.border, shape)
.consoleGlass(ConsoleShape.Row, visuals)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
@@ -398,157 +383,6 @@ private fun PinHostRow(label: String, on: Boolean, focused: Boolean, onClick: ()
}
}
/**
* Console counterpart of [SpeedTestDialog]. Same measurement, same targeting rule a TV box on a
* powerline adapter is exactly the machine whose link is worth measuring, so this belongs on the
* couch surface too, even though profile EDITING doesn't.
*/
@Composable
fun GamepadSpeedTestDialog(
hostName: String,
target: SpeedTestTarget,
phase: SpeedTestPhase,
onApply: (toProfile: Boolean) -> Unit,
onDismiss: () -> Unit,
) {
val done = phase as? SpeedTestPhase.Done
GamepadDialog(
title = "Network speed test",
onDismiss = onDismiss,
actions = buildList {
if (done != null) {
add(
DialogAction(
when (target) {
SpeedTestTarget.Global -> "Apply"
is SpeedTestTarget.Profile -> "Apply to “${target.profile.name}"
is SpeedTestTarget.Ask -> "Set in “${target.profile.name}"
},
primary = true,
) { onApply(true) },
)
if (target is SpeedTestTarget.Ask) {
add(DialogAction("Set as default") { onApply(false) })
}
}
add(DialogAction("Close", primary = done == null, onClick = onDismiss))
},
) {
DialogText(hostName)
when (phase) {
SpeedTestPhase.Connecting -> DialogText("Connecting…")
SpeedTestPhase.Measuring ->
DialogText("Measuring — the host is bursting test traffic for two seconds.")
is SpeedTestPhase.Failed -> DialogText(phase.message)
is SpeedTestPhase.Done -> {
DialogText(
"%.0f Mbit/s measured · %.1f %% loss".format(phase.measuredMbps, phase.lossPct),
)
DialogText("Recommended bitrate: %.0f Mbit/s".format(phase.recommendedMbps))
}
}
}
}
/** Console counterpart of [LocalNetworkDialog] — the Android 17+ ACCESS_LOCAL_NETWORK rationale. */
@Composable
fun GamepadLocalNetworkDialog(onAllow: () -> Unit, onSettings: () -> Unit, onDismiss: () -> Unit) {
GamepadDialog(
title = "Allow local network access",
onDismiss = onDismiss,
actions = listOf(
DialogAction("Allow", primary = true, onClick = onAllow),
DialogAction("Open settings", onClick = onSettings),
DialogAction("Not now", onClick = onDismiss),
),
) {
DialogText(
"Android blocks Punktfunk from talking to devices on your network, so it can't find " +
"or reach any host until you allow it.",
)
DialogText(
"If no prompt appears after Allow, enable “Nearby devices” for Punktfunk in " +
"system settings.",
)
}
}
@Composable
fun GamepadTrustNewDialog(pt: PendingTrust, onTrust: () -> Unit, onPairInstead: () -> Unit, onDismiss: () -> Unit) {
GamepadDialog(
title = "Trust this host?",
onDismiss = onDismiss,
actions = listOf(
DialogAction("Cancel", onClick = onDismiss),
DialogAction("Pair with PIN", onClick = onPairInstead),
DialogAction("Trust (TOFU)", primary = true, onClick = onTrust),
),
) {
DialogText("First connection to ${pt.host}:${pt.port}.")
pt.advertisedFp?.let { DialogText("Fingerprint ${it.take(16)}") }
DialogText(
"This host allows trust-on-first-use, but that can't tell an impostor from the real host. " +
"Pairing with a PIN is stronger — it proves both sides.",
)
}
}
@Composable
fun GamepadFingerprintChangedDialog(pt: PendingTrust, onRepair: () -> Unit, onDismiss: () -> Unit) {
GamepadDialog(
title = "Host identity changed",
onDismiss = onDismiss,
actions = listOf(
DialogAction("Cancel", onClick = onDismiss),
DialogAction("Re-pair", primary = true, onClick = onRepair),
),
) {
DialogText(
"The pinned fingerprint for ${pt.host} no longer matches what it now advertises. This can " +
"mean a host reinstall — or an impostor. Re-pair with the host's PIN to continue.",
)
}
}
@Composable
fun GamepadRequestAccessDialog(pt: PendingTrust, onRequestAccess: () -> Unit, onUsePin: () -> Unit, onDismiss: () -> Unit) {
GamepadDialog(
title = "Pairing required",
onDismiss = onDismiss,
actions = listOf(
DialogAction("Cancel", onClick = onDismiss),
DialogAction("Use a PIN", onClick = onUsePin),
DialogAction("Request access", primary = true, onClick = onRequestAccess),
),
) {
DialogText("${pt.host}:${pt.port} requires pairing before it will stream.")
DialogText(
"Request access and approve this device in the host's console (or web UI) — no PIN needed. " +
"Or pair with the 4-digit PIN the host displays.",
)
}
}
@Composable
fun GamepadAwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) {
val ink = LocalGamepadInk.current
GamepadDialog(
title = "Waiting for approval",
onDismiss = onCancel,
actions = listOf(DialogAction("Cancel", primary = true, onClick = onCancel)),
) {
val deviceName = Build.MODEL ?: "this device"
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = ink.fg)
Text("Approve this device on $hostLabel.", color = ink.fg)
}
DialogText(
"Open the host's console (or web UI) and approve “$deviceName”. It connects automatically " +
"once you approve — no PIN needed.",
)
}
}
/**
* Console PIN pairing: four digit slots set with the D-pad (left/right selects a slot, up/down changes
* 09), then Pair. Runs [NativeBridge.nativePair] off the UI thread; on success hands the verified
@@ -598,11 +432,10 @@ fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired:
)
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)), contentAlignment = Alignment.Center) {
ConsoleModal {
Column(
Modifier.padding(24.dp).widthIn(max = 460.dp).heightIn(max = maxCardHeight)
.clip(RoundedCornerShape(24.dp))
.background(Color(0xF01A1730)).border(1.dp, ink.fg(0.12f), RoundedCornerShape(24.dp))
.consoleCard()
.verticalScroll(rememberScrollState())
.padding(28.dp),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -616,7 +449,7 @@ fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired:
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
repeat(4) { i -> PinSlot(digits[i], focused = slot == i && !pairing) }
}
err?.let { Text(it, color = Color(0xFFE0736F), style = MaterialTheme.typography.bodyMedium) }
err?.let { Text(it, color = ink.danger, style = MaterialTheme.typography.bodyMedium) }
DialogButton(
label = if (pairing) "Pairing…" else "Pair",
focused = slot == 4 && !pairing,
@@ -638,6 +471,12 @@ private fun PinSlot(value: Int, focused: Boolean) {
.border(if (focused) 2.dp else 1.dp, if (focused) ink.accent else ink.fg(0.1f), shape),
contentAlignment = Alignment.Center,
) {
Text(value.toString(), fontSize = 30.sp, fontWeight = FontWeight.Bold, color = ink.fg, fontFamily = FontFamily.Monospace)
Text(
value.toString(),
fontSize = 30.sp,
fontWeight = FontWeight.Bold,
color = ink.fg,
fontFamily = FontFamily.Monospace,
)
}
}
@@ -1,8 +1,10 @@
package io.unom.punktfunk
import android.content.res.Configuration
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
@@ -17,11 +19,11 @@ 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.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PageSize
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
@@ -33,6 +35,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -44,6 +47,7 @@ import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
@@ -55,6 +59,7 @@ import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import io.unom.punktfunk.kit.security.KnownHost
import kotlin.math.absoluteValue
import kotlin.math.cos
import kotlinx.coroutines.launch
// The gamepad-driven home — the Android mirror of the Apple client's GamepadHomeView: a distinct,
@@ -62,6 +67,12 @@ import kotlinx.coroutines.launch
// active. A center-snapping carousel of hosts (saved first, then discovered, then a trailing Add
// Host tile), driven from the couch: A connects, X opens Settings, Y opens a saved host's library.
/**
* How far a fully off-centre card turns away from the viewer, in radians (~48°). Never rendered as
* a rotation see the projection note at the call site.
*/
private const val CARD_TURN_RAD = 0.838f
/** One navigable launcher tile — a saved host, a discovered-but-unsaved host, or the Add Host action. */
class HomeTile(
val id: String,
@@ -79,6 +90,15 @@ class HomeTile(
* belong to the host's own tile, and this one offers only Unpin.
*/
val pinnedProfileId: String? = null,
/**
* The profile a press will actually connect with the host's binding, or the pin's own
* profile. Rendered as a chip on the card rather than appended to the subtitle: on a PIN card
* the profile is the entire reason the card exists, and a card that only whispers it in grey
* body text can't say that. Matches the Apple client's tile.
*/
val profileName: String? = null,
/** The profile's `#RRGGBB` chip colour, if it set one. */
val profileAccent: Color? = null,
val activate: () -> Unit,
) {
// Any SAVED host offers the library (matches Apple) — the fetch itself returns a clear "pair
@@ -118,6 +138,16 @@ fun GamepadHome(
LaunchedEffect(pagerState.settledPage) { navTarget = pagerState.settledPage }
val current = tiles.getOrNull(navTarget)
// Bumped on every confirm — the centred card dips under the press and springs back, so A reads
// as a button being pushed rather than as a screen simply changing.
var pressToken by remember { mutableIntStateOf(0) }
val press = remember { Animatable(1f) }
LaunchedEffect(pressToken) {
if (pressToken == 0) return@LaunchedEffect
press.animateTo(0.97f, ConsoleMotion.ease(70))
press.animateTo(1f, spring(dampingRatio = 0.45f, stiffness = Spring.StiffnessMedium))
}
GamepadNavEffect(
active = navActive && tiles.isNotEmpty(),
onMove = { dir ->
@@ -127,7 +157,8 @@ fun GamepadHome(
scope.launch { pagerState.animateScrollToPage(target) }
}
},
onActivate = { tiles.getOrNull(navTarget)?.let(onActivate) }, // A / D-pad-center → Connect
// A / D-pad-center → Connect
onActivate = { pressToken++; tiles.getOrNull(navTarget)?.let(onActivate) },
onSecondary = { // Y (gamepad) → Library
tiles.getOrNull(navTarget)?.takeIf { libraryEnabled && it.hasLibrary }?.let(onOpenLibrary)
},
@@ -145,9 +176,9 @@ fun GamepadHome(
// way. Each hint is also TAPPABLE (touch hatch).
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: false
val connectLabel = if (current?.isAdd == true) "Add Host" else "Connect"
val connectAction: () -> Unit = { tiles.getOrNull(navTarget)?.let(onActivate) }
val connectAction: () -> Unit = { pressToken++; tiles.getOrNull(navTarget)?.let(onActivate) }
val optionsAction: () -> Unit = { current?.let(onOptions) }
val arrowTint = Color(0xFF9A93C7)
val arrowTint = PadGlyph.Arrow
val hints = buildList {
if (padIsGamepad) {
add(PadGlyph.hint('A', connectLabel, onClick = connectAction))
@@ -177,7 +208,12 @@ fun GamepadHome(
val cardWidth = (maxWidth * 0.82f).coerceAtMost(360.dp)
val cardHeight = (maxHeight * 0.56f).coerceAtMost(216.dp)
val sidePad = ((maxWidth - cardWidth) / 2).coerceAtLeast(0.dp)
Box(Modifier.fillMaxSize().systemBarsPadding()) {
// The carousel deliberately IGNORES the safe area (first on-glass verdict): only the
// CENTRED card matters, and it sits mid-screen; the fanned neighbours running under
// the hole punch is ambience, while insetting the pager CLIPPED them at the cutout
// edge — cards visibly cut off is worse than cards behind a camera. The title and the
// legend keep their insets; they are content.
Box(Modifier.fillMaxSize()) {
HorizontalPager(
state = pagerState,
pageSize = PageSize.Fixed(cardWidth),
@@ -189,17 +225,35 @@ fun GamepadHome(
val tile = tiles[page]
// Real distance-from-centered (page + fractional drag), so the pop tracks the
// live scroll: centered tile at full scale/brightness, neighbours recede + blur.
val offset = ((pagerState.currentPage - page) + pagerState.currentPageOffsetFraction)
.absoluteValue.coerceIn(0f, 1f)
// Signed, because which SIDE a card fans to decides which edge it turns on.
val signed = (page - pagerState.currentPage) - pagerState.currentPageOffsetFraction
val offset = signed.absoluteValue.coerceIn(0f, 1f)
GamepadHostTile(
tile = tile,
centred = offset < 0.5f,
modifier = Modifier
.graphicsLayer {
val s = lerp(1f, 0.86f, offset)
// The press dip applies to the CENTRED card only — it is the one
// the button acted on, and a whole carousel flinching would read
// as the screen moving rather than a card being pressed.
val s = lerp(1f, 0.86f, offset) * lerp(press.value, 1f, offset)
scaleX = s
scaleY = s
alpha = lerp(1f, 0.5f, offset)
}
.graphicsLayer {
// The neighbours TURN away, projected rather than rendered in 3D.
// `cos(angle)` as a horizontal squeeze IS the orthographic
// projection of a Y-axis rotation, and hinging it on the edge the
// card fans from is what carries the direction the rotation's sign
// would have. The Apple client arrived here the hard way (see
// GamepadCarousel.swift): a real `rotation3DEffect` renders the
// card through an offscreen pass and flashed as the strip settled.
// Affine transforms don't.
scaleX = cos(CARD_TURN_RAD * offset)
transformOrigin =
TransformOrigin(if (signed > 0f) 0f else 1f, 0.5f)
}
// Unbounded so the depth blur isn't hard-clipped at the card's rectangle
// (the cut-off edge). No-op below API 31; a soft blur above.
.blur(radius = (offset * 12f).dp, edgeTreatment = BlurredEdgeTreatment.Unbounded)
@@ -209,6 +263,7 @@ fun GamepadHome(
indication = null,
) {
if (page == navTarget) {
pressToken++
onActivate(tile)
} else {
navTarget = page
@@ -223,20 +278,28 @@ fun GamepadHome(
// Title floats over the top (out of the carousel's layout, so the cards stay centred). Uses
// the shared ConsoleHeader so it lines up with every other screen's heading.
Row(
Modifier.align(Alignment.TopStart).fillMaxWidth().systemBarsPadding()
Modifier.align(Alignment.TopStart).fillMaxWidth().consoleSafeArea()
.padding(end = ConsoleEdgeInset),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
ConsoleHeader("Select a Host", modifier = Modifier.weight(1f))
if (controllerName != null) ControllerStatusChip(controllerName)
// The TITLE has priority (unweighted, so it is measured at its full width first) and the
// chip takes what is left, ellipsizing its device name. The other way round — which is
// what a weighted header gave — a talkative controller name ("Xbox Wireless Controller")
// ate a 360 dp portrait phone's title down to "Selec…".
ConsoleHeader("Select a Host")
if (controllerName != null) {
ControllerStatusChip(controllerName, Modifier.weight(1f, fill = false))
}
}
// Legend floats bottom-start with a real backdrop blur of the content behind it. In LANDSCAPE
// it ignores the safe area (the nav-bar inset made the bottom gap look oversized).
// it ignores the system bars (the nav-bar inset made the bottom gap look oversized) but never
// the cutout — reverse-landscape parks the punch on this very corner.
Box(
Modifier
.align(Alignment.BottomStart)
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
.consoleLegendInsets(landscape)
.padding(ConsoleLegendInset),
) {
GamepadHintBar(hints, hazeState = hazeState)
@@ -244,22 +307,31 @@ fun GamepadHome(
}
}
/** One dark-glass landscape console tile — bigger and bolder than the touch grid's HostCard. */
/**
* One glass landscape console tile bigger and bolder than the touch grid's HostCard, and cut from
* the same [Modifier.consoleGlass] every console surface is, so a card and a settings row catch the
* light the same way. [centred] is the carousel's own focus: the tile the pad is pointing at, which
* earns the lift and the accent bloom.
*/
@Composable
private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) {
private fun GamepadHostTile(tile: HomeTile, centred: Boolean, modifier: Modifier = Modifier) {
val ink = LocalGamepadInk.current
val shape = RoundedCornerShape(26.dp)
val wash = if (tile.filled) {
Brush.verticalGradient(listOf(ink.accent(0.20f), Color(0x14100C2A)))
} else {
Brush.verticalGradient(listOf(Color(0x1AFFFFFF), Color(0x0DFFFFFF)))
}
val visuals = animateConsoleFocus(active = centred)
// A SAVED host wears the palette's accent; a discovered one (or the Add tile) stays neutral
// glass, so "already yours" reads before you get to the label.
val fill = if (tile.filled) ink.accent(0.20f) else ink.glass
Column(
modifier = modifier
.fillMaxWidth()
.clip(shape)
.background(wash)
.border(1.dp, ink.fg(0.16f), shape)
// The carousel already drives its own scale; the glass must not fight it with a second.
.consoleGlass(
ConsoleShape.Tile,
ConsoleFocusVisuals(1f, fill, ink.fg(0.16f), visuals.focus),
// A DASHED edge on anything not yet saved — a host found on the network, and the
// Add tile. It is the touch grid's own convention and the Apple client's, and it
// says "not yours yet" before the subtitle has to.
dashed = !tile.filled,
)
.padding(22.dp),
) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
@@ -291,12 +363,64 @@ private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) {
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (tile.profileName != null) {
ConsoleProfileChip(
name = tile.profileName,
accent = tile.profileAccent,
// On a PIN card the profile is why the card exists; on a bound host's own card it
// is a note about what a press will do. Same chip, two weights.
prominent = tile.pinnedProfileId != null,
modifier = Modifier.padding(top = 5.dp),
)
}
Text(
tile.subtitle,
style = MaterialTheme.typography.bodyMedium,
color = ink.fg(0.55f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 2.dp),
)
}
}
/**
* The profile a card connects with, worn as a tinted capsule. The console counterpart of the touch
* grid's own chip (`HostComponents.kt`) same shape and the same quiet/prominent split, but inked
* from the console palette rather than `MaterialTheme`, since it sits on the aurora.
*
* A profile that set no accent falls back to the palette's, not to the touch theme's primary: on a
* moss or copper field the brand violet would be the one foreign colour on the card.
*/
@Composable
private fun ConsoleProfileChip(
name: String,
accent: Color?,
prominent: Boolean,
modifier: Modifier = Modifier,
) {
val ink = LocalGamepadInk.current
val tint = accent ?: ink.accent
Row(
modifier = modifier
.clip(ConsoleShape.Pill)
.background(tint.copy(alpha = if (prominent) 0.24f else 0.12f))
.padding(horizontal = 9.dp, vertical = 3.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Modifier.size(7.dp).clip(CircleShape).background(tint))
Spacer(Modifier.width(6.dp))
Text(
name,
style = if (prominent) {
MaterialTheme.typography.labelLarge
} else {
MaterialTheme.typography.labelMedium
},
fontWeight = if (prominent) FontWeight.Bold else FontWeight.SemiBold,
color = tint,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@@ -305,10 +429,13 @@ private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) {
private fun MonogramBadge(tile: HomeTile) {
val ink = LocalGamepadInk.current
val shape = RoundedCornerShape(15.dp)
// Lit from the top like every other console surface — and the unsaved badge takes the palette's
// own accent at low opacity rather than the brand violet, which on a copper or moss field was
// the one square of the wrong hue on the screen.
val fill = if (tile.filled) {
Brush.verticalGradient(listOf(ink.accent, ink.accent))
Brush.verticalGradient(listOf(ink.accent.copy(alpha = 0.92f), ink.accent))
} else {
Brush.verticalGradient(listOf(Color(0x296656F2), Color(0x296656F2)))
Brush.verticalGradient(listOf(ink.accent(0.20f), ink.accent(0.14f)))
}
Box(
modifier = Modifier.size(52.dp).clip(shape).background(fill),
@@ -32,6 +32,31 @@ class GamepadInk(
val shadeScale: Float,
/** True when the field is pale, for the few places that branch rather than blend. */
val isLight: Boolean,
/**
* The near-opaque ground a MODAL card sits on. A dialog can't be glass: it has to occlude the
* screen it covers, and it carries [fg] text which is why this must follow the palette. It
* was a hardcoded near-black indigo, so on a pale palette the card's dark ink landed on a dark
* card and the dialogs were unreadable.
*/
val card: Color,
/**
* What dims the screen BEHIND a modal. Always dark, whatever the field: a scrim's job is to
* push the backdrop down, and a pale field lit with more white doesn't recede it glares. A
* pale one needs less of it, because it has further to fall.
*/
val modalScrim: Color,
/**
* The light a glass surface catches along its top edge. White either way a highlight is a
* specular, not a tint but a pale field's frost is already bright, so it takes MORE to read
* as an edge against the pastel showing through it.
*/
val highlight: Color,
/**
* What a failure says itself in the pairing error, and anything else the console has to
* refuse in words. Follows the palette because it lands on [card], not on the field: the salmon
* that reads on a dark modal is washed out on a near-white one.
*/
val danger: Color,
) {
/** The foreground at [alpha]. */
fun fg(alpha: Float): Color = fg.copy(alpha = alpha)
@@ -50,6 +75,7 @@ class GamepadInk(
val accentLuma =
0.2126 * p.accent.first + 0.7152 * p.accent.second + 0.0722 * p.accent.third
val onAccent = if (accentLuma > 0.55) Color.Black else Color.White
val (gr, gg, gb) = p.ground
if (!p.light) {
return GamepadInk(
fg = Color.White,
@@ -59,9 +85,20 @@ class GamepadInk(
shade = Color.Black,
shadeScale = 1f,
isLight = false,
// The palette's own ground, lifted just off it so the card reads as a surface
// ABOVE the field rather than a hole in it. For the brand violet that lands on
// the #1A1730 the dialogs were hardcoded to, which is where the number came from.
card = Color(
(gr + 0.030).toFloat().coerceAtMost(1f),
(gg + 0.030).toFloat().coerceAtMost(1f),
(gb + 0.040).toFloat().coerceAtMost(1f),
0.94f,
),
modalScrim = Color.Black.copy(alpha = 0.62f),
highlight = Color.White.copy(alpha = 0.30f),
danger = Color(0xFFE0736F),
)
}
val (gr, gg, gb) = p.ground
return GamepadInk(
// Tinted toward the palette's own ground so it doesn't read as a foreign grey.
fg = Color((gr * 0.16).toFloat(), (gg * 0.14).toFloat(), (gb * 0.20).toFloat()),
@@ -73,6 +110,15 @@ class GamepadInk(
shade = Color.White,
shadeScale = 0.45f,
isLight = true,
// Near-white rather than near-black: the card carries this palette's DARK ink.
card = Color.White.copy(alpha = 0.94f),
// Lighter than the dark field's: a pastel backdrop is closer to the card already,
// so the same 0.62 would read as a bruise rather than a recession.
modalScrim = Color.Black.copy(alpha = 0.38f),
highlight = Color.White.copy(alpha = 0.55f),
// Deepened for the near-white card the pale palettes' modals use — the dark
// field's salmon has nothing like enough contrast against it.
danger = Color(0xFFB3352F),
)
}
@@ -65,6 +65,10 @@ fun GamepadNavEffect(
) {
val activity = LocalContext.current as? MainActivity ?: return
val state = remember { NavInputState() }
// Menu feel, inherited by every console screen that navigates through here rather than wired
// per screen: a tick as the cursor steps, a pulse on confirm. Renders on the driving pad's own
// motors, the phone body if it has none, and nothing at all on a TV.
val haptics by rememberUpdatedState(rememberConsoleHaptics())
// The effects below are keyed on `active` only (they must NOT restart on every recomposition), so
// they'd otherwise capture the FIRST callbacks — closing over a stale `tiles` (fewer hosts than are
// discovered later, which clamped navigation to that old count). rememberUpdatedState keeps the
@@ -98,7 +102,10 @@ fun GamepadNavEffect(
KeyEvent.KEYCODE_DPAD_UP -> { if (edge) currentOnUp(); true }
KeyEvent.KEYCODE_DPAD_DOWN -> { if (edge) currentOnDown(); true }
KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_DPAD_CENTER,
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> { if (edge) currentOnActivate(); true }
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> {
if (edge) { haptics.confirm(); currentOnActivate() }
true
}
// The gamepad Select / View / Share button → context options (a remote uses Down).
KeyEvent.KEYCODE_BUTTON_SELECT -> { if (edge) currentOnOptions(); true }
KeyEvent.KEYCODE_BUTTON_X -> { if (edge) currentOnTertiary(); true }
@@ -139,8 +146,10 @@ fun GamepadNavEffect(
}
when {
dir == 0 -> committed = 0
dir != committed -> { currentOnMove(dir); committed = dir; fireAt = now + INITIAL_DELAY_MS }
now >= fireAt -> { currentOnMove(dir); fireAt = now + REPEAT_MS }
dir != committed -> {
haptics.tick(); currentOnMove(dir); committed = dir; fireAt = now + INITIAL_DELAY_MS
}
now >= fireAt -> { haptics.tick(); currentOnMove(dir); fireAt = now + REPEAT_MS }
}
delay(16)
}
@@ -167,6 +176,9 @@ fun GamepadNavEffect2D(
) {
val activity = LocalContext.current as? MainActivity ?: return
val state = remember { NavInputState() }
// See [GamepadNavEffect] — the same menu feel, so a form screen and a carousel answer a press
// identically.
val haptics by rememberUpdatedState(rememberConsoleHaptics())
val currentOnDirection by rememberUpdatedState(onDirection)
val currentOnActivate by rememberUpdatedState(onActivate)
val currentOnTertiary by rememberUpdatedState(onTertiary)
@@ -196,12 +208,15 @@ fun GamepadNavEffect2D(
KeyEvent.KEYCODE_DPAD_UP -> { state.dpadY = if (down) -1 else 0; true }
KeyEvent.KEYCODE_DPAD_DOWN -> { state.dpadY = if (down) 1 else 0; true }
KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_DPAD_CENTER,
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> { if (edge) currentOnActivate(); true }
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> {
if (edge) { haptics.confirm(); currentOnActivate() }
true
}
KeyEvent.KEYCODE_BUTTON_X -> { if (edge) currentOnTertiary(); true }
KeyEvent.KEYCODE_BUTTON_Y -> { if (edge) currentOnSecondary(); true }
// Edge-only, no auto-repeat: a held shoulder shouldn't spin through the tabs.
KeyEvent.KEYCODE_BUTTON_L1 -> { if (edge) currentOnShoulder(-1); true }
KeyEvent.KEYCODE_BUTTON_R1 -> { if (edge) currentOnShoulder(1); true }
KeyEvent.KEYCODE_BUTTON_L1 -> { if (edge) { haptics.tick(); currentOnShoulder(-1) }; true }
KeyEvent.KEYCODE_BUTTON_R1 -> { if (edge) { haptics.tick(); currentOnShoulder(1) }; true }
else -> false // B → MainActivity (remapped to BACK → BackHandler)
}
}
@@ -229,8 +244,10 @@ fun GamepadNavEffect2D(
when {
raw == null && nearCentre -> committed = null
raw == null -> { /* in the hysteresis band → hold, don't fire */ }
raw != committed -> { currentOnDirection(raw); committed = raw; fireAt = now + INITIAL_DELAY_MS }
now >= fireAt -> { currentOnDirection(raw); fireAt = now + REPEAT_MS }
raw != committed -> {
haptics.tick(); currentOnDirection(raw); committed = raw; fireAt = now + INITIAL_DELAY_MS
}
now >= fireAt -> { haptics.tick(); currentOnDirection(raw); fireAt = now + REPEAT_MS }
}
delay(16)
}
@@ -17,6 +17,21 @@ import androidx.compose.ui.graphics.Color
// on every client. Keep the three copies in step: a palette added here without the others is a
// value the other clients silently render as Violet.
/**
* One wandering interior control point of the mesh: [x]/[y] its resting place in unit UV, [amp] how
* far it strays, [sx]/[sy] its per-axis rates in rad·s¹ and [phase] its offset. Its live
* displacement `(amp·sin(t·sx+ph), amp·cos(t·sy+ph·1.3))` drives a bounded domain warp, so the
* bright colour pools drift with it.
*/
class MeshWarpPoint(
val x: Double,
val y: Double,
val amp: Double,
val sx: Double,
val sy: Double,
val phase: Double,
)
/** One background colour family. */
class GamepadPalette(
/** The stored `ui_palette` value ([Settings.uiPalette]). */
@@ -47,11 +62,29 @@ class GamepadPalette(
/** The accent as a Compose colour. */
val accentColor: Color by lazy { color(accent) }
/**
* The 16 mesh colours this palette's field is woven from: the ramp sampled per cell (see
* [CELL_RAMP]), or [MESH_COLORS] verbatim for the brand default the exact rule
* `pf-console-ui`'s `Palette::mesh_colors` follows, so one `ui_palette` value is one field on
* every client. Consumed by the AGSL backdrop on API 33+; the blob field
* ([blobColors]) approximates the same table below that.
*/
val meshColors: List<Triple<Double, Double, Double>> by lazy {
if (stops.isEmpty()) {
MESH_COLORS
} else {
(0..15).map { i ->
ramp(stops, 0.5 * ((i % 4) / 3.0 + (i / 4) / 3.0) + CELL_RAMP[i])
}
}
}
companion object {
/**
* Where each of the 16 mesh cells samples the ramp on the clients that draw a mesh. Kept
* here so the three ports stay one table even though this client approximates the field
* with blobs.
* Where each of the 16 mesh cells samples the ramp. The base is the diagonal
* `0.5·(x + y)` top-left the ramp's dark end, bottom-right its bright one and the
* per-cell nudges break the banding a pure diagonal would show. Mirrored from
* `pf-console-ui`'s `CELL_RAMP`.
*/
val CELL_RAMP = listOf(
0.10, -0.06, 0.04, -0.12,
@@ -60,6 +93,34 @@ class GamepadPalette(
-0.10, 0.08, -0.06, 0.12,
)
/**
* The brand default's 16 mesh colours, used verbatim (rather than sampled from a ramp) so
* `violet` stays bit-identical to what every install already sees. Mirrors
* `pf-console-ui`'s `MESH_COLORS`.
*/
val MESH_COLORS = listOf(
Triple(0.075, 0.060, 0.160), Triple(0.34, 0.27, 0.72),
Triple(0.30, 0.26, 0.74), Triple(0.075, 0.060, 0.160),
Triple(0.42, 0.20, 0.54), Triple(0.49, 0.39, 0.95),
Triple(0.28, 0.31, 0.84), Triple(0.16, 0.26, 0.64),
Triple(0.45, 0.23, 0.60), Triple(0.53, 0.31, 0.75),
Triple(0.35, 0.35, 0.91), Triple(0.19, 0.28, 0.70),
Triple(0.075, 0.060, 0.160), Triple(0.22, 0.18, 0.54),
Triple(0.24, 0.20, 0.58), Triple(0.075, 0.060, 0.160),
)
/**
* The four interior points that wander; the 12 boundary points stay pinned to the frame (a
* drifting edge point would shrink the field and expose the ground behind it). Periods
* ~90130 s, out of phase, so the field never visibly loops. Mirrors `MESH_INTERIOR`.
*/
val MESH_INTERIOR = listOf(
MeshWarpPoint(0.333, 0.333, 0.11, 0.049, 0.063, 0.4),
MeshWarpPoint(0.667, 0.333, 0.10, 0.055, 0.052, 2.1),
MeshWarpPoint(0.333, 0.667, 0.10, 0.058, 0.049, 3.6),
MeshWarpPoint(0.667, 0.667, 0.12, 0.047, 0.061, 5.0),
)
/** The brand default's blob ramp — the colours the pre-palette field used. */
private val VIOLET_BLOBS = listOf(
Triple(0.53, 0.47, 0.96), Triple(0.24, 0.20, 0.72), Triple(0.62, 0.30, 0.80),
@@ -84,7 +145,11 @@ class GamepadPalette(
// pure black too: the calm mix on the form screens lifts toward nothing. What is
// left is a faint indigo→violet ember in the bright corner. The accent stays the
// brand violet — focus has to be findable on black.
"oled", "OLED",
// Named for the look, not the panel technology — black with a thin violet corona
// belongs beside Nebula and Abyss. ⚠ The ID stays "oled": it is the stored
// `ui_palette` value and the cross-client key, so renaming it would orphan saved
// choices and desync the clients.
"oled", "Eclipse",
listOf(
Triple(0.000, 0.000, 0.000), Triple(0.000, 0.000, 0.000),
Triple(0.010, 0.020, 0.100), Triple(0.045, 0.016, 0.115),
@@ -3,20 +3,19 @@ package io.unom.punktfunk
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
@@ -25,15 +24,28 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -46,13 +58,23 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.toggleableState
import androidx.compose.ui.state.ToggleableState
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.chrisbanes.haze.HazeState
@@ -103,6 +125,19 @@ internal class GpRow(
val toggled: Boolean? = null, // non-null = a toggle row, drawn as a ConsoleSwitch (not text)
val adjustable: Boolean = true, // false = the row navigates/acts instead of stepping — no chevrons
val enabled: Boolean = true, // dimmed + inert when false (still focusable, for its detail)
/**
* What A does on a non-adjustable row, for the legend. It was the literal "Pin to hosts" in the
* hint bar back when a profile row was the only kind of row that acted rather than stepped; a
* row that opens the Controllers view was then advertised as pinning something.
*/
val actionHint: String = "Open",
/**
* A choice row's full option list + where [value] sits in it what the [ConsoleOptionBand]
* drum turns through. Null (with [selectedIndex] -1) on everything that is not a stepped
* choice: toggles are a switch, and the flat rows keep the quiet text slip.
*/
val options: List<String>? = null,
val selectedIndex: Int = -1,
)
/**
@@ -114,12 +149,28 @@ internal class GpRow(
internal fun liveRow(rows: List<GpRow>, index: Int): GpRow? =
rows.getOrNull(index)?.takeIf { it.enabled }
/**
* Where the cursor was when a row opened a SUB-SCREEN. The shell holds it across the trip (this
* screen's own state does not outlive it) and hands it back, so Back from the Controllers view lands
* on the row that opened it rather than on the first row of the first section.
*
* The row is remembered by ID, not by index: a tab's length follows the hardware and the profile
* catalog, and a remembered index is the stale-pointer bug the tab-switch clamp already exists for.
*/
data class GpSettingsPlace(val tab: GpTab, val rowId: String)
@Composable
fun GamepadSettingsScreen(
initial: Settings,
onChange: (Settings) -> Unit,
onBack: () -> Unit,
navActive: Boolean = true, // false while this screen is cross-fading out, so it drops the pad
/** Open the connected-controllers view / the open-source notices — the shell pushes them. */
onOpenControllers: () -> Unit = {},
onOpenLicenses: () -> Unit = {},
/** Where a return from one of those lands; null = a fresh entry, which starts at the top. */
resume: GpSettingsPlace? = null,
onPlace: (GpSettingsPlace) -> Unit = {},
) {
var s by remember { mutableStateOf(initial) }
fun update(next: Settings) { s = next; onChange(next) }
@@ -162,24 +213,46 @@ fun GamepadSettingsScreen(
// path there is this screen's own Controller-optimized UI toggle, which swaps in the standard
// interface remote-navigably. The strings branch on it.
val tv = remember { isTvDevice(context) }
val allRows = buildSettingsRows(s, hasBodyVibrator, hasGyroscope, av1Capable, ::update) +
buildProfileRows(profiles, savedHosts, tv) { pinProfile = it }
// The installed version, for the About row — the console is the ONLY interface on a TV box, so
// the identity the touch About page states has to be reachable from here too.
val appVersion = remember {
runCatching {
@Suppress("DEPRECATION")
context.packageManager.getPackageInfo(context.packageName, 0).versionName
}.getOrNull().orEmpty()
}
val allRows = buildSettingsRows(
s, hasBodyVibrator, hasGyroscope, av1Capable,
appVersion = appVersion,
openControllers = onOpenControllers,
openLicenses = onOpenLicenses,
update = ::update,
) + buildProfileRows(profiles, savedHosts, tv) { pinProfile = it }
// Which section is showing, and where each one's focus was when it was last left — a detour
// into another tab shouldn't lose your place.
var tab by remember { mutableStateOf(GpTab.STREAM) }
var tab by remember { mutableStateOf(resume?.tab ?: GpTab.STREAM) }
// True while the STRIP holds the cursor rather than the list. Up from the first row moves
// here and Down goes back — the only route to the sections on a D-pad remote, which has no
// shoulder buttons at all (and is exactly what a TV box ships with).
var tabFocused by remember { mutableStateOf(false) }
val tabFocus = remember { mutableStateMapOf<GpTab, Int>() }
val rows = allRows.filter { it.tab == tab }
var focus by remember { mutableIntStateOf(0) }
// Entry focus: the row a sub-screen was opened from, if we are coming back from one. Resolved
// ONCE, against the first row list — after that the cursor belongs to this screen.
var focus by remember {
mutableIntStateOf(rows.indexOfFirst { it.id == resume?.rowId }.coerceAtLeast(0))
}
if (focus > rows.lastIndex) focus = rows.lastIndex.coerceAtLeast(0)
// Which way the section last moved (+1 forward / -1 back) — the row list slides in from that
// side, so stepping sections reads as travelling along a strip rather than teleporting.
var tabDir by remember { mutableIntStateOf(1) }
// L1/R1 — one section along, wrapping (the strip is a ring, like A's value cycle).
fun selectTab(next: GpTab) {
if (next == tab) return
tabFocus[tab] = focus
tabDir = if (next.ordinal > tab.ordinal) 1 else -1
tab = next
// Clamp: a tab's length follows the hardware and the catalog, so a remembered index can
// outlive the row it pointed at.
@@ -188,15 +261,45 @@ fun GamepadSettingsScreen(
}
fun stepTab(delta: Int) {
val all = GpTab.entries
selectTab(all[((all.indexOf(tab) + delta) % all.size + all.size) % all.size])
val next = all[((all.indexOf(tab) + delta) % all.size + all.size) % all.size]
selectTab(next)
// A wrap (last → first) is still a step in the direction you pressed, whatever the ordinals
// say — selectTab's ordinal compare would read it backwards.
tabDir = delta
}
// The direction the focused value last stepped (+1 forward / -1 back) — drives which way the
// value text slides in its AnimatedContent, so the motion matches the button press.
var adjustDir by remember { mutableIntStateOf(1) }
val listState = rememberLazyListState()
// Bumped on every ACCEPTED step of the focused row (the chevron ticks) and every REFUSED one
// (the value gives a little and springs back). A press always gets an answer, even "no".
var stepToken by remember { mutableIntStateOf(0) }
var refusalToken by remember { mutableIntStateOf(0) }
val haptics = rememberConsoleHaptics()
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
// Act on a row, publishing where the cursor was FIRST. A row that opens a sub-screen unmounts
// this one on the spot, so the place has to be out of here before its `activate` runs; every
// activation route (pad, tap, the legend's own A cell) goes through this one door so none of
// them can be the one that forgets.
fun activate(row: GpRow) {
onPlace(GpSettingsPlace(tab, row.id))
adjustDir = 1
row.activate()
}
// Step the focused row's value, answering a refusal rather than swallowing it.
fun step(delta: Int) {
adjustDir = delta
val row = liveRow(rows, focus)
if (row != null && row.adjust(delta)) {
stepToken++
} else {
refusalToken++
haptics.boundary()
}
}
BackHandler(onBack = onBack)
GamepadNavEffect2D(
// The pin picker owns the pad while it's up (its own nav + BackHandler), so this screen
@@ -209,89 +312,233 @@ fun GamepadSettingsScreen(
// On the strip, left/right walks sections; on a row it steps the value. A disabled
// row is INERT, not just dim — the step is refused instead of writing a setting
// that has nothing to act on (see `liveRow`).
NavDir.LEFT ->
if (tabFocused) stepTab(-1) else { adjustDir = -1; liveRow(rows, focus)?.adjust(-1) }
NavDir.RIGHT ->
if (tabFocused) stepTab(1) else { adjustDir = 1; liveRow(rows, focus)?.adjust(1) }
NavDir.LEFT -> if (tabFocused) stepTab(-1) else step(-1)
NavDir.RIGHT -> if (tabFocused) stepTab(1) else step(1)
}
},
// A on the strip drops into the section you picked, which is what "confirm" means there.
onActivate = {
if (tabFocused) tabFocused = false else { adjustDir = 1; liveRow(rows, focus)?.activate() }
if (tabFocused) tabFocused = false else liveRow(rows, focus)?.let { activate(it) }
},
// The shoulders work from either place — a real pad never has to visit the strip.
onShoulder = { delta -> stepTab(delta) },
)
// Keep the focused row on screen, but only SCROLL when it's actually off-screen — so entering the
// screen (focus on the first row) leaves the "Settings" heading visible instead of jumping past it.
// +1 accounts for the heading being item 0.
LaunchedEffect(focus, tab) {
runCatching {
val itemIndex = focus + 1
val info = listState.layoutInfo
val item = info.visibleItemsInfo.firstOrNull { it.index == itemIndex }
val offScreen = item == null ||
item.offset < info.viewportStartOffset ||
item.offset + item.size > info.viewportEndOffset - 96 // keep clear of the floating legend
if (offScreen) listState.animateScrollToItem(itemIndex)
}
}
val animated = animationsEnabled()
val hazeState = remember { HazeState() }
val ink = LocalGamepadInk.current
// The list runs to the PHYSICAL bottom edge (see the column's insets below), so the legend
// zone's clearance has to carry the bottom bar inset itself. Landscape's zone is only the
// pill — its detail lives in the side pane — so it clears less.
val bottomInset = with(LocalDensity.current) {
WindowInsets.systemBars.getBottom(this).toDp()
}
val legendClearance = (if (landscape) 92.dp else ConsoleLegendClearance) + bottomInset
val legendClearancePx = with(LocalDensity.current) { legendClearance.roundToPx() }
// The drum's fixed stage: a portrait phone is the one place the full width starves the row's
// label, so it alone narrows it — the Apple band makes the same single exception. 132, not the
// 156 of the first cut: at 156 the LABELS truncated ("Resoluti…"), and a clipped label loses
// meaning where a drum value only loses its tail into the edge fade.
val bandWidth = if (landscape) 220.dp else 132.dp
Box(Modifier.fillMaxSize()) {
// Everything scrolls — including the heading — so nothing is pinned. Vital in landscape,
// where a fixed title + a fixed detail/legend strip ate most of the (short) height.
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
GamepadFormBackground(Modifier.fillMaxSize())
Column(Modifier.fillMaxSize().systemBarsPadding()) {
// The strip is PINNED while the rows scroll under it: it is this screen's primary
// navigation now, and a switcher you have to scroll back up to find isn't one. The
// title stays in the scrolling list (landscape has no height to spare, and the
// selected pill already says which section you are in).
ConsoleTabStrip(
titles = GpTab.entries.map { it.title },
selected = GpTab.entries.indexOf(tab),
onSelect = { tabFocused = false; selectTab(GpTab.entries[it]) },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 2.dp),
focused = tabFocused,
)
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 104.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
item(key = "__title") {
// "Default settings", not "Settings": this screen edits the base layer only. The
// console honours a host's profile but doesn't edit profiles (design §5.4), so a
// bare "Settings" would quietly imply it changes whatever that host streams with.
ConsoleHeader("Default settings", horizontalInset = false)
/**
* One section's rows as a scrolling pane. A composable local rather than inline because the
* tab transition composes TWO of these at once (incoming and outgoing), and each needs its own
* [LazyListState] Compose refuses one state attached to two lists, which is why the previous
* cut animated a single list's contents and read as the same fade in every direction.
*/
val tabPane: @Composable (GpTab, Modifier) -> Unit = { paneTab, paneModifier ->
val paneRows = if (paneTab == tab) rows else allRows.filter { it.tab == paneTab }
val paneFocus = if (paneTab == tab) focus else (tabFocus[paneTab] ?: 0)
// Seeded at the restored cursor, so re-entering a section lands where it was left without
// a visible catch-up scroll on the first frame.
val paneListState = rememberLazyListState(
initialFirstVisibleItemIndex = paneFocus.coerceIn(0, paneRows.lastIndex.coerceAtLeast(0)),
)
// Keep the focused row on screen, but only SCROLL when it's actually off-screen. Only the
// LIVE pane tracks the cursor; the outgoing one is a photograph on its way out.
if (paneTab == tab) {
LaunchedEffect(focus) {
runCatching {
val info = paneListState.layoutInfo
val item = info.visibleItemsInfo.firstOrNull { it.index == focus }
val offScreen = item == null ||
item.offset < info.viewportStartOffset ||
// The SAME clearance the list pads its bottom with, rather than a literal
// that has to be remembered when the legend zone grows.
item.offset + item.size > info.viewportEndOffset - legendClearancePx
if (offScreen) paneListState.animateScrollToItem(focus)
}
}
itemsIndexed(rows, key = { _, r -> r.id }) { index, row ->
}
LazyColumn(
state = paneListState,
// Capped at the Apple client's 620 row width: a landscape phone is WIDER than it is
// useful, and a settings row stretched across 900 dp reads as a ribbon, not a control.
// Start-aligned (not centred) so the rows and the side detail pane split the screen
// rather than both crowding the middle.
modifier = paneModifier.widthIn(max = 620.dp + ConsoleEdgeInset * 2),
contentPadding = PaddingValues(
start = ConsoleEdgeInset,
end = ConsoleEdgeInset,
top = 8.dp,
// Clears the whole floating legend ZONE, bottom bar included — the list itself
// runs to the screen edge now.
bottom = legendClearance,
),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
itemsIndexed(paneRows, key = { _, r -> r.id }) { index, row ->
val rowFocused = paneTab == tab && index == focus && !tabFocused
SettingRowView(
row,
focused = index == focus && !tabFocused,
focused = rowFocused,
adjustDir = adjustDir,
// Only the focused row can be stepped, so only it needs to answer one.
stepToken = if (rowFocused) stepToken else 0,
refusalToken = if (rowFocused) refusalToken else 0,
bandWidth = bandWidth,
onClick = {
// Same inertness as the pad path above — tapping a dimmed row focuses it
// (so its detail explains itself) but never flips it.
tabFocused = false
if (focus != index) focus = index
else if (row.enabled) { adjustDir = 1; row.activate() }
else if (row.enabled) activate(row)
},
)
}
}
}
}
/** The section switcher with its directional content — shared by both orientations below. */
val tabbedContent: @Composable (Modifier) -> Unit = { contentModifier ->
AnimatedContent(
targetState = tab,
modifier = contentModifier,
transitionSpec = {
if (!animated) {
fadeIn(tween(ConsoleMotion.REDUCED_MS)) togetherWith
fadeOut(tween(ConsoleMotion.REDUCED_MS))
} else {
// DIRECTION-driven, with a real exit: the incoming section slides in from the
// side the press pointed at while the outgoing leaves the other way — paging
// along a strip. The previous cut slid a single list's contents 24 dp under an
// 85 % fade, which read as the same crossfade whichever shoulder was pressed.
val dir = tabDir
(
slideInHorizontally(ConsoleMotion.ease(ConsoleMotion.TAB_MS)) { it / 6 * dir } +
fadeIn(ConsoleMotion.ease(ConsoleMotion.TAB_MS))
) togetherWith (
slideOutHorizontally(ConsoleMotion.ease(ConsoleMotion.TAB_MS)) { -it / 6 * dir } +
fadeOut(ConsoleMotion.ease(ConsoleMotion.TAB_MS))
)
}
},
label = "settingsTab",
) { t ->
tabPane(t, Modifier.fillMaxHeight())
}
}
Box(Modifier.fillMaxSize()) {
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
// The backdrop stays full-bleed — it is ambience. The CHROME (strip, rows' start
// edge) takes the safe area on the sides and top only: the LIST deliberately runs to
// the physical bottom of the screen, with the bottom inset folded into its
// contentPadding, so scrolled rows glide off the edge instead of being guillotined at
// an invisible inset line 24 px above it (third on-glass verdict).
GamepadFormBackground(Modifier.fillMaxSize())
Column(
Modifier
.fillMaxSize()
.windowInsetsPadding(
WindowInsets.systemBars.union(WindowInsets.displayCutout)
.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top),
),
) {
// The strip is PINNED while the rows scroll under it: it is this screen's primary
// navigation, and a switcher you have to scroll back up to find isn't one.
Row(
Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ConsoleTabStrip(
titles = GpTab.entries.map { it.title },
selected = GpTab.entries.indexOf(tab),
onSelect = { tabFocused = false; selectTab(GpTab.entries[it]) },
modifier = Modifier.weight(1f),
focused = tabFocused,
)
// The base-layer marker, where a full "Default settings" heading used to eat a
// headline row on EVERY tab. The honesty it carried stays: this screen edits
// the defaults only — the console honours a host's profile but doesn't edit
// profiles (design §5.4) — and this quiet chip at the strip's end says so
// without a second heading repeating the tab pill's own word.
Text(
"Defaults",
style = MaterialTheme.typography.labelMedium,
color = ink.fg(0.45f),
maxLines = 1,
modifier = Modifier.padding(start = 10.dp, end = ConsoleEdgeInset),
)
}
if (landscape) {
Row(Modifier.fillMaxSize()) {
tabbedContent(Modifier.weight(0.6f))
// The focused row's description, in the width a wide phone wastes — beside
// the rows instead of floating over the list's tail (the portrait band).
// Presentation only: the row already merges this text into its own
// announcement, so the pane is hidden from a screen reader like the band.
val focusedRow = rows.getOrNull(focus)
AnimatedContent(
targetState = if (tabFocused) null else focusedRow,
transitionSpec = {
fadeIn(ConsoleMotion.ease(ConsoleMotion.FOCUS_MS)) togetherWith
fadeOut(ConsoleMotion.ease(ConsoleMotion.FOCUS_MS))
},
modifier = Modifier
.weight(0.4f)
.semantics { hideFromAccessibility() },
label = "sideDetail",
) { r ->
Column(
Modifier
.fillMaxHeight()
.padding(start = 6.dp, end = ConsoleEdgeInset, top = 22.dp),
) {
if (r != null && r.detail.isNotBlank()) {
Text(
r.label,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = ink.fg(0.85f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
r.detail,
style = MaterialTheme.typography.bodyMedium,
color = ink.fg(0.6f),
modifier = Modifier.padding(top = 6.dp),
)
}
}
}
}
} else {
tabbedContent(Modifier.fillMaxSize())
}
}
}
// Floating frosted legend — a real backdrop blur of the rows scrolling behind it (no dedicated
// strip). In landscape it ignores the safe area so it hugs the corner instead of the nav-bar inset.
// The floating legend ZONE: the focused row's description above, the controls pill below,
// both frosted over whatever scrolls behind them. It is an OVERLAY, so nothing in it can
// ever displace the list — which is the whole reason the detail moved here out of the row.
// In landscape it ignores the system bars so it hugs the corner instead of the nav-bar
// inset, but it still takes the display cutout (reverse-landscape parks the punch here).
Box(
Modifier
.align(Alignment.BottomStart)
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
.consoleLegendInsets(landscape)
.padding(ConsoleLegendInset),
) {
// The legend follows the focused row (the desktop console's hints() does the same):
@@ -306,31 +553,49 @@ fun GamepadSettingsScreen(
// Activity (preview/tests), like GamepadHintBar's own glyph choice.
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true
val sections = listOfNotNull(
GamepadHint('⇄', Color(0xFF9A93C7), "Section", onClick = { stepTab(1) })
GamepadHint('⇄', PadGlyph.Arrow, "Section", onClick = { stepTab(1) })
.takeIf { padIsGamepad },
)
GamepadHintBar(
if (tabFocused) listOf(
GamepadHint('↔', Color(0xFF9A93C7), "Section"),
PadGlyph.hint('A', "Open") { tabFocused = false },
PadGlyph.hint('B', "Done", onClick = onBack),
) else sections + when {
focused != null && !focused.enabled -> listOf(
PadGlyph.hint('B', "Done", onClick = onBack),
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
// Portrait only: landscape's description lives in the side pane, where the wide
// aspect has room for it — a band AND a pane would say the same thing twice.
if (!landscape) {
ConsoleDetailBand(
// On the strip there is no row to describe, and the pills already name the
// sections — a stale row's description there would describe the wrong thing.
text = if (tabFocused) "" else focused?.detail.orEmpty(),
key = if (tabFocused) "__strip" else focused?.id,
hazeState = hazeState,
)
focused != null && !focused.adjustable -> listOf(
PadGlyph.hint('A', "Pin to hosts") { focused.activate() },
}
GamepadHintBar(
if (tabFocused) listOf(
GamepadHint('↔', PadGlyph.Arrow, "Section"),
PadGlyph.hint('A', "Open") { tabFocused = false },
PadGlyph.hint('B', "Done", onClick = onBack),
)
else -> listOf(
GamepadHint('', Color(0xFF9A93C7), "Adjust"),
// Tappable too (touch escape hatch): Change cycles the focused row, Done leaves.
PadGlyph.hint('A', "Change") { rows.getOrNull(focus)?.activate() },
PadGlyph.hint('B', "Done", onClick = onBack),
)
},
hazeState = hazeState,
)
) else sections + when {
focused != null && !focused.enabled -> listOf(
PadGlyph.hint('B', "Done", onClick = onBack),
)
// What A does here follows the ROW: it opens the pin picker on a profile,
// the connected-controllers view on that one, the notices on the About row.
// It was the literal "Pin to hosts" while profiles were the only such rows.
focused != null && !focused.adjustable -> listOf(
PadGlyph.hint('A', focused.actionHint) { activate(focused) },
PadGlyph.hint('B', "Done", onClick = onBack),
)
else -> listOf(
GamepadHint('↔', PadGlyph.Arrow, "Adjust"),
// Tappable too (touch hatch): Change cycles the focused row, Done leaves.
PadGlyph.hint('A', "Change") {
rows.getOrNull(focus)?.let { activate(it) }
},
PadGlyph.hint('B', "Done", onClick = onBack),
)
},
hazeState = hazeState,
)
}
}
// The pin-to-hosts picker for the activated profile row — the console counterpart of the
@@ -347,24 +612,64 @@ fun GamepadSettingsScreen(
}
}
/**
* One settings row. Its geometry NEVER changes with focus that is the whole design of it.
*
* It used to unfold its description in place, which meant every D-pad step shrank one row and grew
* another, shifting the entire list under the cursor and moving the keep-focus-visible scroll's
* target out from under it mid-animation. The description now lives in the screen's floating
* [ConsoleDetailBand], which is an overlay and cannot displace anything. Focus changes colour,
* lift and bloom here; it does not change size.
*
* The value gets the same treatment sideways: a fixed minimum slot, end-aligned, with the size
* transform snapped so the slot's WIDTH never animates. Stepping a choice used to widen and narrow
* that slot on every press, walking the chevron back and forth. Tabular figures finish the job
* without them `1920 × 1080 2560 × 1440` changes width on the digits alone.
*/
@Composable
private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick: () -> Unit) {
private fun SettingRowView(
row: GpRow,
focused: Boolean,
adjustDir: Int,
stepToken: Int,
refusalToken: Int,
/** The option drum's fixed stage — sized by the SCREEN (orientation decides how much a row can spare). */
bandWidth: Dp,
onClick: () -> Unit,
) {
val ink = LocalGamepadInk.current
val visuals = animateConsoleFocus(active = focused)
val shape = RoundedCornerShape(14.dp)
// The chevrons keep their layout slot and only fade, so the value never jumps sideways when
// focus arrives; the value colour cross-fades with them. A non-adjustable row (a profile row
// navigates, the empty-catalog placeholder does nothing) never shows them at all.
val chevronAlpha by animateFloatAsState(
if (focused && row.adjustable) 0.6f else 0f,
tween(160),
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
label = "chevrons",
)
val valueColor by animateColorAsState(
ink.fg(if (focused) 1f else 0.6f),
tween(160),
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
label = "valueColor",
)
// A press always gets an answer. Accepted: the chevron on the pressed side ticks outward and
// springs back. Refused: the whole value slot gives 4 dp toward the press and springs back —
// the "door is locked" motion, so a limit reads as a limit instead of as a dropped input.
val chevronKick = remember { Animatable(0f) }
LaunchedEffect(stepToken) {
if (stepToken == 0) return@LaunchedEffect
chevronKick.snapTo(2f * adjustDir)
chevronKick.animateTo(0f, spring(dampingRatio = 0.45f, stiffness = 900f))
}
val refusal = remember { Animatable(0f) }
LaunchedEffect(refusalToken) {
if (refusalToken == 0) return@LaunchedEffect
refusal.animateTo(
ConsoleMotion.REFUSAL_NUDGE.value * adjustDir,
ConsoleMotion.ease(ConsoleMotion.REFUSAL_MS / 2),
)
refusal.animateTo(0f, spring(dampingRatio = 0.5f, stiffness = Spring.StiffnessMedium))
}
Column {
if (row.header != null) {
Text(
@@ -375,74 +680,127 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
modifier = Modifier.padding(start = 16.dp, top = 14.dp, bottom = 4.dp),
)
}
Column(
Row(
modifier = Modifier
.fillMaxWidth()
.graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
.clip(shape)
.background(visuals.background)
.border(1.dp, visuals.border, shape)
.consoleGlass(ConsoleShape.Row, visuals)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onClick,
)
// ONE announcement per row, not five leaves read in layout order. Three things had
// to be gathered to make it true:
// * the VALUE of a toggle row existed nowhere in the tree — the switch replaces
// the value text (see below), so `row.value` ("On"/"Off") was drawn by nothing;
// * the DESCRIPTION lives in the floating band at the far bottom of the screen,
// which is the right place to LOOK and the wrong place to be read — so it is
// merged here, where the row it explains is;
// * `enabled` was a colour and nothing else.
// A row therefore announces "Refresh rate, 120 Hz, Frame rate the host renders and
// streams at" — which is what the screen already means, said once.
.semantics(mergeDescendants = true) {
role = if (row.toggled != null) Role.Switch else Role.Button
contentDescription = listOfNotNull(
row.label,
row.value.takeIf { it.isNotBlank() },
row.detail.takeIf { it.isNotBlank() },
).joinToString(", ")
row.toggled?.let {
toggleableState = if (it) ToggleableState.On else ToggleableState.Off
}
if (!row.enabled) disabled()
}
.padding(horizontal = 16.dp, vertical = 13.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(
row.label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
// A disabled row (the "No profiles yet" placeholder) dims but stays focusable,
// so its detail line can still explain what would go here.
color = ink.fg(if (row.enabled) 1f else 0.45f),
maxLines = 1,
)
Spacer(Modifier.weight(1f))
Text(
row.label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
// A disabled row (the "No profiles yet" placeholder) dims but stays focusable, so
// the detail band can still explain what would go here.
color = ink.fg(if (row.enabled) 1f else 0.45f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
// Takes the slack rather than a Spacer doing it, so a long label ellipsizes into
// the room it actually has instead of shoving the value slot off the row.
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
Row(
modifier = Modifier.offset { IntOffset(refusal.value.dp.roundToPx(), 0) },
verticalAlignment = Alignment.CenterVertically,
) {
if (row.toggled != null) {
// A toggle is a switch, not text — the sliding knob + tinting track IS the value.
ConsoleSwitch(on = row.toggled, focused = focused)
} else {
Text(" ", color = ink.fg, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
// The value slides in the direction it was stepped and its width animates, so
// cycling a choice reads as motion through a list rather than a text swap.
AnimatedContent(
targetState = row.value,
transitionSpec = {
val dir = adjustDir
(slideInHorizontally(tween(180)) { w -> w / 2 * dir } + fadeIn(tween(180))) togetherWith
(slideOutHorizontally(tween(140)) { w -> -w / 2 * dir } + fadeOut(tween(100))) using
SizeTransform(clip = false)
},
label = "value",
) { value ->
Text(
value,
style = MaterialTheme.typography.bodyMedium,
color = valueColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
Icon(
Icons.Filled.ChevronLeft,
// Decoration: it says "this value steps", which the row's Switch/Button
// role already says. Left in the tree it is read out on every focused row.
contentDescription = null,
tint = ink.fg,
modifier = Modifier
.size(18.dp)
.semantics { hideFromAccessibility() }
.graphicsLayer { alpha = chevronAlpha }
.offset { IntOffset(minOf(chevronKick.value, 0f).dp.roundToPx(), 0) },
)
if (row.options != null && row.selectedIndex in row.options.indices) {
// The drum — see ConsoleOptionBand. Its width is FIXED by the row, so a
// step can never reflow the chevrons, and the tabular-figures concern
// dissolves with it: nothing about the row's layout depends on the label.
ConsoleOptionBand(
options = row.options,
selection = row.selectedIndex,
focused = focused,
width = bandWidth,
)
} else {
// The flat rows (profile pin counts, the empty-catalog placeholder) keep
// the quiet slip: the changed string slides in following the motion.
AnimatedContent(
targetState = row.value,
transitionSpec = {
val dir = adjustDir
(
slideInHorizontally(
ConsoleMotion.ease(ConsoleMotion.VALUE_MS),
) { w -> w / 2 * dir } +
fadeIn(ConsoleMotion.ease(ConsoleMotion.VALUE_MS))
) togetherWith (
slideOutHorizontally(
ConsoleMotion.ease(ConsoleMotion.VALUE_OUT_MS),
) { w -> -w / 2 * dir } +
fadeOut(ConsoleMotion.ease(100))
) using SizeTransform(clip = false) { _, _ -> snap() }
},
label = "value",
) { value ->
Text(
value,
style = MaterialTheme.typography.bodyMedium,
color = valueColor,
textAlign = TextAlign.End,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Text(" ", color = ink.fg, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
Icon(
Icons.Filled.ChevronRight,
contentDescription = null,
tint = ink.fg,
modifier = Modifier
.size(18.dp)
.semantics { hideFromAccessibility() }
.graphicsLayer { alpha = chevronAlpha }
.offset { IntOffset(maxOf(chevronKick.value, 0f).dp.roundToPx(), 0) },
)
}
}
// The focused row carries its own one-line description — no dedicated (space-eating)
// detail strip. It unfolds right where you're looking, and the row grows to fit.
AnimatedVisibility(
visible = focused && row.detail.isNotBlank(),
enter = fadeIn(tween(180, delayMillis = 60)) + expandVertically(tween(180)),
exit = fadeOut(tween(90)) + shrinkVertically(tween(150)),
) {
Text(
row.detail,
style = MaterialTheme.typography.bodySmall,
color = ink.fg(0.6f),
maxLines = 2,
modifier = Modifier.padding(top = 6.dp),
)
}
}
}
}
@@ -450,12 +808,17 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
/** Build the console settings rows from the current [Settings], writing through [update].
* [hasBodyVibrator] gates the "Rumble on this phone" row and [hasGyroscope] the "Gyro from this
* phone" row (both absent on TVs); [av1Capable] gates the AV1 codec entry (see
* `codecOptionsFor`). Every row declares its [GpTab]; the screen shows one tab at a time. */
* `codecOptionsFor`). [appVersion] is the installed version the About row states, and
* [openControllers] / [openLicenses] are the two rows that navigate rather than set anything.
* Every row declares its [GpTab]; the screen shows one tab at a time. */
internal fun buildSettingsRows(
s: Settings,
hasBodyVibrator: Boolean,
hasGyroscope: Boolean,
av1Capable: Boolean,
appVersion: String = "",
openControllers: () -> Unit = {},
openLicenses: () -> Unit = {},
update: (Settings) -> Unit,
): List<GpRow> {
fun <T> choice(
@@ -480,6 +843,8 @@ internal fun buildSettingsRows(
val i = if (idx < 0) 0 else (idx + 1) % options.size
options.getOrNull(i)?.let { write(it.first) }
},
options = options.map { it.second },
selectedIndex = idx,
)
}
fun toggle(
@@ -633,6 +998,28 @@ internal fun buildSettingsRows(
"triggers, lightbar and gyro.",
s.dsCapture, enabled = s.gamepadForwarding,
) { update(s.copy(dsCapture = it)) },
// The diagnostics view — same screen the touch settings reach, same words for it. It was
// reachable from touch ONLY, which on a TV box means not at all: there is no touch interface
// to fall back to there, and "my controller does nothing" is the support case it answers.
//
// Deliberately NOT gated on the master forwarding switch its neighbours all follow: this is
// the row you reach for when forwarding looks broken, and a diagnostic that dims itself when
// the thing it diagnoses is off is worse than none.
//
// No value: this row navigates, it doesn't hold a setting (a count read here would be a
// snapshot, and a stale "none detected" is worse than no number at all — the screen it opens
// watches hot-plug live).
GpRow(
id = "controllers",
tab = GpTab.CONTROLLER,
header = "Diagnostics",
label = "Connected controllers",
value = "",
detail = "What the app detects, with a live input test.",
adjust = { false },
activate = openControllers,
adjustable = false,
),
// The palette leads Interface: it is the one row whose effect you can see while you step
// it (the backdrop behind this very list recolours), so it wants to be the first thing
@@ -647,7 +1034,7 @@ internal fun buildSettingsRows(
choice(
"hud", GpTab.INTERFACE, null, "Statistics overlay",
"How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " +
"A 3-finger tap cycles the tiers live.",
"Select + X on a pad, or a 3-finger tap, cycles the tiers live.",
STATS_VERBOSITY_OPTIONS, s.statsVerbosity,
) { update(s.copy(statsVerbosity = it)) },
toggle(
@@ -680,6 +1067,22 @@ internal fun buildSettingsRows(
} else {
null
},
) + listOf(
// About closes the Interface section, the way the touch settings' last category does. The
// notices are a licence obligation and were reachable from touch only — on a TV box that is
// nowhere. The version rides in the VALUE slot rather than as a second, inert row: it is the
// identity half of an About page, and the screen this opens states it again at the top.
GpRow(
id = "licenses",
tab = GpTab.INTERFACE,
header = "About",
label = "Open-source licenses",
value = appVersion,
detail = "Third-party notices and credits.",
adjust = { false },
activate = openLicenses,
adjustable = false,
),
)
}
@@ -694,7 +1097,7 @@ internal fun buildSettingsRows(
* Controller-optimized UI toggle a few rows up, which swaps the standard interface in
* (d-pad-navigable; the profile editor lives there on every device, unlike tvOS where none exists).
*/
private fun buildProfileRows(
internal fun buildProfileRows(
profiles: List<StreamProfile>,
savedHosts: List<KnownHost>,
tv: Boolean,
@@ -741,6 +1144,7 @@ private fun buildProfileRows(
adjust = { false },
activate = { openPinPicker(p) },
adjustable = false,
actionHint = "Pin to hosts",
)
}
}
@@ -0,0 +1,100 @@
package io.unom.punktfunk
import io.unom.punktfunk.kit.discovery.DiscoveredHost
import io.unom.punktfunk.kit.security.KnownHost
/**
* The console home's tiles, in carousel order: every saved host with its pinned host+profile cards
* immediately behind it, then the hosts seen on the network but not yet saved, then Add Host.
*
* Pure, and deliberately not a composable. The half of this that can be WRONG is the ordering and
* what a tile claims a pin drifting away from the host it belongs to, a discovered host offered a
* second time next to the saved record it already is, a chip naming a profile the press won't
* actually use. None of that needs a display to be checked, and `HomeTilesTest` checks it without
* one; the console home itself needs the live JNI core to compose at all.
*
* [isOnline] and [pinsFor] arrive as lambdas rather than as the discovery lists and the profile
* store behind them: "online" means advertising on mDNS OR answering a QUIC probe (the routed
* Tailscale/VPN case), which is a rule belonging to the screen that does the probing, not to a list
* builder.
*/
internal fun buildHomeTiles(
savedHosts: List<KnownHost>,
/** The live catalog — resolves each host's binding into the name and colour its chip wears. */
profiles: List<StreamProfile>,
pinsFor: (KnownHost) -> List<StreamProfile>,
/** Already de-duped against [savedHosts] by the caller: a saved host is not also "discovered". */
discoveredUnsaved: List<DiscoveredHost>,
isOnline: (KnownHost) -> Boolean,
/**
* Dial a saved host. The second argument is `connect`'s one-off profile reference: null on a
* host's own tile (follow whatever the host is bound to), the pinned profile's id on a pin tile.
*/
onConnect: (KnownHost, String?) -> Unit,
onConnectDiscovered: (DiscoveredHost) -> Unit,
onAddHost: () -> Unit,
): List<HomeTile> = buildList {
savedHosts.forEach { kh ->
val bound = kh.profileId?.let { id -> profiles.firstOrNull { it.id == id } }
add(
HomeTile(
id = "saved-${kh.id}",
title = kh.name,
subtitle = "${kh.address}:${kh.port}",
filled = true,
online = isOnline(kh),
paired = kh.paired,
knownHost = kh,
// The binding is what a press will actually do, so the tile says so — the console
// can't edit profiles, but it must never lie about which one it uses. It rides in
// the card's own chip now rather than as a "· Name" tail on the address, which is
// where it read as an afterthought.
profileName = bound?.name,
profileAccent = accentColor(bound?.accent),
activate = { onConnect(kh, null) },
),
)
// Pinned host+profile combinations, right after their host: one focus-and-press each,
// which is the affordance a controller surface does well (menus are not).
pinsFor(kh).forEach { p ->
add(
HomeTile(
id = "pin-${kh.id}-${p.id}",
title = kh.name,
// The address, like every other card — the PROFILE is what makes this card
// different, and it now says so in the chip instead of standing in for the
// subtitle, which left a pin card unable to say where it pointed.
subtitle = "${kh.address}:${kh.port}",
filled = true,
online = isOnline(kh),
paired = kh.paired,
knownHost = kh,
pinnedProfileId = p.id,
profileName = p.name,
profileAccent = accentColor(p.accent),
activate = { onConnect(kh, p.id) },
),
)
}
}
discoveredUnsaved.forEach { dh ->
add(
HomeTile(
id = "disc-${dh.host}:${dh.port}",
title = dh.name,
subtitle = "${dh.host}:${dh.port}",
online = true,
activate = { onConnectDiscovered(dh) },
),
)
}
add(
HomeTile(
id = "add",
title = "Add Host",
subtitle = "Register a host by address",
isAdd = true,
activate = onAddHost,
),
)
}
@@ -15,13 +15,12 @@ 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.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PageSize
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -42,6 +41,11 @@ import androidx.compose.ui.layout.ContentScale
import android.content.res.Configuration
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.zIndex
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
@@ -54,6 +58,7 @@ import androidx.compose.ui.unit.sp
import coil.ImageLoader
import coil.compose.AsyncImage
import coil.request.ImageRequest
import io.unom.punktfunk.components.launcherIcon
import io.unom.punktfunk.kit.library.DEFAULT_MGMT_PORT
import io.unom.punktfunk.kit.library.GameEntry
import io.unom.punktfunk.kit.library.LibraryClient
@@ -127,7 +132,7 @@ fun LibraryScreen(
Box(Modifier.fillMaxSize()) {
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
GamepadAuroraBackground(Modifier.fillMaxSize())
Column(Modifier.fillMaxSize().systemBarsPadding()) {
Column(Modifier.fillMaxSize().consoleSafeArea()) {
ConsoleHeader("${host.name} — Library")
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
when (val s = state) {
@@ -171,7 +176,7 @@ fun LibraryScreen(
// Launching overlay — the connect + host-side game boot takes a moment; block the pad while it runs.
if (launching) {
Box(
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.6f)),
Modifier.fillMaxSize().background(ink.modalScrim),
contentAlignment = Alignment.Center,
) {
Column(
@@ -187,7 +192,7 @@ fun LibraryScreen(
// screen (ignore the safe area in landscape, where the bottom edge isn't a tap target).
Box(
Modifier.align(Alignment.BottomStart)
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
.consoleLegendInsets(landscape)
.padding(ConsoleLegendInset),
) {
GamepadHintBar(
@@ -262,10 +267,18 @@ private fun Coverflow(
Text(
if (current?.isLauncher == true) "LAUNCHERS" else "GAMES",
style = MaterialTheme.typography.labelSmall,
color = Color.White.copy(alpha = 0.45f),
// The palette's ink, not white: on a pale field this heading was white on
// near-white and simply wasn't there.
color = ink.fg(0.45f),
letterSpacing = 2.sp,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
modifier = Modifier
.fillMaxWidth()
// A live region: this heading is the ONLY signal that the cursor has
// crossed from the launchers into the games, and a coverflow gives a
// reader no other way to notice — it is one strip, not two lists.
.semantics { liveRegion = LiveRegionMode.Polite }
.padding(bottom = 8.dp),
)
}
HorizontalPager(
@@ -287,10 +300,22 @@ private fun Coverflow(
.width(coverWidth)
.height(coverHeight)
// Touch: tap the centred cover to launch it; tap a neighbour to bring it centre.
.clickable {
// The label says which of the two a press does, because from the poster
// alone they are indistinguishable — and the CENTRED one is the only one A
// acts on, which nothing else in the tree says.
.clickable(
onClickLabel = if (page == pagerState.currentPage) {
"Launch ${games[page].title}"
} else {
"Bring ${games[page].title} to the centre"
},
) {
if (page == pagerState.currentPage) onLaunch(games[page])
else scope.launch { pagerState.animateScrollToPage(page) }
}
.semantics {
if (page == pagerState.currentPage) selected = true
}
.graphicsLayer {
// Centre at full size; EVERY neighbour settles to one size, so an even pitch
// yields even VISUAL gaps. (A progressive shrink made the outer gaps grow —
@@ -349,11 +374,14 @@ private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Mo
val ink = LocalGamepadInk.current
val candidates = game.art.posterCandidates
var idx by remember(game.id) { mutableStateOf(0) }
val shape = RoundedCornerShape(16.dp)
val shape = ConsoleShape.Poster
Box(
modifier = modifier
.clip(shape)
.background(Color(0xFF241F3D))
// The ground a cover sits on while its art loads (and the permanent one for a launcher
// entry, which rarely has art). Palette-derived rather than a fixed indigo, so a poster
// wall on a pale field isn't a grid of dark holes.
.background(LocalGamepadPalette.current.groundColor)
.border(1.dp, ink.fg(0.12f), shape),
contentAlignment = Alignment.Center,
) {
@@ -367,27 +395,53 @@ private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Mo
onError = { idx++ }, // this candidate failed — try the next, or fall to the placeholder
)
} else {
// A launcher rarely has poster art. Naming the launcher says "opens Steam"; the title
// would read as "a game whose cover failed to load".
Text(
if (game.isLauncher) game.storeLabel else game.title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = ink.fg(0.75f),
textAlign = TextAlign.Center,
modifier = Modifier.padding(12.dp),
)
// A launcher ships no poster by design, so its brand mark IS the poster — drawn big and
// centred, tinted like the text it replaces. Falling back to the launcher's name says
// "opens Steam" for a mark we don't ship; the title would read as "a game whose cover
// failed to load".
val mark = launcherIcon(game.iconToken)
if (mark != null) {
Icon(
imageVector = mark,
contentDescription = game.title,
tint = ink.fg(0.75f),
modifier = Modifier.fillMaxSize(0.45f),
)
} else {
Text(
if (game.isLauncher) game.storeLabel else game.title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = ink.fg(0.75f),
textAlign = TextAlign.Center,
modifier = Modifier.padding(12.dp),
)
}
}
// Store badge, top-start — brand-filled for a launcher entry (design D4).
Box(Modifier.fillMaxSize().padding(8.dp), contentAlignment = Alignment.TopStart) {
Text(
game.storeLabel,
style = MaterialTheme.typography.labelSmall,
color = ink.fg,
// A launcher's badge is brand-filled, so it reads on the ACCENT; a game's sits on
// a plain dark wash over its own art.
color = if (game.isLauncher) ink.onAccent else Color.White,
modifier = Modifier
.clip(RoundedCornerShape(50))
// A bare store name read out after the title says nothing about WHY it is
// there; the poster's own description already carries the title.
.semantics {
contentDescription = if (game.isLauncher) {
"Opens ${game.storeLabel}"
} else {
"From ${game.storeLabel}"
}
}
.clip(ConsoleShape.Pill)
.background(
if (game.isLauncher) MaterialTheme.colorScheme.primary
// The console's palette accent, not `MaterialTheme.colorScheme.primary` —
// that is the TOUCH theme's colour (Material You, seeded from the user's
// wallpaper), which had nothing to do with the field this poster sits on.
if (game.isLauncher) ink.accent
else Color.Black.copy(alpha = 0.5f),
)
.padding(horizontal = 8.dp, vertical = 3.dp),
@@ -1,8 +1,12 @@
package io.unom.punktfunk
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -19,20 +23,130 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
/**
* Open-source licenses: punktfunk's own license (MIT OR Apache-2.0) plus the third-party software
* notices, read from the bundled `THIRD-PARTY-NOTICES.txt` asset (generated by
* scripts/gen-third-party-notices.sh). Reached from [SettingsScreen]; Back returns there.
*
* This is the TOUCH entry point; [ConsoleLicensesScreen] shows the same notices on the console's
* field, where they need a scroll route a controller can actually drive.
*/
@Composable
fun LicensesScreen(onBack: () -> Unit) {
val context = LocalContext.current
BackHandler(onBack = onBack)
Column(Modifier.fillMaxSize()) {
// Pinned header with a visible Back affordance (Back-button/gesture still work via BackHandler).
Row(
modifier = Modifier.fillMaxWidth().padding(start = 4.dp, end = 12.dp, top = 8.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
Text("Open-source licenses", style = MaterialTheme.typography.headlineSmall)
}
LicensesBody(
scroll = rememberScrollState(),
contentPadding = PaddingValues(start = 20.dp, end = 20.dp, bottom = 24.dp),
)
}
}
/**
* The notices on the console's field. The reason this exists as its own screen rather than the touch
* one dropped into the shell is the SCROLL: the body is a wall of text with exactly one focusable
* node (the touch screen's back arrow), and Compose scrolls a container only to keep a FOCUSED child
* visible so a controller could reach the first screenful of `THIRD-PARTY-NOTICES.txt` and not one
* line further. Here up/down steps and the shoulders page, driving the scroll state directly.
*
* B closes, as everywhere else; there is nothing on this screen to confirm, so A is not advertised.
*/
@Composable
fun ConsoleLicensesScreen(onBack: () -> Unit, navActive: Boolean = true) {
BackHandler(onBack = onBack)
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
val hazeState = remember { HazeState() }
val scroll = rememberScrollState()
val scrollBy = rememberConsoleScroller(scroll)
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true
GamepadNavEffect2D(
active = navActive,
onDirection = { dir ->
when (dir) {
NavDir.UP -> scrollBy(-1, false)
NavDir.DOWN -> scrollBy(1, false)
// Left/right are deliberately inert: there is nothing beside this text, and paging
// sideways off a D-pad would be a second, undocumented way to do the shoulders' job.
NavDir.LEFT, NavDir.RIGHT -> {}
}
},
onActivate = {},
onShoulder = { delta -> scrollBy(delta, true) },
)
Box(Modifier.fillMaxSize()) {
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
// Calm: this is a screen to read, and a drifting field behind small monospace text is
// the one place the aurora would be actively unhelpful. Full-bleed under the cutout —
// only the content takes the safe area.
GamepadFormBackground(Modifier.fillMaxSize())
// Inked from the palette: the notices carry no colour of their own, so outside a Surface
// they would render in Material's default BLACK content colour over the aurora.
ConsoleInkedTheme {
Column(Modifier.fillMaxSize().consoleSafeArea()) {
LicensesBody(
scroll = scroll,
contentPadding = PaddingValues(
start = ConsoleEdgeInset,
end = ConsoleEdgeInset,
bottom = ConsoleLegendClearance,
),
) {
ConsoleHeader("Open-source licenses", horizontalInset = false)
}
}
}
}
Box(
Modifier
.align(Alignment.BottomStart)
.consoleLegendInsets(landscape)
.padding(ConsoleLegendInset),
) {
GamepadHintBar(
listOfNotNull(
GamepadHint('↕', PadGlyph.Arrow, "Scroll"),
// A TV remote has no shoulders — its route is the D-pad, one step at a time.
GamepadHint('⇄', PadGlyph.Arrow, "Page").takeIf { padIsGamepad },
PadGlyph.hint('B', "Close", onClick = onBack),
),
hazeState = hazeState,
)
}
}
}
/**
* The notices themselves, shared by both interfaces the licenses are a legal obligation, so the
* two routes must show the same text rather than two copies that can drift. [heading] is empty for
* the touch screen, which pins its own title row above the scroll.
*/
@Composable
private fun LicensesBody(
scroll: ScrollState,
contentPadding: PaddingValues,
heading: @Composable () -> Unit = {},
) {
val context = LocalContext.current
val notices = remember {
runCatching {
context.assets.open("THIRD-PARTY-NOTICES.txt").bufferedReader().use { it.readText() }
@@ -52,52 +166,40 @@ fun LicensesScreen(onBack: () -> Unit) {
}.getOrNull()
}
Column(Modifier.fillMaxSize()) {
// Pinned header with a visible Back affordance (Back-button/gesture still work via BackHandler).
Row(
modifier = Modifier.fillMaxWidth().padding(start = 4.dp, end = 12.dp, top = 8.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
Text("Open-source licenses", style = MaterialTheme.typography.headlineSmall)
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp)
.padding(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
if (version != null) {
Text(
"Punktfunk $version",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scroll)
.padding(contentPadding),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
heading()
if (version != null) {
Text(
"Punktfunk is licensed under MIT OR Apache-2.0, at your option. It uses the open-source " +
"components below, each under its own license.",
"Punktfunk $version",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
"Punktfunk is licensed under MIT OR Apache-2.0, at your option. It uses the open-source " +
"components below, each under its own license.",
style = MaterialTheme.typography.bodyMedium,
)
Text(
notices,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
)
if (fontLicense != null) {
Text("Bundled font", style = MaterialTheme.typography.titleMedium)
Text(
"The Geist typeface is licensed under the SIL Open Font License 1.1.",
style = MaterialTheme.typography.bodyMedium,
)
Text(
notices,
fontLicense,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
)
if (fontLicense != null) {
Text("Bundled font", style = MaterialTheme.typography.titleMedium)
Text(
"The Geist typeface is licensed under the SIL Open Font License 1.1.",
style = MaterialTheme.typography.bodyMedium,
)
Text(
fontLicense,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
)
}
}
}
}
@@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.systemBars
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -142,6 +143,16 @@ class MainActivity : ComponentActivity() {
var lastPadStyle by mutableStateOf(Gamepad.PadStyle.GENERIC)
private set
/**
* The `InputDevice.id` of the controller driving the console UI, or 0 for none. Kept beside
* [lastPadStyle] because the console's menu haptics render on the DRIVING pad's own motors when
* it has any a rumble that comes out of the device you are not holding is worse than none.
* Falls back to the phone body (see `rememberConsoleHaptics`), and to silence on a TV, where
* neither a remote nor the box has an actuator.
*/
var lastPadDeviceId by mutableIntStateOf(0)
private set
/**
* A `punktfunk://` URL waiting to be routed — set from the VIEW intent that started (or
* re-entered) this activity, cleared by whoever handles it. Compose observes it.
@@ -606,7 +617,10 @@ class MainActivity : ComponentActivity() {
// pad, WHICH pad family, so the glyphs wear its lettering/shapes.
if (event.action == KeyEvent.ACTION_DOWN && isConsoleNavKey(event.keyCode)) {
lastPadIsGamepad = event.isFromSource(InputDevice.SOURCE_GAMEPAD)
if (lastPadIsGamepad) lastPadStyle = Gamepad.styleFor(event.device)
if (lastPadIsGamepad) {
lastPadStyle = Gamepad.styleFor(event.device)
lastPadDeviceId = event.deviceId
}
}
// The Controllers debug screen sees pad events before the navigation remap below.
padKeyProbe?.let { if (it(event)) return true }
@@ -695,6 +709,7 @@ class MainActivity : ComponentActivity() {
if (dir != 0) {
lastPadIsGamepad = true // a stick/HAT push can only come from a real gamepad
lastPadStyle = Gamepad.styleFor(event.device)
lastPadDeviceId = event.deviceId
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, dir))
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, dir))
return true
@@ -577,7 +577,7 @@ private fun GeneralSettings(s: Settings, update: (Settings) -> Unit) {
selected = s.statsVerbosity,
field = "stats_verbosity",
caption = "Compact is one line; Detailed adds the decoder and latency breakdown. " +
"A 3-finger tap cycles the tiers in-stream.",
"A 3-finger tap, or Select + X on a pad, cycles the tiers in-stream.",
) { v -> update(s.copy(statsVerbosity = v)) }
}
DeviceScopeOnly {
@@ -28,6 +28,9 @@ import android.view.inputmethod.InputConnection
import android.view.inputmethod.InputMethodManager
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
@@ -53,6 +56,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
@@ -152,6 +156,35 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
motionHint = false
}
}
// Whether this session has a controller — the start banner names pad chords only when there is
// a pad to press them on. Seeded from the router the moment it is built (it opens a slot for
// every already-connected controller) and latched true by a pad that arrives later; it never
// goes back to false. A pad LEAVING inside the banner's six seconds is not worth the write:
// teardown closes every slot, and poking Compose state from there is exactly what the nulled
// callbacks in onDispose avoid. The latch is also what carries a pad through a USB capture
// claiming it — its InputDevice slot closes and reopens as a capture-link one.
var padPresent by remember(handle) { mutableStateOf(false) }
// The start-of-stream banner: what this session's shortcuts ARE, said once. A stream takes the
// whole screen and answers to none of the device's usual gestures, so it has to say how to get
// back out — the desktop console draws the same pill for the same reason
// (`pf-console-ui/src/skia_overlay.rs`, BANNER_S = 6 s with a BANNER_FADE_S = 0.6 s tail).
// Two states because the fade and the removal are different moments: `bannerUp` composes the
// pill at all, `bannerFading` runs its alpha down over the last 600 ms.
var bannerUp by remember(handle) { mutableStateOf(true) }
var bannerFading by remember(handle) { mutableStateOf(false) }
val bannerAlpha by animateFloatAsState(
targetValue = if (bannerFading) 0f else 1f,
// Linear, like the desktop's (BANNER_S - age) / BANNER_FADE_S ramp — Compose's default
// easing would hold near-opaque and then drop, which reads as a glitch rather than a fade.
animationSpec = tween(600, easing = LinearEasing),
label = "streamStartBanner",
)
LaunchedEffect(handle) {
delay(5400) // 6 s the 0.6 s tail: fully opaque until here, exactly as on the desktop
bannerFading = true
delay(600)
bannerUp = false // stop composing it once it is invisible
}
// The one place mute is toggled — Compose state + the native flag, always together.
val setMicMuted = { muted: Boolean ->
micMuted = muted
@@ -161,7 +194,8 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
// Live decode stats for the HUD. `statsOn` (verbosity != OFF) gates the whole native pipeline:
// the per-frame sampling (nativeSetVideoStatsEnabled — a hidden HUD costs one atomic load per
// frame) AND the 1 s poll loop, which only runs while the overlay is visible. Enabling resets
// the native window, so re-showing never renders stale data. A 3-finger tap cycles the
// the native window, so re-showing never renders stale data. A 3-finger tap — or the Select + X
// pad chord, which is the only route a TV or a passthrough-touch session has — cycles the
// verbosity tier live (Off → Compact → Normal → Detailed → Off); the default comes from
// Settings. The tier only changes how many lines `StatsOverlay` draws — switching between the
// visible tiers keeps sampling running (the effect keys on `statsOn`, not the tier) so it never
@@ -184,6 +218,11 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
// TV form factor (leanback): the decoder actively switches the HDMI output mode to the stream
// refresh; a phone/tablet gets the softer seamless frame-rate hint instead.
val isTv = remember { context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) }
// A screen with fingers on it — the start banner may only name the three-finger stats tap on a
// device that can perform it. A TV box has no touchscreen at all, and its remote is not one.
val hasTouch = remember {
context.packageManager.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN)
}
LaunchedEffect(handle, statsOn) {
NativeBridge.nativeSetVideoStatsEnabled(handle, statsOn)
if (statsOn) {
@@ -362,6 +401,9 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
initialSettings.systemButtonsForward(), initialSettings.guideGestureEnabled(),
)
activity?.gamepadRouter = router
// Every controller that was already connected got a slot in the router's constructor, so
// this is the session's pad answer at t=0 — what the start banner's words are chosen from.
padPresent = router.forwardedDevices().isNotEmpty()
// Select+Start+L1+R1 chord leaves the stream — a deliberate quit (signal it so the host skips
// the keep-alive linger), unlike a host-ended / backgrounded drop. The router debounces it
// (must be held ~1.5 s) and fires onExitChord on its main-thread timer, so leave the stream
@@ -384,6 +426,11 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
micHint = if (next) "Microphone muted" else "Microphone live"
}
}
// Select + X steps the stats overlay one tier — the same live cycle the three-finger tap
// performs, and the ONLY route to it on a TV or in a passthrough-touch session. Session-
// local on purpose: this mirrors the tap exactly (`onCycleStats` below), and the settings
// row calls it a live cycle — the stored default is what the next stream starts from.
router.onStatsChord = { statsVerbosity = statsVerbosity.next() }
// Physical mouse: uncaptured hover/click/wheel forwards as absolute pointing; captured
// (setting or the Ctrl+Alt+Shift+Q chord) raw deltas forward as relative mouse-look.
// The local cursor is hidden over the stream — the host's own cursor, composited into
@@ -505,7 +552,12 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
// The other edge: a controller that arrives (or first speaks) mid-session gets its sensors
// read too. The pads already connected were swept by PadSensors.start() above — both run
// on the main thread with nothing between them, so no controller falls through the gap.
router.onSlotOpened = { deviceId -> padSensors?.onSlotOpened(deviceId) }
router.onSlotOpened = { deviceId ->
padSensors?.onSlotOpened(deviceId)
// A pad that wakes up a second into the stream still deserves the chord banner — the
// desktop rebuilds its banner text every frame for exactly this case.
padPresent = true
}
// Steam Controller 2 as-is passthrough (opt-out): capture a wired/Puck USB pad — or an
// already-paired BLE one — and forward its raw reports; the host mirrors a real
// 28DE:1302 that its Steam drives directly, and Steam's rumble/settings writes come back
@@ -645,6 +697,7 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
ds?.stop() // rumble-stop on the physical pad + release the USB link + free the wire slot
router.onExitArmed = null // don't poke Compose state from release()'s disarm while tearing down
router.onMicChord = null // same: no mute toggle on buttons released during teardown
router.onStatsChord = null // same: no tier cycle on buttons released during teardown
router.onMotionUnreachable = null // same: no notice raised by a slot closing at teardown
router.release() // flush every slot (nothing sticks host-side) + drop the hot-plug listener
activity?.gamepadRouter = null
@@ -847,6 +900,42 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
if (remotePointerOn) {
RemotePointerHint(Modifier.align(Alignment.TopCenter).padding(top = 16.dp))
}
// The start banner (desktop parity), naming ONLY the shortcuts this session actually has:
// pad chords when a controller is here, the Back gesture and the three-finger tap when it
// is not. Recomputed rather than captured, because both inputs change under it — a pad can
// wake mid-banner, and `micRunning` only settles once the capture has actually opened.
// Above the video and below the gesture layer: it teaches touches, it must never eat one.
//
// Bottom-centre is the desktop's placement and the only edge left — TopStart is the HUD,
// TopEnd the mic badge, TopCentre the three transient cues — but MotionUnreachableHint
// already owns it, and both of these can be up at t≈0. The banner YIELDS rather than
// stacking or sliding off-centre: the notice reports something broken about THIS session
// and names the setting that fixes it, while the banner repeats shortcuts that will be
// there next stream too. Two pills sharing an edge for six seconds would cost the reader
// both.
if (bannerUp && !motionHint) {
StreamStartBanner(
text = buildList {
if (padPresent) {
add("Hold Select + Start + L1 + R1 to leave")
// Only while a capture is actually running: the chord itself no-ops
// without one, and offering a mute for a mic nobody has is the lie the
// whole control exists to avoid.
if (micRunning) add("Select + Y mic")
add("Select + X stats")
} else {
// No pad: Back is the deliberate exit (gesture, key, or a TV remote's
// button — all land on the same BackHandler).
add("Back leaves the stream")
// The tap lives in the pointer touch models only — passthrough gives every
// finger to the host verbatim — and needs a screen to put three fingers on.
if (hasTouch && touchMode != TouchMode.TOUCH) add("three-finger tap for stats")
}
}.joinToString(" · "),
alpha = bannerAlpha,
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 24.dp),
)
}
// Invisible 1-px focus anchor for the host-typing soft keyboard (three-finger swipe up
// in the mouse modes) AND the pointer-capture grab target — it never draws or takes
// touches, it just owns IME focus and receives captured-pointer events.
@@ -1053,6 +1142,33 @@ private fun RemotePointerHint(modifier: Modifier = Modifier) {
)
}
/**
* The start-of-stream banner: the shortcuts this session actually has, in the same pill as every
* other in-stream cue, shown once and then gone. The desktop console draws the identical thing
* bottom-centre (`pf-console-ui/src/skia_overlay.rs` six seconds with a 0.6 s fade), because a
* stream owns the whole screen and answers to none of the device's usual gestures: without a line
* saying how to get back out, the only discoverable exit is force-quitting the app.
*
* [text] and [alpha] are the caller's. Only it knows what this session HAS a pad, a mic, a
* touchscreen and only it owns the timer, which is precisely what a screenshot wants to skip.
* Purely visual: it sits below the gesture layer, takes no touches and is never clickable. Internal
* so the screenshot scene can shoot the real pill instead of a copy of it that drifts.
*/
@Composable
internal fun StreamStartBanner(text: String, alpha: Float, modifier: Modifier = Modifier) {
Text(
text,
// Alpha FIRST: the fade has to take the pill's backdrop with it, and everything after this
// in the chain draws inside the layer it opens.
modifier = modifier
.alpha(alpha)
.background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(8.dp))
.padding(horizontal = 14.dp, vertical = 8.dp),
color = Color.White,
fontSize = 15.sp,
)
}
/**
* Invisible focus anchor for typing on the host: the three-finger swipe summons the device IME
* onto this view. Two IME models, picked by the host's capabilities:
@@ -0,0 +1,99 @@
package io.unom.punktfunk.components
// GENERATED by scripts/gen_launcher_icon_tables.py from the assets/launcher-icons masters.
// Do not edit by hand — re-run `bash scripts/gen-launcher-icons.sh` instead.
// Per-mark provenance and licensing: assets/launcher-icons/README.md.
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.PathParser
import androidx.compose.ui.unit.dp
import kotlin.math.max
/**
* The brand mark a `role: "launcher"` tile draws, resolved from the entry's `icon` token.
* Material ships no brand icons, so this is a curated registry the sibling of [OsIcons],
* which does the equivalent job for the host cards.
*
* Held as raw SVG path strings rather than transcribed ImageVector DSL: [PathParser] builds
* the vector once and [launcherIcon] caches it. Viewports are the masters' own and are NOT
* all square, so the builder letterboxes a mark forced into a square box is a squashed mark.
*/
private class LauncherGlyph(
val viewportWidth: Float,
val viewportHeight: Float,
val d: String,
)
private val GLYPHS: Map<String, LauncherGlyph> = mapOf(
"steam" to LauncherGlyph(
viewportWidth = 496f,
viewportHeight = 512f,
d = "M496 256c0 137-111.2 248-248.4 248-113.8 0-209.6-76.3-239-180.4l95.2 39.3c6.4 32.1 34.9 56.4 68.9 56.4 39.2 0 71.9-32.4 70.2-73.5l84.5-60.2c52.1 1.3 95.8-40.9 95.8-93.5 0-51.6-42-93.5-93.7-93.5s-93.7 42-93.7 93.5v1.2L176.6 279c-15.5-.9-30.7 3.4-43.5 12.1L0 236.1C10.2 108.4 117.1 8 247.6 8 384.8 8 496 119 496 256zM155.7 384.3l-30.5-12.6a52.79 52.79 0 0 0 27.2 25.8c26.9 11.2 57.8-1.6 69-28.4 5.4-13 5.5-27.3.1-40.3-5.4-13-15.5-23.2-28.5-28.6-12.9-5.4-26.7-5.2-38.9-.6l31.5 13c19.8 8.2 29.2 30.9 20.9 50.7-8.3 19.9-31 29.2-50.8 21zm173.8-129.9c-34.4 0-62.4-28-62.4-62.3s28-62.3 62.4-62.3 62.4 28 62.4 62.3-27.9 62.3-62.4 62.3zm.1-15.6c25.9 0 46.9-21 46.9-46.8 0-25.9-21-46.8-46.9-46.8s-46.9 21-46.9 46.8c.1 25.8 21.1 46.8 46.9 46.8z",
),
"lutris" to LauncherGlyph(
viewportWidth = 24f,
viewportHeight = 24f,
d = "m21.231 18.89.001-.002c-1.293 3.243-5.218 5.232-9.447 5.105C5.3 23.993 0 18.48 0 11.906S5.276.001 11.785.001c1.793 0 3.493.406 5.015 1.13.081-.177.271-.544.451-.557.238-.017.374.137.526.309.154.172.46.429.46.429s1.393-.481 2.955.377c1.563.858 1.783 1.116 2.09 1.716.152.301.195.829.2 1.282a.796.796 0 0 0-.07-.003c-.496 0-.96.455-.96 1.08 0 .263.082.496.215.678l-.01.007a1.505 1.505 0 0 0-.132.01 18.704 18.704 0 0 0-.389-.142 2.53 2.53 0 0 1-.82-.472 1.402 1.402 0 0 0-1.196-2.112c-.383 0-.73.156-.982.41-.472-.271-1.174-.482-2.527-.565l-.407-.011c-2.282.012-3.611.279-5.979 1.301-.603.283-1.206.615-1.785 1.001-.423.3-.639.67-.709 1.137a1.326 1.326 0 0 0 1.23 1.373h.042c1.27.06 2.039 1.99 2.063 2.497.004.05.004.023.003.08-.032.727-.37 1.267-1.088 1.246a1.231 1.231 0 0 1-.976-.494c-.063-.077-.103-.172-.159-.254-.666-1.081-1.732-1.36-2.771-1.523-.438-.068-1.073-.122-1.31.25a8.28 8.28 0 0 0-.577 3.063c-.02 5.036 4.041 9.118 9.026 9.118 2.575 0 5.349-.952 6.993-2.7l-.035.03c-1.772 1.473-4.66 1.941-6.027 1.941-4.302 0-7.818-3.232-7.818-7.578 0-1.276.288-2.396.814-3.36.495.183.947.483 1.28 1.022a.24.24 0 0 0 .013.021c.064.092.111.197.182.284.424.524.881.658 1.342.68h.01c.43.013.768-.12 1.024-.342.347-.3.55-.79.577-1.382v-.014c.002-.085 0-.053-.004-.112-.024-.376-.333-1.318-.906-2.027-.266-.331-.587-.607-.95-.774l.12-.074c.756-.457 2.364-.977 4.592-.638 1.13.173 2.055.419 3.483.879 1.657.534 2.579 1.279 3.854 1.427.15.017.301.018.45.003.41 1.129.634 2.35.634 3.621 0 2.068-.59 3.995-1.611 5.62zm1.947-12.274s-.115.201-.364.322c-.103.05-.282-.075-.45.1-.359.726.516 1.332.923 1.315.408-.017.73-.432.712-.793-.017-.558-.82-.944-.82-.944zm.234-1.432c.255 0 .462.26.462.58 0 .32-.207.58-.462.58-.254 0-.46-.26-.46-.58 0-.32.206-.58.46-.58zm-3.292-.951c.492 0 .89.403.89.9a.895.895 0 0 1-.89.898.895.895 0 0 1-.89-.899c0-.496.399-.899.89-.899z",
),
"heroic" to LauncherGlyph(
viewportWidth = 24f,
viewportHeight = 24f,
d = "M11.999 0 11.997 0a.891.891 0 0 0-.36.075C8.964 1.253 6.29 2.434 3.618 3.613A.893.893 0 0 0 3.1 4.619l3.146 14.646c.043.197.15.375.307.504l4.88 4.027a.895.895 0 0 0 1.131.006l5-4.031a.895.895 0 0 0 .315-.516L20.9 4.614a.895.895 0 0 0-.515-1L12.358.074A.892.892 0 0 0 12 0zm0 .35v.003c.114 0 .228.023.334.07l7.42 3.27a.827.827 0 0 1 .476.924l-2.793 13.535a.83.83 0 0 1-.289.478l-4.623 3.725a.826.826 0 0 1-1.045-.006l-4.513-3.723a.829.829 0 0 1-.281-.465L3.775 4.622a.83.83 0 0 1 .476-.931L11.665.42a.832.832 0 0 1 .334-.07zm-.045 1.954L10.28 5.202h-.002l1.211 11.301.512.409.512-.409 1.117-11.3zM9.003 16.261l-.584 1.068.584 1.07 2.295-.38.47-.69-.47-.671zm5.996 0-2.295.397-.47.671.47.69 2.295.38.584-1.07zm-2.998 1.488-.51.444-.281 2.168.789.55.793-.55-.295-2.168z",
),
"playnite" to LauncherGlyph(
viewportWidth = 1024f,
viewportHeight = 1024f,
d = "M966.686,623.899c-9.773-81.666-29.323-161.25-54.514-239.447c-13.759-42.709-30.419-84.189-56.091-121.452 c-31.701-46.014-74.789-72.958-130.812-78.579c-29.631-2.973-57.785,4.118-85.677,12.35 c-61.172,18.056-123.359,25.124-186.493,14.903c-30.919-5.006-61.308-13.526-91.743-21.225 c-76.445-19.338-145.323,4.995-191.165,69.261c-11.441,16.04-21.194,33.543-29.78,51.312 c-25.091,51.925-40.443,107.249-54.53,162.924c-18.822,74.393-33.019,149.491-33.664,226.571c0,7.184-0.342,14.386,0.061,21.547 c1.557,27.727,4.354,55.289,16.045,80.97c15.334,33.68,45.905,46.725,79.471,31.198c18.291-8.461,36.293-19.857,50.766-33.743 c24.597-23.598,46.616-49.934,69.125-75.64c17.934-20.481,39.086-35.301,66.115-40.203c15.779-2.862,31.802-6.006,47.736-6.118 c87.888-0.62,175.783-0.602,263.673-0.278c51.4,0.189,93.314,19.382,124.091,62.134c12.518,17.388,27.83,32.889,42.78,48.371 c18.598,19.259,38.974,36.431,64.412,46.39c32.967,12.907,62.547,1.677,77.882-30.198c3.965-8.242,6.963-17.122,9.155-26.017 C976.198,727.534,972.874,675.607,966.686,623.899z M315.471,527.643c-44.289,0.213-80.733-36.32-80.847-81.045 c-0.115-45.048,35.472-81.194,80.197-81.458c44.521-0.263,80.718,35.897,80.884,80.801 C395.871,490.671,359.773,527.429,315.471,527.643z M708.857,319.301c21.859,0.06,39.486,17.884,39.471,39.91 c-0.015,22.133-17.489,39.677-39.523,39.682c-22.045,0.005-39.456-17.53-39.444-39.724 C669.372,337.125,687.089,319.241,708.857,319.301z M622.269,486.36c-21.542,0.085-39.7-18.08-39.808-39.822 c-0.108-21.888,17.617-39.622,39.62-39.641c22.066-0.018,39.759,17.552,39.718,39.442 C661.758,468.205,643.909,486.275,622.269,486.36z M708.967,573.333c-21.823,0.096-39.537-17.668-39.611-39.721 c-0.074-22.079,17.523-39.992,39.338-40.044c21.715-0.052,39.597,17.908,39.645,39.816 C748.386,555.477,730.883,573.237,708.967,573.333z M795.752,486.362c-21.764,0.155-39.671-17.882-39.651-39.938 c0.021-22.15,17.628-39.639,39.793-39.525c22.091,0.114,39.527,17.993,39.155,40.152 C834.686,468.733,817.216,486.209,795.752,486.362z",
),
"epic" to LauncherGlyph(
viewportWidth = 24f,
viewportHeight = 24f,
d = "M3.537 0C2.165 0 1.66.506 1.66 1.879V18.44a4.262 4.262 0 00.02.433c.031.3.037.59.316.92.027.033.311.245.311.245.153.075.258.13.43.2l8.335 3.491c.433.199.614.276.928.27h.002c.314.006.495-.071.928-.27l8.335-3.492c.172-.07.277-.124.43-.2 0 0 .284-.211.311-.243.28-.33.285-.621.316-.92a4.261 4.261 0 00.02-.434V1.879c0-1.373-.506-1.88-1.878-1.88zm13.366 3.11h.68c1.138 0 1.688.553 1.688 1.696v1.88h-1.374v-1.8c0-.369-.17-.54-.523-.54h-.235c-.367 0-.537.17-.537.539v5.81c0 .369.17.54.537.54h.262c.353 0 .523-.171.523-.54V8.619h1.373v2.143c0 1.144-.562 1.71-1.7 1.71h-.694c-1.138 0-1.7-.566-1.7-1.71V4.82c0-1.144.562-1.709 1.7-1.709zm-12.186.08h3.114v1.274H6.117v2.603h1.648v1.275H6.117v2.774h1.74v1.275h-3.14zm3.816 0h2.198c1.138 0 1.7.564 1.7 1.708v2.445c0 1.144-.562 1.71-1.7 1.71h-.799v3.338h-1.4zm4.53 0h1.4v9.201h-1.4zm-3.13 1.235v3.392h.575c.354 0 .523-.171.523-.54V4.965c0-.368-.17-.54-.523-.54zm-3.74 10.147a1.708 1.708 0 01.591.108 1.745 1.745 0 01.49.299l-.452.546a1.247 1.247 0 00-.308-.195.91.91 0 00-.363-.068.658.658 0 00-.28.06.703.703 0 00-.224.163.783.783 0 00-.151.243.799.799 0 00-.056.299v.008a.852.852 0 00.056.31.7.7 0 00.157.245.736.736 0 00.238.16.774.774 0 00.303.058.79.79 0 00.445-.116v-.339h-.548v-.565H7.37v1.255a2.019 2.019 0 01-.524.307 1.789 1.789 0 01-.683.123 1.642 1.642 0 01-.602-.107 1.46 1.46 0 01-.478-.3 1.371 1.371 0 01-.318-.455 1.438 1.438 0 01-.115-.58v-.008a1.426 1.426 0 01.113-.57 1.449 1.449 0 01.312-.46 1.418 1.418 0 01.474-.309 1.58 1.58 0 01.598-.111 1.708 1.708 0 01.045 0zm11.963.008a2.006 2.006 0 01.612.094 1.61 1.61 0 01.507.277l-.386.546a1.562 1.562 0 00-.39-.205 1.178 1.178 0 00-.388-.07.347.347 0 00-.208.052.154.154 0 00-.07.127v.008a.158.158 0 00.022.084.198.198 0 00.076.066.831.831 0 00.147.06c.062.02.14.04.236.061a3.389 3.389 0 01.43.122 1.292 1.292 0 01.328.17.678.678 0 01.207.24.739.739 0 01.071.337v.008a.865.865 0 01-.081.382.82.82 0 01-.229.285 1.032 1.032 0 01-.353.18 1.606 1.606 0 01-.46.061 2.16 2.16 0 01-.71-.116 1.718 1.718 0 01-.593-.346l.43-.514c.277.223.578.335.9.335a.457.457 0 00.236-.05.157.157 0 00.082-.142v-.008a.15.15 0 00-.02-.077.204.204 0 00-.073-.066.753.753 0 00-.143-.062 2.45 2.45 0 00-.233-.062 5.036 5.036 0 01-.413-.113 1.26 1.26 0 01-.331-.16.72.72 0 01-.222-.243.73.73 0 01-.082-.36v-.008a.863.863 0 01.074-.359.794.794 0 01.214-.283 1.007 1.007 0 01.34-.185 1.423 1.423 0 01.448-.066 2.006 2.006 0 01.025 0zm-9.358.025h.742l1.183 2.81h-.825l-.203-.499H8.623l-.198.498h-.81zm2.197.02h.814l.663 1.08.663-1.08h.814v2.79h-.766v-1.602l-.711 1.091h-.016l-.707-1.083v1.593h-.754zm3.469 0h2.235v.658h-1.473v.422h1.334v.61h-1.334v.442h1.493v.658h-2.255zm-5.3.897l-.315.793h.624zm-1.145 5.19h8.014l-4.09 1.348z",
),
"gog" to LauncherGlyph(
viewportWidth = 24f,
viewportHeight = 24f,
d = "M7.15 15.24H4.36a.4.4 0 0 0-.4.4v2c0 .21.18.4.4.4h2.8v1.32h-3.5c-.56 0-1.02-.46-1.02-1.03v-3.39c0-.56.46-1.02 1.03-1.02h3.48v1.32zM8.16 11.54c0 .58-.47 1.05-1.05 1.05H2.63v-1.35h3.78a.4.4 0 0 0 .4-.4V6.39a.4.4 0 0 0-.4-.4H4.39a.4.4 0 0 0-.41.4v2.02c0 .23.18.4.4.4H6v1.35H3.68c-.58 0-1.05-.46-1.05-1.04V5.68c0-.57.47-1.04 1.05-1.04H7.1c.58 0 1.05.47 1.05 1.04v5.86zM21.36 19.36h-1.32v-4.12h-.93a.4.4 0 0 0-.4.4v3.72h-1.33v-4.12h-.93a.4.4 0 0 0-.4.4v3.72h-1.33v-4.42c0-.56.46-1.02 1.03-1.02h5.61v5.44zM21.37 11.54c0 .58-.47 1.05-1.05 1.05h-4.48v-1.35h3.78a.4.4 0 0 0 .4-.4V6.39a.4.4 0 0 0-.4-.4h-2.03a.4.4 0 0 0-.4.4v2.02c0 .23.18.4.4.4h1.62v1.35H16.9c-.58 0-1.05-.46-1.05-1.04V5.68c0-.57.47-1.04 1.05-1.04h3.43c.58 0 1.05.47 1.05 1.04v5.86zM13.72 4.64h-3.44c-.58 0-1.04.47-1.04 1.04v3.44c0 .58.46 1.04 1.04 1.04h3.44c.57 0 1.04-.46 1.04-1.04V5.68c0-.57-.47-1.04-1.04-1.04m-.3 1.75v2.02a.4.4 0 0 1-.4.4h-2.03a.4.4 0 0 1-.4-.4V6.4c0-.22.17-.4.4-.4H13c.23 0 .4.18.4.4zM12.63 13.92H9.24c-.57 0-1.03.46-1.03 1.02v3.39c0 .57.46 1.03 1.03 1.03h3.39c.57 0 1.03-.46 1.03-1.03v-3.39c0-.56-.46-1.02-1.03-1.02m-.3 1.72v2a.4.4 0 0 1-.4.4v-.01H9.94a.4.4 0 0 1-.4-.4v-1.99c0-.22.18-.4.4-.4h2c.22 0 .4.18.4.4zM23.49 1.1a1.74 1.74 0 0 0-1.24-.52H1.75A1.74 1.74 0 0 0 0 2.33v19.34a1.74 1.74 0 0 0 1.75 1.75h20.5A1.74 1.74 0 0 0 24 21.67V2.33c0-.48-.2-.92-.51-1.24m0 20.58a1.23 1.23 0 0 1-1.24 1.24H1.75A1.23 1.23 0 0 1 .5 21.67V2.33a1.23 1.23 0 0 1 1.24-1.24h20.5a1.24 1.24 0 0 1 1.24 1.24v19.34z",
),
"xbox" to LauncherGlyph(
viewportWidth = 512f,
viewportHeight = 512f,
d = "M369.9 318.2c44.3 54.3 64.7 98.8 54.4 118.7-7.9 15.1-56.7 44.6-92.6 55.9-29.6 9.3-68.4 13.3-100.4 10.2-38.2-3.7-76.9-17.4-110.1-39-27.9-18.2-34.2-25.7-34.2-40.6 0-29.9 32.9-82.3 89.2-142.1 32-33.9 76.5-73.7 81.4-72.6 9.4 2.1 84.3 75.1 112.3 109.5zM188.6 143.8c-29.7-26.9-58.1-53.9-86.4-63.4-15.2-5.1-16.3-4.8-28.7 8.1-29.2 30.4-53.5 79.7-60.3 122.4-5.4 34.2-6.1 43.8-4.2 60.5 5.6 50.5 17.3 85.4 40.5 120.9 9.5 14.6 12.1 17.3 9.3 9.9-4.2-11-.3-37.5 9.5-64 14.3-39 53.9-112.9 120.3-194.4zm311.6 63.5c-16.9-80-67.5-130.3-74.6-130.3-7.3 0-24.2 6.5-36 13.9-23.3 14.5-41 31.4-64.3 52.8 42.4 53.3 102.2 139.4 122.9 202.3 6.8 20.7 9.7 41.1 7.4 52.3-1.7 8.5-1.7 8.5 1.4 4.6 6.1-7.7 19.9-31.3 25.4-43.5 7.4-16.2 15-40.2 18.6-58.7 4.3-22.5 3.9-70.8-.8-93.4zM141.3 43c47.7-2.5 109.7 34.5 114.3 35.4 .7 .1 10.4-4.2 21.6-9.7 63.9-31.1 94-25.8 107.4-25.2-63.9-39.3-152.7-50-233.9-11.7-23.4 11.1-24 11.9-9.4 11.2z",
),
)
private val CACHE = HashMap<String, ImageVector>()
/**
* The [ImageVector] for an `icon` token, or null when the entry carries none or names a mark
* this build ships no art for the caller then falls back to naming the launcher, which is
* what every launcher tile looked like before the token existed.
*
* Tinted by the caller via `tint`, so one mark serves every palette.
*/
fun launcherIcon(token: String?): ImageVector? {
val glyph = GLYPHS[token ?: return null] ?: return null
return CACHE.getOrPut(token) {
// Square the box and centre the mark in it, so a wide or tall master keeps its aspect
// ratio instead of being stretched to the tile.
val side = max(glyph.viewportWidth, glyph.viewportHeight)
val dx = (side - glyph.viewportWidth) / 2f
val dy = (side - glyph.viewportHeight) / 2f
ImageVector.Builder(
name = "launcher_$token",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = side,
viewportHeight = side,
).apply {
addGroup(translationX = dx, translationY = dy)
addPath(
pathData = PathParser().parsePathString(glyph.d).toNodes(),
fill = SolidColor(Color.White),
)
clearGroup()
}.build()
}
}
@@ -0,0 +1,107 @@
package io.unom.punktfunk
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* The console route to the two sub-screens, driven through the REAL settings screen the rows
* themselves are pinned by `ConsoleSubScreenRowsTest`; what needs the Compose runtime is the trip:
* that a press on the row reaches the shell, and that coming back lands where you left rather than
* at the top of the first section (the shell's `AnimatedContent` discards a screen's state the
* moment it stops being the target, so the place has to travel out and back).
*
* Rows are activated by TAP for the same reason `GamepadSettingsLayoutTest` does it: the pad path
* needs a `MainActivity` for its probes, and both routes end in the same `activate`.
*
* `sdk = [36]` for the reason every Robolectric test here pins it: android-all jars stop at 36 while
* the app compiles against 37.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [36], qualifiers = "w360dp-h800dp-xxhdpi")
class ConsoleSubScreenRoutesTest {
@get:Rule
val compose = createAndroidComposeRule<ComponentActivity>()
@Test
fun openingTheControllersRowNavigatesAndReportsWhereItWas() {
var opened = 0
var place: GpSettingsPlace? = null
compose.setContent {
GamepadSettingsScreen(
initial = Settings(),
onChange = {},
onBack = {},
onOpenControllers = { opened++ },
// Entering as if we had just come back from it, which is also what puts the cursor
// on the row — so a single tap ACTIVATES rather than merely focusing.
resume = GpSettingsPlace(GpTab.CONTROLLER, "controllers"),
onPlace = { place = it },
)
}
compose.waitForIdle()
compose.onNodeWithText("Connected controllers").performClick()
compose.waitForIdle()
assertEquals("the console never reached the diagnostics screen", 1, opened)
assertEquals(
"the place has to leave before the row does — this screen is gone the next frame",
GpSettingsPlace(GpTab.CONTROLLER, "controllers"),
place,
)
}
/**
* Back from a sub-screen lands on the section it was opened from, with the row on screen. The
* cursor is restored by row ID rather than index, so it survives a section whose length follows
* the hardware.
*/
@Test
fun comingBackFromTheNoticesLandsOnTheRowThatOpenedThem() {
compose.setContent {
GamepadSettingsScreen(
initial = Settings(),
onChange = {},
onBack = {},
resume = GpSettingsPlace(GpTab.INTERFACE, "licenses"),
)
}
compose.waitForIdle()
compose.onNodeWithText("Open-source licenses").assertIsDisplayed()
// Not back at the top of the first section — "Resolution" leads the Stream tab, which is
// where a screen that forgot its place would be.
compose.onNodeWithText("Resolution").assertDoesNotExist()
// And the legend describes THIS row's A. It said the literal "Pin to hosts" on every
// non-adjustable row back when profiles were the only ones.
compose.onNodeWithText("Open").assertIsDisplayed()
compose.onNodeWithText("Pin to hosts").assertDoesNotExist()
}
/**
* The notices screen stands on its own on the console's field: no Scaffold or Surface above it
* (the shell has neither), its own backdrop, and a legend that says how to leave. Composing it
* is most of the assertion a screen that only ever ran inside the touch Scaffold takes its
* content colour from one.
*/
@Test
fun theConsoleNoticesScreenStandsOnItsOwn() {
compose.setContent { ConsoleLicensesScreen(onBack = {}) }
compose.waitForIdle()
compose.onNodeWithText("Open-source licenses").assertIsDisplayed()
compose.onNodeWithText("Scroll").assertIsDisplayed()
compose.onNodeWithText("Close").assertIsDisplayed()
}
}
@@ -0,0 +1,117 @@
package io.unom.punktfunk
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The console's route to the two screens that were reachable from touch only the
* connected-controllers diagnostics and the open-source notices.
*
* "Touch only" reads as a minor gap on a phone and is a dead end on a TV box, where the console IS
* the interface: there is no touch UI to fall back to, so a screen with no console row could not be
* opened at all. These pin the rows themselves; `ConsoleSubScreenRoutesTest` drives the real screen.
*/
class ConsoleSubScreenRowsTest {
private fun rows(
forwarding: Boolean = true,
version: String = "1.2.3",
controllers: () -> Unit = {},
licenses: () -> Unit = {},
): List<GpRow> = buildSettingsRows(
Settings(gamepadForwarding = forwarding),
hasBodyVibrator = true,
hasGyroscope = true,
av1Capable = true,
appVersion = version,
openControllers = controllers,
openLicenses = licenses,
) {}
private fun row(rows: List<GpRow>, id: String): GpRow = rows.first { it.id == id }
@Test
fun `the controllers row opens the diagnostics view from the controller section`() {
var opened = 0
val r = row(rows(controllers = { opened++ }), "controllers")
assertEquals(GpTab.CONTROLLER, r.tab)
assertEquals("Connected controllers", r.label)
r.activate()
assertEquals(1, opened)
}
/**
* It must NOT follow the master forwarding switch, unlike every other row in its section: the
* screen it opens is what you reach for precisely when forwarding looks broken, and a diagnostic
* that dims itself when the thing it diagnoses is off is worse than no diagnostic.
*/
@Test
fun `the controllers row stays live with forwarding off`() {
val off = rows(forwarding = false)
assertTrue(row(off, "controllers").enabled)
assertNotNull(liveRow(off, off.indexOfFirst { it.id == "controllers" }))
// Its neighbours in the section still dim, so this is a deliberate exemption and not a
// forgotten `enabled =`.
assertFalse(row(off, "sc2").enabled)
}
@Test
fun `the about row opens the notices and states the installed version`() {
var opened = 0
val r = row(rows(version = "0.27.0", licenses = { opened++ }), "licenses")
assertEquals(GpTab.INTERFACE, r.tab)
assertEquals("About", r.header)
// The version rides in the value slot — on a TV this row is the whole About page.
assertEquals("0.27.0", r.value)
r.activate()
assertEquals(1, opened)
}
/** Both navigate; neither holds a value, so left/right must be refused rather than silently eaten. */
@Test
fun `neither row steps a value`() {
val all = rows()
for (id in listOf("controllers", "licenses")) {
val r = row(all, id)
assertFalse("$id should draw no chevrons", r.adjustable)
assertFalse("$id must refuse a step", r.adjust(1))
assertFalse("$id must refuse a step", r.adjust(-1))
}
}
/**
* The legend follows the ROW. It used to say the literal "Pin to hosts" on every non-adjustable
* row, because a profile row was the only kind there was so the moment another one existed,
* A on it was advertised as pinning something.
*/
@Test
fun `an action row advertises what A actually does`() {
val all = rows()
assertEquals("Open", row(all, "controllers").actionHint)
assertEquals("Open", row(all, "licenses").actionHint)
val profiles = buildProfileRows(listOf(newProfile("Work")), emptyList(), tv = false) {}
assertEquals("Pin to hosts", profiles.first().actionHint)
}
/**
* The scroll geometry both console sub-screens share. A wall of text has no focusable rows for
* Compose to keep visible, so these screens move the scroll state themselves and how far one
* press travels is the whole of their feel.
*/
@Test
fun `a page overlaps what you were reading and a step is shorter still`() {
val viewport = 1000f
val page = consoleScrollDelta(viewport, page = true, dir = 1)
val step = consoleScrollDelta(viewport, page = false, dir = 1)
assertTrue("a page that skips a whole screenful loses your place", page < viewport)
assertTrue("a page has to be worth pressing", page > viewport / 2f)
assertTrue("a D-pad step must be shorter than a shoulder page", step > 0f && step < page)
assertEquals("the other direction is the other way", -page, consoleScrollDelta(viewport, true, -1), 0.001f)
// Before the first layout there is no viewport: a press then moves nothing, rather than
// scrolling by a fraction of zero and reading as a dead button on the way in.
assertEquals(0f, consoleScrollDelta(0f, page = true, dir = 1), 0f)
}
}
@@ -0,0 +1,168 @@
package io.unom.punktfunk
import java.io.File
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The console UI's cross-client contract, against `clients/shared/console-vectors.json`.
*
* The background palettes, the settings section names and the screen-transition motion each exist
* in three hand-written copies this client, `pf-console-ui` (Rust) and the Apple client and
* until this file they were held together by nothing but a comment asking the next person to keep
* them in step. Two of the three had already drifted.
*
* Read straight off disk with a relative path rather than copied into test resources, for the
* reason the deeplink vectors state: a copy would be a fourth contract, free to go stale. Gradle
* runs a unit test with the MODULE directory as its working directory, so `../../shared/` from
* `clients/android/app` lands on `clients/shared`.
*
* What this pins that the older [GamepadPaletteTest] could not: the DERIVED 16-cell mesh and the
* 4 blob colours per palette. Those are what actually reach the screen the mesh through the AGSL
* shader on API 33+, the blobs through the fallback field below it and the existing tests only
* ever measured the `stops` they are computed from.
*/
class ConsoleVectorsTest {
private companion object {
/** One step of Compose's 8-bit-per-component sRGB packing — see the blob comparison. */
const val EIGHT_BIT_STEP = 1.0 / 255.0
}
private val vectors: JSONObject by lazy {
val file = File("../../shared/console-vectors.json")
assertTrue(
"the shared vector file must be reachable at ${file.absolutePath}",
file.isFile,
)
JSONObject(file.readText())
}
private fun JSONObject.doubles(key: String): List<Double> =
getJSONArray(key).let { a -> (0 until a.length()).map { a.getDouble(it) } }
private fun close(what: String, got: Double, want: Double, tol: Double = 1e-6) {
assertTrue(
"$what: vectors say $want, this client computes $got",
kotlin.math.abs(got - want) <= tol,
)
}
@Test
fun cellRampAndMeshInteriorMatch() {
assertEquals("CELL_RAMP", vectors.doubles("cell_ramp"), GamepadPalette.CELL_RAMP)
val interior = vectors.getJSONArray("mesh_interior")
assertEquals("mesh interior count", GamepadPalette.MESH_INTERIOR.size, interior.length())
GamepadPalette.MESH_INTERIOR.forEachIndexed { i, p ->
val w = interior.getJSONArray(i)
val got = listOf(p.x, p.y, p.amp, p.sx, p.sy, p.phase)
got.forEachIndexed { k, v -> close("mesh_interior[$i][$k]", v, w.getDouble(k)) }
}
}
/** Every palette, field by field — and then the two tables derived from it. */
@Test
fun everyPaletteMatchesTheSharedVectors() {
val want = vectors.getJSONArray("palettes")
assertEquals("palette count", want.length(), GamepadPalette.ALL.size)
GamepadPalette.ALL.forEachIndexed { i, p ->
val w = want.getJSONObject(i)
val id = w.getString("id")
assertEquals("palette order", id, p.id)
assertEquals("$id name", w.getString("name"), p.name)
assertEquals("$id light", w.getBoolean("light"), p.light)
val stops = w.getJSONArray("stops")
assertEquals("$id stop count", stops.length(), p.stops.size)
p.stops.forEachIndexed { s, t ->
val ws = stops.getJSONArray(s)
close("$id stops[$s].r", t.first, ws.getDouble(0))
close("$id stops[$s].g", t.second, ws.getDouble(1))
close("$id stops[$s].b", t.third, ws.getDouble(2))
}
val ground = w.doubles("ground")
close("$id ground.r", p.ground.first, ground[0])
close("$id ground.g", p.ground.second, ground[1])
close("$id ground.b", p.ground.third, ground[2])
val accent = w.doubles("accent")
close("$id accent.r", p.accent.first, accent[0])
close("$id accent.g", p.accent.second, accent[1])
close("$id accent.b", p.accent.third, accent[2])
// The mesh the shader is built from — 16 cells, sampled off the ramp per CELL_RAMP.
val mesh = w.getJSONArray("mesh")
assertEquals("$id mesh cells", mesh.length(), p.meshColors.size)
p.meshColors.forEachIndexed { c, t ->
val wc = mesh.getJSONArray(c)
close("$id mesh[$c].r", t.first, wc.getDouble(0))
close("$id mesh[$c].g", t.second, wc.getDouble(1))
close("$id mesh[$c].b", t.third, wc.getDouble(2))
}
// The four blobs the API 2832 fallback field drifts. These come back as Compose
// `Color`s, which pack an sRGB colour at 8 bits per component — so the table
// round-trips through 1/255 quantisation and the tolerance below IS that quantisation,
// not slack. Anything the contract actually cares about (a mistyped stop, a shifted
// sample point) moves these by far more than one 8-bit step.
val blobs = w.getJSONArray("blobs")
assertEquals("$id blob count", blobs.length(), p.blobColors.size)
p.blobColors.forEachIndexed { b, colour ->
val wb = blobs.getJSONArray(b)
close("$id blob[$b].r", colour.red.toDouble(), wb.getDouble(0), EIGHT_BIT_STEP)
close("$id blob[$b].g", colour.green.toDouble(), wb.getDouble(1), EIGHT_BIT_STEP)
close("$id blob[$b].b", colour.blue.toDouble(), wb.getDouble(2), EIGHT_BIT_STEP)
}
}
}
/**
* The section names, in order. The desktop console carries one tab this client does not
* Input, which holds touch mode, mouse, invert-scroll and shortcuts: desktop-host settings
* with nothing to set on a phone or a TV. The vectors flag it `desktop_only` rather than
* leaving it out, so neither side has to red the other to be right.
*/
@Test
fun tabNamesMatchTheSharedVectors() {
val tabs = vectors.getJSONArray("tabs")
val want = (0 until tabs.length())
.map { tabs.getJSONObject(it) }
.filterNot { it.optBoolean("desktop_only", false) }
.map { it.getString("name") }
assertEquals("console settings tabs", want, GpTab.entries.map { it.title })
}
/**
* The screen-transition contract. The easing is sampled rather than compared as Bézier
* control points: this client evaluates the desktop's analytic `1 (1t)³` directly, while
* SwiftUI can only approximate it samples with a tolerance are the one form all three can
* meet. It is also the assertion that would have caught the curve this client shipped with
* first, a "cubic-bezier(0.215, 0.61, 0.355, 1)" that is a full 0.08 slack at the midpoint.
*/
@Test
fun motionMatchesTheSharedVectors() {
val motion = vectors.getJSONObject("motion")
close("transition", ConsoleMotion.TRANSITION_MS / 1000.0, motion.getDouble("transition_s"))
close("push slide", ConsoleMotion.PUSH_SLIDE.value.toDouble(), motion.getDouble("push_slide_dp"))
close("enter scale", ConsoleMotion.ENTER_SCALE.toDouble(), motion.getDouble("enter_scale"), 1e-5)
close("exit scale", ConsoleMotion.EXIT_SCALE.toDouble(), motion.getDouble("exit_scale"), 1e-5)
close("reveal alpha", ConsoleMotion.REVEAL_ALPHA.toDouble(), motion.getDouble("reveal_alpha"), 1e-5)
val curve = motion.getJSONObject("ease_out_cubic")
val tol = curve.getDouble("tolerance")
val samples = curve.getJSONArray("samples")
assertTrue("the curve needs enough samples to pin it", samples.length() >= 5)
for (i in 0 until samples.length()) {
val s = samples.getJSONObject(i)
val t = s.getDouble("t")
close(
"ease_out_cubic($t)",
ConsoleMotion.EaseOutCubic.transform(t.toFloat()).toDouble(),
s.getDouble("p"),
tol,
)
}
}
}
@@ -0,0 +1,111 @@
package io.unom.punktfunk
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.getBoundsInRoot
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.unit.Dp
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* The console settings list must not MOVE under the cursor. This is the regression net for the
* layout instability the visual refresh fixed, and it needs the real Compose runtime because the
* bug was entirely a layout one every value the model held was correct throughout.
*
* What used to happen: the focused row unfolded its description in place
* (`AnimatedVisibility` + `expandVertically`), so every step of the cursor shrank one row and grew
* another and shifted every row below the focus point on a list that is simultaneously being
* scrolled to keep the focused row visible, whose target therefore moved mid-animation. The
* description now renders in the screen's floating `ConsoleDetailBand`, which is an overlay and
* cannot displace anything. Sideways, the value's `AnimatedContent` animated its own WIDTH on every
* step, walking the chevron and the label's right edge back and forth.
*
* Focus is moved by TAP here rather than by pad: the pad path needs a `MainActivity` for its input
* probes, and the screen routes both to the same `focus` state the geometry under test is the
* same either way.
*
* `sdk = [36]` for the reason every Robolectric test here pins it: android-all jars stop at 36
* while the app compiles against 37.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [36], qualifiers = "w360dp-h800dp-xxhdpi")
class GamepadSettingsLayoutTest {
@get:Rule
val compose = createAndroidComposeRule<ComponentActivity>()
private fun settings() {
compose.setContent {
GamepadSettingsScreen(initial = Settings(), onChange = {}, onBack = {})
}
}
/** A Dp compared at hairline tolerance — a rounding difference is not a layout shift. */
private fun assertSame(what: String, expected: Dp, actual: Dp) {
assertEquals(what, expected.value.toDouble(), actual.value.toDouble(), 0.5)
}
/**
* Moving the cursor down the list leaves every OTHER row exactly where it was. The rows below
* the new focus are the ones the old in-row detail pushed around, so they are the assertion
* that matters; the row above proves the shrink half.
*/
@Test
fun focusingARowMovesNoOtherRow() {
settings()
// Entry focus is the first row (Resolution), so "Refresh rate" starts unfocused and
// "Compositor" sits below both candidates.
val refreshBefore = compose.onNodeWithText("Refresh rate").getBoundsInRoot()
val compositorBefore = compose.onNodeWithText("Compositor").getBoundsInRoot()
// One tap on an unfocused row focuses it (a second would activate it — see the screen).
compose.onNodeWithText("Bitrate").performClick()
compose.waitForIdle()
val refreshAfter = compose.onNodeWithText("Refresh rate").getBoundsInRoot()
val compositorAfter = compose.onNodeWithText("Compositor").getBoundsInRoot()
assertSame("row above the cursor moved", refreshBefore.top, refreshAfter.top)
assertSame("row below the cursor moved", compositorBefore.top, compositorAfter.top)
}
/**
* Stepping a value leaves the row's own geometry alone. The label's right edge is the probe:
* it is what the widening value slot used to shove, and it is stable for any value that fits
* the slot (which every shipped Bitrate label does).
*/
@Test
fun steppingAValueMovesNoLabel() {
settings()
compose.onNodeWithText("Bitrate").performClick() // focus it
compose.waitForIdle()
val labelBefore = compose.onNodeWithText("Bitrate").getBoundsInRoot()
compose.onNodeWithText("Bitrate").performClick() // now activates → cycles the value
compose.waitForIdle()
val labelAfter = compose.onNodeWithText("Bitrate").getBoundsInRoot()
assertSame("label moved sideways under a value step", labelBefore.left, labelAfter.left)
assertSame("label moved sideways under a value step", labelBefore.right, labelAfter.right)
assertSame("row changed height under a value step", labelBefore.top, labelAfter.top)
}
/**
* The focused row's description is on screen in the floating band, not inside the row. Proves
* the detail did not simply get dropped when it left the row: it is still what the cursor
* explains itself with.
*/
@Test
fun theFocusedRowsDetailIsShown() {
settings()
compose.onNodeWithText("Refresh rate").performClick()
compose.waitForIdle()
compose.onNodeWithText("Frame rate the host renders and streams at.").assertExists()
}
}
@@ -0,0 +1,210 @@
package io.unom.punktfunk
import androidx.compose.ui.graphics.Color
import io.unom.punktfunk.kit.discovery.DiscoveredHost
import io.unom.punktfunk.kit.security.KnownHost
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The console home's tile list ([buildHomeTiles]). Pure JVM the carousel itself needs the live
* JNI core to compose, so its ORDER and what each tile claims had no cover at all until now, and
* both are exactly the kind of thing that survives a refactor looking fine and behaving wrong.
*
* Run: `./gradlew :app:testDebugUnitTest --tests 'io.unom.punktfunk.HomeTilesTest'`.
*/
class HomeTilesTest {
private fun host(
name: String,
address: String,
fp: String = "",
profileId: String? = null,
pins: List<String> = emptyList(),
) = KnownHost(
address = address,
port = 9777,
name = name,
fpHex = fp,
paired = true,
id = "id-$name",
profileId = profileId,
pinnedProfileIds = pins,
)
private fun advert(name: String, address: String, fp: String? = null) = DiscoveredHost(
key = "$address:9777",
name = name,
host = address,
port = 9777,
fingerprint = fp,
)
private val work = StreamProfile(id = "p-work", name = "Work", accent = "#3B82F6")
private val travel = StreamProfile(id = "p-travel", name = "Travel")
/** The builder with nothing plugged in — every list empty, every callback a no-op. */
private fun tiles(
savedHosts: List<KnownHost> = emptyList(),
profiles: List<StreamProfile> = emptyList(),
pins: Map<String, List<StreamProfile>> = emptyMap(),
discoveredUnsaved: List<DiscoveredHost> = emptyList(),
online: Set<String> = emptySet(),
onConnect: (KnownHost, String?) -> Unit = { _, _ -> },
onConnectDiscovered: (DiscoveredHost) -> Unit = {},
onAddHost: () -> Unit = {},
) = buildHomeTiles(
savedHosts = savedHosts,
profiles = profiles,
pinsFor = { kh -> pins[kh.id].orEmpty() },
discoveredUnsaved = discoveredUnsaved,
isOnline = { it.name in online },
onConnect = onConnect,
onConnectDiscovered = onConnectDiscovered,
onAddHost = onAddHost,
)
/**
* A pin belongs to the host above it. Ordering is the whole affordance: on a controller a pin is
* reached by walking one tile past its host, and a builder that grouped all the pins at the end
* would still LOOK right in a screenshot of any single tile.
*/
@Test
fun pinnedCardsFollowTheirOwnHost() {
val living = host("living", "192.168.1.42", pins = listOf(work.id, travel.id))
val studio = host("studio", "192.168.1.61", pins = listOf(work.id))
val ids = tiles(
savedHosts = listOf(living, studio),
profiles = listOf(work, travel),
pins = mapOf(living.id to listOf(work, travel), studio.id to listOf(work)),
).map { it.id }
assertEquals(
listOf(
"saved-id-living",
"pin-id-living-p-work",
"pin-id-living-p-travel",
"saved-id-studio",
"pin-id-studio-p-work",
"add",
),
ids,
)
}
/** Add Host is the last tile, always — including on a device with nothing saved or seen. */
@Test
fun theAddTileIsAlwaysLast() {
val empty = tiles()
assertEquals(listOf("add"), empty.map { it.id })
assertTrue(empty.single().isAdd)
val populated = tiles(
savedHosts = listOf(host("living", "192.168.1.42")),
discoveredUnsaved = listOf(advert("studio", "192.168.1.61")),
)
assertEquals(listOf("saved-id-living", "disc-192.168.1.61:9777", "add"), populated.map { it.id })
assertTrue(populated.last().isAdd)
// The Add tile is not a host: no library, no options menu, nothing to wake.
assertNull(populated.last().knownHost)
}
/**
* A host that is both saved and advertising appears ONCE. The de-dupe is the caller's
* ([KnownHost.matches], which the screen applies before handing the list over) checked here
* because the rule that matters is the fingerprint one: a host that came back on a new DHCP
* address is the same machine, and matching on address alone would offer it a second time as a
* stranger, next to the record that already holds its trust.
*/
@Test
fun aSavedHostSeenOnTheNetworkIsNotListedTwice() {
val fp = "ab12cd34"
val living = host("living", "192.168.1.42", fp = fp)
// Same host, new address after a cold boot, plus a genuine stranger.
val adverts = listOf(advert("living", "192.168.1.77", fp = fp), advert("stranger", "192.168.1.99"))
val unsaved = adverts.filter { dh -> listOf(living).none { it.matches(dh) } }
val ids = tiles(savedHosts = listOf(living), discoveredUnsaved = unsaved).map { it.id }
assertEquals(listOf("saved-id-living", "disc-192.168.1.99:9777", "add"), ids)
}
/**
* The chip says which profile a press will connect with the host's binding on its own tile,
* the pinned profile on a pin tile. The console cannot EDIT profiles, so this claim is the only
* thing standing between a user and a stream with settings they didn't choose.
*/
@Test
fun theChipNamesTheProfileThePressWillUse() {
val living = host("living", "192.168.1.42", profileId = work.id, pins = listOf(travel.id))
val result = tiles(
savedHosts = listOf(living),
profiles = listOf(work, travel),
pins = mapOf(living.id to listOf(travel)),
)
val own = result[0]
assertEquals("Work", own.profileName)
assertEquals(Color(0xFF3B82F6), own.profileAccent)
assertNull(own.pinnedProfileId)
val pin = result[1]
assertEquals("Travel", pin.profileName)
assertEquals(travel.id, pin.pinnedProfileId)
// Travel set no accent: a chip with no colour, not a crash and not a stray default.
assertNull(pin.profileAccent)
// A binding whose profile was deleted resolves to nothing — the tile stays silent rather
// than naming an id that resolves to nobody.
val dangling = tiles(savedHosts = listOf(host("ghost", "10.0.0.5", profileId = "p-gone")))
assertNull(dangling[0].profileName)
}
/** Both address and the subtitle: a pin card says where it points, like every other card. */
@Test
fun everySavedTileSaysWhereItPoints() {
val living = host("living", "192.168.1.42", pins = listOf(work.id))
val result = tiles(
savedHosts = listOf(living),
profiles = listOf(work),
pins = mapOf(living.id to listOf(work)),
online = setOf("living"),
)
result.take(2).forEach {
assertEquals("192.168.1.42:9777", it.subtitle)
assertEquals("living", it.title)
assertTrue(it.filled)
assertTrue(it.online)
assertTrue(it.paired)
assertNotNull(it.knownHost)
}
// Host tile → library (Y); pin tile → none, because a pin is a shortcut, not a second host.
assertTrue(result[0].hasLibrary)
assertFalse(result[1].hasLibrary)
}
/**
* What a press DOES. A host's own tile dials with no one-off reference so the host's binding is
* followed; a pin tile forces its own profile. Passing the pin's id as the binding (or the
* other way round) is invisible until someone streams at the wrong bitrate.
*/
@Test
fun activationCarriesTheRightProfileReference() {
val living = host("living", "192.168.1.42", pins = listOf(work.id))
val dialled = mutableListOf<Pair<String, String?>>()
val discovered = mutableListOf<String>()
var addOpened = false
val result = tiles(
savedHosts = listOf(living),
profiles = listOf(work),
pins = mapOf(living.id to listOf(work)),
discoveredUnsaved = listOf(advert("stranger", "192.168.1.99")),
onConnect = { kh, oneOff -> dialled += kh.name to oneOff },
onConnectDiscovered = { dh -> discovered += dh.host },
onAddHost = { addOpened = true },
)
result.forEach { it.activate() }
assertEquals(listOf("living" to null, "living" to work.id), dialled)
assertEquals(listOf("192.168.1.99"), discovered)
assertTrue(addOpened)
}
}
@@ -83,6 +83,16 @@ class ScreenshotTest {
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
fun streamNormal() = shootRoot("stream-normal") { 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) }
@Test
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
fun streamBannerTouch() = shootRoot("stream-banner-touch") { StreamBannerScene(pad = false) }
// The touch flow is a Material dialog over the host grid (a separate window → shootScreen).
@Test
fun connecting() = shootScreen("connecting") {
@@ -114,6 +124,59 @@ class ScreenshotTest {
fun consoleSettingsLight() =
shootRoot("console-settings-light") { ConsoleSettingsScene(paletteId = "holo") }
/**
* Landscape the orientation the console actually runs in, and a DIFFERENT layout since the
* on-glass review: rows capped and left-aligned, the focused row's description in a side pane
* on the right instead of the floating band.
*/
@Test
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
fun consoleSettingsLandscape() =
shootRoot("console-settings-landscape") { 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() }
@Test
fun consoleHomeLight() = shootRoot("console-home-light") { ConsoleHomeScene(paletteId = "holo") }
/**
* Landscape the orientation the console UI actually runs in, and the only one wide enough to
* show the carousel's NEIGHBOURS, which is where the projected turn (`CARD_TURN_RAD`) lives.
*/
@Test
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
fun consoleHomeLandscape() = shootRoot("console-home-landscape") { ConsoleHomeScene() }
/**
* The API 31/32 field. `RuntimeShader` is API 33+, so everything below it keeps the four
* drifting blobs an honest approximation rather than an emulation, and the thing this shot
* exists to keep honest.
*/
@Test
@Config(sdk = [31], qualifiers = "w360dp-h800dp-xxhdpi")
fun consoleHomeBlobFallback() = shootRoot("console-home-blobs") { 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() }
@Test
fun consoleLicensesLight() =
shootRoot("console-licenses-light") { ConsoleLicensesScene(paletteId = "holo") }
@Test
fun consoleControllers() = shootRoot("console-controllers") { ConsoleControllersScene() }
@Test
fun consoleControllersLight() =
shootRoot("console-controllers-light") { ConsoleControllersScene(paletteId = "holo") }
@Test
fun trust() = shootScreen("trust") {
HostsScene()
@@ -32,9 +32,13 @@ import io.unom.punktfunk.ConnectModal
import io.unom.punktfunk.ConnectPhase
import io.unom.punktfunk.ConnectTakeover
import androidx.compose.runtime.CompositionLocalProvider
import io.unom.punktfunk.GamepadHome
import io.unom.punktfunk.GamepadInk
import io.unom.punktfunk.GamepadPalette
import io.unom.punktfunk.ConsoleControllersScreen
import io.unom.punktfunk.ConsoleLicensesScreen
import io.unom.punktfunk.GamepadSettingsScreen
import io.unom.punktfunk.HomeTile
import io.unom.punktfunk.LocalGamepadInk
import io.unom.punktfunk.LocalGamepadPalette
import io.unom.punktfunk.Settings
@@ -43,10 +47,11 @@ import io.unom.punktfunk.SettingsCategory
import io.unom.punktfunk.SettingsScreen
import io.unom.punktfunk.StatsOverlay
import io.unom.punktfunk.StatsVerbosity
import io.unom.punktfunk.StreamStartBanner
import io.unom.punktfunk.ProfileEditorFields
import io.unom.punktfunk.ProfileStore
import io.unom.punktfunk.SettingsOverlay
import io.unom.punktfunk.SpeedTestDialog
import io.unom.punktfunk.SpeedTestPrompt
import io.unom.punktfunk.SpeedTestPhase
import io.unom.punktfunk.SpeedTestTarget
import io.unom.punktfunk.components.HostCard
@@ -242,7 +247,8 @@ internal fun SettingsProfileScene() {
*/
@Composable
internal fun SpeedTestScene() {
SpeedTestDialog(
SpeedTestPrompt(
gamepadUi = false,
hostName = "Living Room PC",
target = SpeedTestTarget.Ask(newProfile("Game")),
phase = SpeedTestPhase.Done(throughputKbps = 412_000, lossPct = 0.3, recommendedKbps = 288_400),
@@ -427,6 +433,111 @@ internal fun ConnectConsoleScene() =
* stand in for it: this is a different screen with different navigation, and the strip is the part
* a layout regression would eat first.
*/
/**
* The start-of-stream banner over the same synthetic "streamed frame" the real
* [StreamStartBanner] at full opacity, since the caller owns the 6 s timer and a shot must not race
* it. Two variants because the WORDS are the point: the banner names pad chords or touch gestures
* depending on what the session actually has, and a screenshot is the only place the two can be
* compared side by side.
*/
@Composable
internal fun StreamBannerScene(pad: Boolean) {
Box(
Modifier
.fillMaxSize()
.background(
Brush.linearGradient(
listOf(Color(0xFF2A1E5C), Color(0xFF0E1B3D), Color(0xFF06122B)),
),
),
) {
StreamStartBanner(
text = if (pad) {
"Hold Select + Start + L1 + R1 to leave · Select + Y mic · Select + X stats"
} else {
"Back leaves the stream · three-finger tap for stats"
},
alpha = 1f,
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 24.dp),
)
}
}
/**
* The console HOME the host carousel over the living backdrop, which is the screen the aurora is
* most of. Worth its own shot for exactly that reason: on API 33+ the field is the real bicubic
* MESH (`GamepadAurora`'s AGSL port of the desktop console's shader) and below it the four-blob
* fallback, and the two are only comparable side by side. The scene composes [GamepadHome]
* directly with mock tiles it needs no JNI core and no session, unlike the ConnectScreen that
* normally feeds it.
*/
@Composable
internal fun ConsoleHomeScene(paletteId: String = "violet") {
val palette = GamepadPalette.named(paletteId)
val tiles = listOf(
HomeTile(
id = "living", title = "Living Room PC", subtitle = "192.168.1.42 · Paired",
filled = true, online = true, paired = true, activate = {},
),
HomeTile(
id = "studio", title = "studio-deck", subtitle = "192.168.1.61 · Discovered",
online = true, activate = {},
),
HomeTile(id = "add", title = "Add Host", subtitle = "By address", isAdd = true, activate = {}),
)
CompositionLocalProvider(
LocalGamepadPalette provides palette,
LocalGamepadInk provides GamepadInk.of(palette),
) {
GamepadHome(
tiles = tiles,
libraryEnabled = true,
controllerName = "Xbox Wireless Controller",
navActive = false,
onActivate = {},
onOpenLibrary = {},
onOpenSettings = {},
)
}
}
/**
* The two screens the console could not reach at all until WP8.3 the open-source notices and the
* connected-controllers view in their console presentation.
*
* Worth a shot each, and worth a PALE one: both are ordinary Material screens underneath, and the
* console shows them through a `ColorScheme` derived from the palette's ink. That derivation is the
* 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.
*/
@Composable
internal fun ConsoleLicensesScene(paletteId: String = "violet") =
ConsolePalette(paletteId) { ConsoleLicensesScreen(onBack = {}, navActive = false) }
@Composable
internal fun ConsoleControllersScene(paletteId: String = "violet") =
ConsolePalette(paletteId) {
ConsoleControllersScreen(gamepadSetting = 0, onBack = {}, navActive = false)
}
/**
* 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.
*/
@Composable
private fun ConsolePalette(paletteId: String, content: @Composable () -> Unit) {
val palette = GamepadPalette.named(paletteId)
CompositionLocalProvider(
LocalGamepadPalette provides palette,
LocalGamepadInk provides GamepadInk.of(palette),
content = content,
)
}
@Composable
internal fun ConsoleSettingsScene(paletteId: String = "violet") {
// The scene calls the screen directly, so it has to publish the palette locals `App` would
@@ -46,8 +46,8 @@ class GamepadRouter(
* as well would give the host two pads for one pair of hands.
*
* Off still opens slots and tracks held state; it only stops the wire sends. That is
* deliberate: the exit and mic chords are read off the same slots, and a couch that lost its
* quit shortcut because a forwarding preference was off would be the worse bug. Nothing is
* deliberate: the exit, mic and stats chords are read off the same slots, and a couch that lost
* its quit shortcut because a forwarding preference was off would be the worse bug. Nothing is
* claimed by keeping a slot the Android input stack shares controllers unlike the USB
* capture links, which `StreamScreen` does not start at all while this is off.
*/
@@ -149,6 +149,20 @@ class GamepadRouter(
*/
var onMicChord: (() -> Unit)? = null
/**
* Invoked (main thread) each time the stats chord ([STATS_CHORD], Select + X) is COMPLETED on a
* pad one verbosity tier of the in-stream statistics overlay per completion. It exists
* because a controller in both hands has no other way to the numbers: the three-finger tap
* needs a touchscreen AND one of the pointer touch models, so a TV or a gamepad-only session
* has none. `StreamScreen` wires it to the live tier cycle.
*
* Fires immediately and once per chord like [onMicChord], and like it the buttons still go to
* the host the chord adds a local meaning to them rather than swallowing them. The Apple
* client's `GamepadCapture.statsChord` is the same two buttons; a shortcut that differs per
* platform is worse than no shortcut.
*/
var onStatsChord: (() -> Unit)? = null
/**
* Invoked (main thread) once per pad when a captured controller WITH a gyro turns out to be in
* a session whose virtual pad has no motion plane its motion is not being sent, because every
@@ -204,7 +218,7 @@ class GamepadRouter(
/**
* One button transition on [slot] the shared body behind [onButton] and an [ExternalPad]'s
* transitions: forward the wire event, track held state, arm/disarm the exit chord, and fire
* the mic-mute chord ([MIC_CHORD]).
* the instant chords ([MIC_CHORD], [STATS_CHORD]).
*/
private fun slotButton(slot: Slot, bit: Int, down: Boolean, send: Boolean) {
// Raw system buttons stay local under the "local" policy — no wire send and no held
@@ -232,13 +246,11 @@ class GamepadRouter(
slot.held = slot.held or bit
// Full chord now held on this pad → start the hold countdown (idempotent while held).
if (slot.held and EXIT_CHORD == EXIT_CHORD) armExit()
// Mic mute, edge-triggered on the button that COMPLETES the chord: a genuine press
// (`wasHeld` lacks the bit, so an auto-repeat DOWN can't re-fire it) of a chord member
// that leaves the whole chord held. Any other button pressed while Select + Y are down
// fails the middle test, so the toggle happens once per chord, not once per press.
if (wasHeld and bit == 0 && bit and MIC_CHORD != 0 && slot.held and MIC_CHORD == MIC_CHORD) {
onMicChord?.invoke()
}
// Mic mute and the stats-tier cycle, each edge-triggered on the button that COMPLETES
// its chord (see [completesChord]) — the two meanings this client gives Select plus a
// face button. Both leave the press on the wire: the game still gets its buttons.
if (completesChord(wasHeld, bit, MIC_CHORD)) onMicChord?.invoke()
if (completesChord(wasHeld, bit, STATS_CHORD)) onStatsChord?.invoke()
} else {
val owned = guideGesture && bit == Gamepad.BTN_BACK && consumeSelectRelease(slot)
if (!owned && send && forwarding) {
@@ -628,7 +640,11 @@ class GamepadRouter(
return null
}
private companion object {
// `internal` rather than private: the chord masks and [completesChord] are the only part of
// this router a JVM unit test can reach — everything else needs an InputManager, a main Looper
// and live InputDevices behind it — and until `GamepadChordTest` there was nothing pinning the
// chords at all. Still invisible to :app, which is what private bought.
internal companion object {
/** Mirror of `punktfunk-core::input::MAX_PADS` — wire pad indices 0..15. */
const val MAX_PADS = 16
@@ -650,6 +666,28 @@ class GamepadRouter(
*/
const val MIC_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_Y
/**
* Stats-overlay chord: Select + X, one verbosity tier per completion. X keeps both
* properties [MIC_CHORD]'s Y has it is none of [EXIT_CHORD]'s four buttons, so no way of
* reaching the exit chord passes through this one on the way (and vice versa), and Select
* is a menu button rather than a twitch action. Byte-for-byte the Apple client's
* `GamepadCapture.statsChord`, which was modelled on [MIC_CHORD] in the first place and
* leaves Y free for the mic chord to land there in turn the two clients converge on one
* pad vocabulary from both ends.
*/
const val STATS_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_X
/**
* Whether pressing [bit] on a pad that held [wasHeld] beforehand COMPLETED [chord]: a
* genuine press (`wasHeld` lacks the bit, so an auto-repeat DOWN can't re-fire it) of a
* chord member that leaves the whole chord held (`wasHeld or bit` is the slot's held set
* the instant after the press). Any other button pressed while the chord is already down
* fails the middle test, so a chord fires once per chord, not once per press and lifting
* any member re-arms it, since the next press of that member is a fresh completion.
*/
internal fun completesChord(wasHeld: Int, bit: Int, chord: Int): Boolean =
wasHeld and bit == 0 && bit and chord != 0 && (wasHeld or bit) and chord == chord
/** Synthetic slot-key base for [ExternalPad]s — below every real (positive) InputDevice id. */
const val EXTERNAL_ID_BASE = -1000
@@ -44,6 +44,9 @@ data class Artwork(val portrait: String?, val header: String?, val hero: String?
* opens the launcher itself (Steam Big Picture, Heroic) rather than a title. Kept a plain nullable
* String on purpose: the host owns the vocabulary, and an unknown future value must degrade to a
* game rather than break the decode (design D4).
*
* [icon] is the token for the entry's brand mark (`"steam"`, `"heroic"`) never art, never a URL.
* Null on every older host and on every ordinary title.
*/
data class GameEntry(
val id: String,
@@ -51,12 +54,25 @@ data class GameEntry(
val title: String,
val art: Artwork,
val role: String? = null,
val icon: String? = null,
) {
val isCustom: Boolean get() = store == "custom"
/** Whether this entry opens a launcher rather than a game. */
val isLauncher: Boolean get() = role == "launcher"
/**
* The brand-icon token, re-validated rather than taken on trust.
*
* The host checks the shape on the way in, so this only fires for a host older than that
* check or one that isn't ours. It costs a scan of a short string and means no consumer has
* to wonder what it is about to look up.
*/
val iconToken: String? get() = icon?.takeIf { t ->
t.isNotEmpty() && t.length <= 32 && t[0] in 'a'..'z' &&
t.all { it in 'a'..'z' || it in '0'..'9' || it == '-' }
}
/**
* Display name for the store badge the same table the other clients use
* (`pf-console-ui::library::store_label`). Before this the UI said "Steam" for every non-custom
@@ -151,6 +167,7 @@ object LibraryClient {
hero = resolveArt(str(art, "hero"), base),
),
role = str(o, "role"),
icon = str(o, "icon"),
),
)
}
@@ -0,0 +1,183 @@
package io.unom.punktfunk.kit
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The router's instant button chords mic mute (Select + Y) and the stats-tier cycle
* (Select + X) pinned at the one place a JVM test can reach them: [GamepadRouter.completesChord],
* the shared edge rule both fire on. A [GamepadRouter] itself needs an InputManager, a main Looper
* and live InputDevices behind it, so driving real KeyEvents through it is not a unit test; the
* rule below is the whole of what those two `if`s decide.
*
* The Apple client pins the same chords from its own side (`GamepadStatsChordTests`), and the two
* suites exist for the same reason: a chord that stops completing fails INVISIBLY the buttons
* still reach the game, nothing logs, and the couch simply finds that a shortcut it was told about
* does nothing. On a TV the stats chord is the only route to the overlay at all.
*/
class GamepadChordTest {
/** The two chords `slotButton` tests on every press, in the order it tests them. */
private val instantChords = listOf(GamepadRouter.MIC_CHORD, GamepadRouter.STATS_CHORD)
/**
* One pad's held-button set, driven exactly the way `slotButton` drives a slot's: the chord
* test reads the state from BEFORE the press, then the bit joins `held`. [press] returns the
* chords that completed on it an empty list means the press was silent.
*/
private inner class Pad {
var held = 0
private set
fun press(bit: Int): List<Int> {
val wasHeld = held
held = held or bit
return instantChords.filter { GamepadRouter.completesChord(wasHeld, bit, it) }
}
/** An auto-repeat DOWN: Android re-delivers a held button, so `wasHeld` already has it. */
fun repeat(bit: Int): List<Int> = press(bit)
fun release(bit: Int) {
held = held and bit.inv()
}
}
/**
* Select + X, the same pair as the Apple client's `GamepadCapture.statsChord`
* (`GamepadWire.back | GamepadWire.x`). A per-platform shortcut is worse than none, so the
* literal bits are spelled out here rather than derived from the constant under test.
*/
@Test
fun `the stats chord is Select plus X`() {
assertEquals(0x0020 or 0x4000, GamepadRouter.STATS_CHORD)
assertEquals(Gamepad.BTN_BACK or Gamepad.BTN_X, GamepadRouter.STATS_CHORD)
assertEquals(Gamepad.BTN_BACK or Gamepad.BTN_Y, GamepadRouter.MIC_CHORD)
}
/**
* The three chords must not be reachable through one another: pressing toward the exit chord
* may not cycle the overlay or mute the mic on the way, and neither instant chord may arm a
* disconnect. Select is the one button they share by design everything else is disjoint, and
* no chord is a subset of another (a subset would complete whenever its superset did).
*/
@Test
fun `the chords meet only on Select`() {
val chords = mapOf(
"exit" to GamepadRouter.EXIT_CHORD,
"mic" to GamepadRouter.MIC_CHORD,
"stats" to GamepadRouter.STATS_CHORD,
)
for ((aName, a) in chords) {
for ((bName, b) in chords) {
if (aName == bName) continue
assertEquals("$aName and $bName share a button other than Select", Gamepad.BTN_BACK, a and b)
assertNotEquals("$aName is a subset of $bName", a and b, a)
assertNotEquals("$bName is a subset of $aName", a and b, b)
}
}
}
/**
* One cycle per chord, not one per press: the completing button fires it, an auto-repeat of
* that same button does not, and a third button pressed on top of the held chord finds the mask
* already complete.
*/
@Test
fun `the chord fires once, on the button that completes it`() {
val pad = Pad()
assertEquals("Select alone is not a chord", emptyList<Int>(), pad.press(Gamepad.BTN_BACK))
assertEquals(listOf(GamepadRouter.STATS_CHORD), pad.press(Gamepad.BTN_X))
assertEquals("auto-repeat re-fired the chord", emptyList<Int>(), pad.repeat(Gamepad.BTN_X))
assertEquals("a press on top re-fired the chord", emptyList<Int>(), pad.press(Gamepad.BTN_A))
assertEquals(emptyList<Int>(), pad.press(Gamepad.BTN_B))
}
/**
* Lifting either member re-arms the chord pressing it again is a fresh completion. Both
* directions matter: a couch user cycling tiers taps X with Select still down, and one who
* lifted Select instead taps Select again with X still down.
*/
@Test
fun `either member re-arms the chord when released`() {
val pad = Pad()
pad.press(Gamepad.BTN_BACK)
assertEquals(listOf(GamepadRouter.STATS_CHORD), pad.press(Gamepad.BTN_X))
pad.release(Gamepad.BTN_X)
assertEquals(listOf(GamepadRouter.STATS_CHORD), pad.press(Gamepad.BTN_X))
pad.release(Gamepad.BTN_BACK)
assertEquals(listOf(GamepadRouter.STATS_CHORD), pad.press(Gamepad.BTN_BACK))
}
/** A partial mask fires nothing — either member alone, or with a non-member alongside it. */
@Test
fun `a partial chord never fires`() {
for (opening in listOf(Gamepad.BTN_BACK, Gamepad.BTN_X, Gamepad.BTN_Y)) {
val pad = Pad()
assertEquals(emptyList<Int>(), pad.press(opening))
for (other in listOf(Gamepad.BTN_A, Gamepad.BTN_B, Gamepad.BTN_LB, Gamepad.BTN_DPAD_UP)) {
assertEquals(emptyList<Int>(), pad.press(other))
}
}
}
/**
* Walking into the exit chord (Select + Start + L1 + R1, in any order) must pass through
* neither instant chord: the disconnect hold is the one gesture where a stray mute or a
* changed overlay would land while the user is looking at the "hold to quit" hint.
*/
@Test
fun `reaching the exit chord fires nothing on the way`() {
val exit = listOf(Gamepad.BTN_BACK, Gamepad.BTN_START, Gamepad.BTN_LB, Gamepad.BTN_RB)
for (order in exit.permutations()) {
val pad = Pad()
for (bit in order) {
assertEquals("$order fired a chord at $bit", emptyList<Int>(), pad.press(bit))
}
assertEquals(GamepadRouter.EXIT_CHORD, pad.held)
}
}
/**
* X and Y held, then Select: ONE press completes BOTH chords. That is the honest reading of
* "the button that completes the mask", it is what the Apple client does too, and the
* alternative first match wins would make the same press mean different things depending
* on which chord the router happened to test first. Pinned so the behaviour is a decision
* rather than a surprise; both outcomes are visible and reversible on screen.
*/
@Test
fun `a shared Select can complete both chords at once`() {
val pad = Pad()
pad.press(Gamepad.BTN_X)
pad.press(Gamepad.BTN_Y)
assertEquals(instantChords, pad.press(Gamepad.BTN_BACK))
}
/** The chord bits are the wire's, so they must stay inside the 32-bit button mask. */
@Test
fun `chord masks are wire button bits`() {
for (chord in instantChords + GamepadRouter.EXIT_CHORD) {
assertTrue("chord $chord has no bits", chord != 0)
assertEquals("a chord bit is not a known BTN_*", chord, chord and ALL_BUTTONS)
}
}
private companion object {
/** Every button bit `Gamepad` defines — the universe a chord may draw from. */
val ALL_BUTTONS = listOf(
Gamepad.BTN_DPAD_UP, Gamepad.BTN_DPAD_DOWN, Gamepad.BTN_DPAD_LEFT, Gamepad.BTN_DPAD_RIGHT,
Gamepad.BTN_START, Gamepad.BTN_BACK, Gamepad.BTN_LS_CLICK, Gamepad.BTN_RS_CLICK,
Gamepad.BTN_LB, Gamepad.BTN_RB, Gamepad.BTN_GUIDE,
Gamepad.BTN_A, Gamepad.BTN_B, Gamepad.BTN_X, Gamepad.BTN_Y,
Gamepad.BTN_PADDLE1, Gamepad.BTN_PADDLE2, Gamepad.BTN_PADDLE3, Gamepad.BTN_PADDLE4,
Gamepad.BTN_TOUCHPAD, Gamepad.BTN_MISC1,
).fold(0) { acc, bit -> acc or bit }
/** Every ordering of a chord's buttons — presses arrive in whatever order the hands do. */
fun <T> List<T>.permutations(): List<List<T>> =
if (size <= 1) listOf(this)
else flatMap { head -> (this - head).permutations().map { listOf(head) + it } }
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"pins" : [
{
"identity" : "glur",
"kind" : "remoteSourceControl",
"location" : "https://github.com/joogps/Glur.git",
"state" : {
"revision" : "ba4f05d3c9a608ec773b9305f2af6089390de68a"
}
}
],
"version" : 2
}
+21 -1
View File
@@ -16,6 +16,17 @@ let package = Package(
.library(name: "PunktfunkShared", targets: ["PunktfunkShared"]),
.executable(name: "PunktfunkClient", targets: ["PunktfunkClient"]),
],
dependencies: [
// Progressive (gradient) backdrop blur for the form screens' trays a real blur with no
// material tint stage (see GamepadTrayBlur). Pinned by REVISION, not `from:`: the
// GlurBackdrop product exists only on main no release carries it (the newest tag,
// `1.1`, predates it, and is not three-component semver anyway, so version-based
// resolution stops at 1.0.4). The revision is main's head at adoption time; a revision
// pin stays reproducible when the branch moves.
.package(
url: "https://github.com/joogps/Glur.git",
revision: "ba4f05d3c9a608ec773b9305f2af6089390de68a"),
],
targets: [
.binaryTarget(name: "PunktfunkCore", path: "PunktfunkCore.xcframework"),
// No dependencies by design an extension process links this alone.
@@ -39,6 +50,10 @@ let package = Package(
// licensing in that README). `.process` compiles the catalog; loaded via
// OsIcon.swift.
.process("Resources/OsIcons.xcassets"),
// The launcher tiles' brand marks (template vector imagesets generated from the
// assets/launcher-icons masters by scripts/gen-launcher-icons.sh per-mark
// provenance and licensing in that README). Loaded via LauncherIcon.swift.
.process("Resources/LauncherIcons.xcassets"),
],
linkerSettings: [
// Rust staticlib system deps.
@@ -51,7 +66,12 @@ let package = Package(
// (The tvOS slide-transition package is referenced by the Xcode PROJECT only
// its manifest breaks SwiftPM whole-graph validation on macOS, and only the
// Punktfunk-tvOS target links it; the #if os(tvOS) import never compiles here.)
.executableTarget(name: "PunktfunkClient", dependencies: ["PunktfunkKit"]),
.executableTarget(
name: "PunktfunkClient",
dependencies: [
"PunktfunkKit",
.product(name: "GlurBackdrop", package: "Glur"),
]),
// PunktfunkCore is a direct dep too so the wire tests can name the C ABI's
// `PunktfunkInputEvent` / `PUNKTFUNK_INPUT_KIND_*` when asserting the gamepad byte layout.
.testTarget(
@@ -11,6 +11,12 @@
BB0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = BB0000000000000000000006 /* PunktfunkKit */; };
CC0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = CC0000000000000000000006 /* PunktfunkKit */; };
DD0000000000000000000003 /* SwiftUINavigationTransitions in Frameworks */ = {isa = PBXBuildFile; productRef = DD0000000000000000000002 /* SwiftUINavigationTransitions */; };
EE0000000000000000000012 /* Glur in Frameworks */ = {isa = PBXBuildFile; productRef = EE0000000000000000000002 /* Glur */; };
EE0000000000000000000013 /* GlurBackdrop in Frameworks */ = {isa = PBXBuildFile; productRef = EE0000000000000000000003 /* GlurBackdrop */; };
EE0000000000000000000014 /* Glur in Frameworks */ = {isa = PBXBuildFile; productRef = EE0000000000000000000004 /* Glur */; };
EE0000000000000000000015 /* GlurBackdrop in Frameworks */ = {isa = PBXBuildFile; productRef = EE0000000000000000000005 /* GlurBackdrop */; };
EE0000000000000000000016 /* Glur in Frameworks */ = {isa = PBXBuildFile; productRef = EE0000000000000000000006 /* Glur */; };
EE0000000000000000000017 /* GlurBackdrop in Frameworks */ = {isa = PBXBuildFile; productRef = EE0000000000000000000007 /* GlurBackdrop */; };
E295569A300948B9009F939C /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E2955699300948B9009F939C /* WidgetKit.framework */; };
E295569C300948B9009F939C /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E295569B300948B9009F939C /* SwiftUI.framework */; };
E29556A9300948BA009F939C /* PunktfunkWidgetsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E2955697300948B9009F939C /* PunktfunkWidgetsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
@@ -88,6 +94,8 @@
buildActionMask = 2147483647;
files = (
AA0000000000000000000005 /* PunktfunkKit in Frameworks */,
EE0000000000000000000012 /* Glur in Frameworks */,
EE0000000000000000000013 /* GlurBackdrop in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -96,6 +104,8 @@
buildActionMask = 2147483647;
files = (
BB0000000000000000000005 /* PunktfunkKit in Frameworks */,
EE0000000000000000000014 /* Glur in Frameworks */,
EE0000000000000000000015 /* GlurBackdrop in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -105,6 +115,8 @@
files = (
CC0000000000000000000005 /* PunktfunkKit in Frameworks */,
DD0000000000000000000003 /* SwiftUINavigationTransitions in Frameworks */,
EE0000000000000000000016 /* Glur in Frameworks */,
EE0000000000000000000017 /* GlurBackdrop in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -175,6 +187,8 @@
name = Punktfunk;
packageProductDependencies = (
AA0000000000000000000006 /* PunktfunkKit */,
EE0000000000000000000002 /* Glur */,
EE0000000000000000000003 /* GlurBackdrop */,
);
productName = Punktfunk;
productReference = AA0000000000000000000001 /* Punktfunk.app */;
@@ -201,6 +215,8 @@
name = "Punktfunk-iOS";
packageProductDependencies = (
BB0000000000000000000006 /* PunktfunkKit */,
EE0000000000000000000004 /* Glur */,
EE0000000000000000000005 /* GlurBackdrop */,
);
productName = "Punktfunk-iOS";
productReference = BB0000000000000000000001 /* Punktfunk-iOS.app */;
@@ -226,6 +242,8 @@
packageProductDependencies = (
CC0000000000000000000006 /* PunktfunkKit */,
DD0000000000000000000002 /* SwiftUINavigationTransitions */,
EE0000000000000000000006 /* Glur */,
EE0000000000000000000007 /* GlurBackdrop */,
);
productName = "Punktfunk-tvOS";
productReference = CC0000000000000000000001 /* Punktfunk-tvOS.app */;
@@ -283,6 +301,7 @@
packageReferences = (
AA000000000000000000000F /* XCLocalSwiftPackageReference "." */,
DD0000000000000000000001 /* XCRemoteSwiftPackageReference "swiftui-navigation-transitions" */,
EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = AA0000000000000000000008 /* Products */;
@@ -848,6 +867,14 @@
minimumVersion = 0.18.0;
};
};
EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/joogps/Glur.git";
requirement = {
kind = revision;
revision = ba4f05d3c9a608ec773b9305f2af6089390de68a;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
@@ -868,6 +895,36 @@
package = DD0000000000000000000001 /* XCRemoteSwiftPackageReference "swiftui-navigation-transitions" */;
productName = SwiftUINavigationTransitions;
};
EE0000000000000000000002 /* Glur */ = {
isa = XCSwiftPackageProductDependency;
package = EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */;
productName = Glur;
};
EE0000000000000000000003 /* GlurBackdrop */ = {
isa = XCSwiftPackageProductDependency;
package = EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */;
productName = GlurBackdrop;
};
EE0000000000000000000004 /* Glur */ = {
isa = XCSwiftPackageProductDependency;
package = EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */;
productName = Glur;
};
EE0000000000000000000005 /* GlurBackdrop */ = {
isa = XCSwiftPackageProductDependency;
package = EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */;
productName = GlurBackdrop;
};
EE0000000000000000000006 /* Glur */ = {
isa = XCSwiftPackageProductDependency;
package = EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */;
productName = Glur;
};
EE0000000000000000000007 /* GlurBackdrop */ = {
isa = XCSwiftPackageProductDependency;
package = EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */;
productName = GlurBackdrop;
};
E2CAFE000000000000000002 /* PunktfunkShared */ = {
isa = XCSwiftPackageProductDependency;
productName = PunktfunkShared;
@@ -1,6 +1,14 @@
{
"originHash" : "5d17a752eb57d190a90cbd663718ff44034b24fe0ae1baafea7677db2d49da6f",
"originHash" : "bb1ce9bc6042f166bd0aad78a15081e673781d3a90fa52fd8ec8a08875878ef6",
"pins" : [
{
"identity" : "glur",
"kind" : "remoteSourceControl",
"location" : "https://github.com/joogps/Glur.git",
"state" : {
"revision" : "ba4f05d3c9a608ec773b9305f2af6089390de68a"
}
},
{
"identity" : "objc-runtime-tools",
"kind" : "remoteSourceControl",
@@ -0,0 +1,202 @@
// Configurable Home-Screen / Lock-Screen library widget (kind "PunktfunkLibrary"). The user picks
// a saved host in the widget's configuration (long-press Edit Widget the picker is
// `HostEntity`'s query over the shared App-Group store, running in this extension process); a tap
// deep-links into that host's game library via `punktfunk://browse/<uuid>` the app's onOpenURL
// routes it to the same library presentation every internal surface drives. No session starts
// until a title is picked there.
//
// Unconfigured, it follows the most recently connected host (the same order the hosts widget
// leads with). A configured host that no longer exists shows the empty state rather than silently
// following a different host a widget that says "Studio" must never open someone else's library.
//
// Timeline is a single `.never` entry the app pushes reloads on store changes (HostStore
// WidgetCenter.reloadTimelines), exactly like the hosts widget.
import AppIntents
import SwiftUI
import WidgetKit
import PunktfunkShared
// MARK: - Configuration intent
/// The widget's per-instance configuration. Executes in the EXTENSION process which is why
/// `HostEntity` and its query live in PunktfunkShared, not the app.
struct LibraryWidgetConfigIntent: WidgetConfigurationIntent {
static let title: LocalizedStringResource = "Choose Host"
static let description = IntentDescription("Pick whose game library this widget opens.")
@Parameter(title: "Host", description: "Leave empty to follow your most recent host.")
var host: HostEntity?
}
// MARK: - Timeline
struct LibraryEntry: TimelineEntry {
let date: Date
/// The resolved target: the configured host if it still exists, the most recent one when
/// unconfigured, nil when there's nothing to open (empty store, or a removed configured host).
let host: StoredHost?
}
struct LibraryProvider: AppIntentTimelineProvider {
func placeholder(in context: Context) -> LibraryEntry {
LibraryEntry(date: .now, host: nil)
}
func snapshot(for configuration: LibraryWidgetConfigIntent, in context: Context) async
-> LibraryEntry {
LibraryEntry(date: .now, host: Self.resolve(configuration.host))
}
func timeline(for configuration: LibraryWidgetConfigIntent, in context: Context) async
-> Timeline<LibraryEntry> {
// Single entry, never auto-refresh: the app reloads this timeline on every store change.
Timeline(entries: [LibraryEntry(date: .now, host: Self.resolve(configuration.host))],
policy: .never)
}
/// The configured host by id nil (NOT a fallback) when it's gone; most-recent when nothing
/// was configured.
static func resolve(_ configured: HostEntity?) -> StoredHost? {
let hosts = HostsProvider.loadHosts() // shared-suite JSON, most-recent first
guard let configured else { return hosts.first }
return hosts.first { $0.id == configured.id }
}
}
// MARK: - Widget
struct LibraryWidget: Widget {
var body: some WidgetConfiguration {
AppIntentConfiguration(
kind: "PunktfunkLibrary", intent: LibraryWidgetConfigIntent.self,
provider: LibraryProvider()
) { entry in
LibraryWidgetView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("Game Library")
.description("Jump straight into a host's game library.")
.supportedFamilies([.systemSmall, .accessoryCircular, .accessoryRectangular])
}
}
// MARK: - Views
/// Deep link that opens a stored host's library.
private func browseURL(_ host: StoredHost) -> URL {
DeepLink.browse(host: host.id).url
}
struct LibraryWidgetView: View {
@Environment(\.widgetFamily) private var family
let entry: LibraryEntry
var body: some View {
switch family {
case .accessoryCircular:
CircularLibraryView(host: entry.host)
case .accessoryRectangular:
RectangularLibraryView(host: entry.host)
default: // systemSmall + fallback
SmallLibraryView(host: entry.host)
}
}
}
private struct SmallLibraryView: View {
let host: StoredHost?
var body: some View {
if let host {
VStack(alignment: .leading, spacing: 6) {
Image(systemName: "square.grid.2x2.fill")
.font(.title2)
.foregroundStyle(Color.brand)
Spacer(minLength: 0)
Text(host.displayName)
.font(.headline)
.lineLimit(2)
Text("Game Library")
.font(.caption2)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.widgetURL(browseURL(host))
} else {
EmptyLibraryView()
}
}
}
private struct CircularLibraryView: View {
let host: StoredHost?
var body: some View {
ZStack {
AccessoryWidgetBackground()
Image(systemName: "square.grid.2x2.fill")
}
.widgetURL(host.map(browseURL))
}
}
private struct RectangularLibraryView: View {
let host: StoredHost?
var body: some View {
HStack {
Image(systemName: "square.grid.2x2.fill")
VStack(alignment: .leading) {
Text(host?.displayName ?? "Punktfunk")
.lineLimit(1)
Text("Library")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.widgetURL(host.map(browseURL))
}
}
private struct EmptyLibraryView: View {
var body: some View {
VStack(spacing: 6) {
Image(systemName: "square.grid.2x2")
.font(.title2)
.foregroundStyle(.secondary)
Text("Open Punktfunk to pick a host.")
.font(.caption)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// MARK: - Previews (Xcode canvas)
//
// Same pattern as the hosts widget: `#Preview(as:widget:timeline:)` feeds sample entries directly,
// so the canvas works without a paired device or saved hosts. The small preview's second entry
// shows the empty state one timeline click away.
private let previewHost = StoredHost(
name: "Studio", address: "192.168.1.20",
lastConnected: .now.addingTimeInterval(-40 * 60))
#Preview("Small", as: .systemSmall) {
LibraryWidget()
} timeline: {
LibraryEntry(date: .now, host: previewHost)
LibraryEntry(date: .now, host: nil)
}
#Preview("Lock Screen circular", as: .accessoryCircular) {
LibraryWidget()
} timeline: {
LibraryEntry(date: .now, host: previewHost)
}
#Preview("Lock Screen rectangular", as: .accessoryRectangular) {
LibraryWidget()
} timeline: {
LibraryEntry(date: .now, host: previewHost)
}
@@ -15,6 +15,7 @@ import WidgetKit
struct PunktfunkWidgetBundle: WidgetBundle {
var body: some Widget {
HostsWidget()
LibraryWidget()
PunktfunkSessionLiveActivity()
}
}
@@ -56,6 +56,12 @@ struct ContentView: View {
/// Owns the Live Activity for the running session (Lock Screen / Dynamic Island). Driven from
/// the session model's published state below; iPhone/iPad only.
@State private var liveActivity = SessionActivityController()
/// The window's bottom safe-area inset (the home-indicator strip), reported by
/// DisplayBottomInsetProbe from UIKit's own callbacks and published as
/// `\.displayBottomInset` for the screens that pin a legend to the display's corner. Held
/// HERE and read through the environment because asking UIKit for it during a body severs
/// the asking view's updates on device (see the probe).
@State private var displayBottomInset: CGFloat = 0
#endif
@State private var pairingTarget: StoredHost?
/// A fresh `pair=required`/unknown host the user tapped: drives the choice between no-PIN
@@ -115,6 +121,23 @@ struct ContentView: View {
/// scenePhase drives the keep-alive: use THIS, not the willResignActive observers resign-active
/// also fires for Control Center / app-switcher peeks, where the disconnect timer must not start.
@Environment(\.scenePhase) private var scenePhase
#if os(iOS)
@Environment(\.horizontalSizeClass) private var hSizeClass
@Environment(\.verticalSizeClass) private var vSizeClass
#endif
/// The gamepad UI's form-metric tier for this window, published from HERE the app's root.
/// A screen that applies `gamepadPaletteInk` itself sits ABOVE its own copy of the environment,
/// so its `@Environment` resolves against its parent; publishing at the root is what makes
/// every one of them (including the ones presented as sheets and covers, which inherit the
/// environment) read its own window's tier instead of the bare default.
private var gamepadMetrics: GamepadFormMetrics {
#if os(iOS)
.forWindow(h: hSizeClass, v: vSizeClass)
#else
.platformDefault
#endif
}
private var gamepadUIActive: Bool {
GamepadUIEnvironment.isActive(
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled,
@@ -181,6 +204,36 @@ struct ContentView: View {
}
private var driven: some View {
drivenBase
.environment(\.gamepadMetrics, gamepadMetrics)
#if os(iOS)
.environment(\.displayBottomInset, displayBottomInset)
// The probe is UIKit's, not any screen's: mounted once here as a background so the
// legend-pinning screens can READ the inset from the environment without ever asking
// UIKit during their own body (which severs their updates see the probe).
.background {
DisplayBottomInsetProbe { displayBottomInset = $0 }
}
#endif
#if os(iOS) || os(macOS)
// The console's own modal, over WHICHEVER screen is up. Not attached to `home`, which
// renders only while `model.connection == nil`: a connection exists through the
// pair-required and approval handshakes, which is precisely when these prompts fire.
// It sits above the connect takeover too the delegated-approval wait is raised
// DURING a dial and owns the only Cancel for it. (The takeover draws nothing in that
// state: `connectingOverlayName` is nil while `awaitingApproval` is set, so the two
// never poll the pad at once.)
.overlay {
if let prompt = consolePrompt {
GamepadPromptView(prompt: prompt)
.gamepadPaletteInk()
.transition(.opacity)
}
}
#endif
}
private var drivenBase: some View {
Group {
// The stream view's structural identity MUST be stable across the
// awaiting-trust streaming transition: recreating it restarts the pump,
@@ -368,8 +421,21 @@ struct ContentView: View {
// (the "Pair with PIN instead" path disconnects first the host's accept loop
// is sequential, a pairing connection would queue behind the live session).
#if !os(tvOS)
.sheet(item: $pairingTarget) { host in
// macOS presents BOTH pairing UIs from here, picking by mode (the console UI's screen is
// gamepad-navigable; PairSheet's Form is not). iOS hides this sheet in gamepad mode
// instead there the pair screen is one of the shell's in-place layers, exactly like
// settings and add-host (see `touchPairingTarget`).
.sheet(item: touchPairingTarget) { host in
#if os(macOS)
if gamepadUIActive {
GamepadPairView(host: host, onPaired: { handlePaired(host, fingerprint: $0) })
.frame(width: 660, height: 620)
} else {
PairSheet(host: host) { fingerprint in handlePaired(host, fingerprint: fingerprint) }
}
#else
PairSheet(host: host) { fingerprint in handlePaired(host, fingerprint: fingerprint) }
#endif
}
.sheet(item: $speedTestTarget) { host in
SpeedTestSheet(host: host)
@@ -409,9 +475,102 @@ struct ContentView: View {
// budget (inline, they tip SwiftUI's per-expression limit see the split sections idiom).
private var deepLinkNoticePresented: Binding<Bool> {
Binding(get: { deepLinkNotice != nil }, set: { if !$0 { deepLinkNotice = nil } })
Binding(
get: { deepLinkNotice != nil && !consolePromptShowing },
set: { if !$0 { deepLinkNotice = nil } })
}
/// True while the console prompt owns the modal state (see `consolePrompt`). Always false on
/// tvOS, whose alerts the focus engine drives natively.
private var consolePromptShowing: Bool {
#if os(iOS) || os(macOS)
consolePrompt != nil
#else
false
#endif
}
#if os(iOS) || os(macOS)
/// The modal state the console UI should present ITSELF, as a pad-navigable prompt, instead of
/// letting a system alert take it. `.alert`/`.confirmationDialog` are UIKit/AppKit surfaces a
/// controller cannot navigate, and these are not incidental prompts: "Pairing required" is the
/// FIRST thing an unpaired host shows, "Connection failed" strands the console UI behind a
/// modal only a finger can dismiss, and "Waiting for approval" owns the only Cancel for a
/// connect that may never complete. One at a time, most-urgent first a system alert stack
/// would layer these, but a console shows one screen.
///
/// Gated on not STREAMING, not on `model.connection == nil`: a connection object exists well
/// before a stream does, through exactly the handshakes these prompts belong to. Streaming is
/// the one case that must stay with the system alert there the pad belongs to
/// `GamepadCapture` and is being forwarded to the host.
private var consolePrompt: GamepadPrompt? {
guard gamepadUIActive, model.phase != .streaming else { return nil }
if let req = approvalChoice {
return GamepadPrompt(
id: "pairing-required",
title: "Pairing required",
message: "\(req.host.displayName) requires pairing. Request access and approve "
+ "this device in the host's web console (port 47992 → Pairing) — no PIN "
+ "needed. Or pair with the 4-digit PIN it can display.",
actions: [
// The follow-on presentation is deferred a tick exactly as the system dialog
// does it, so this prompt is fully torn down before the next screen mounts
// two controller pollers overlapping for a frame is how one A press reaches
// both.
GamepadPromptAction(id: "request", title: "Request Access", isPrimary: true) {
approvalChoice = nil
DispatchQueue.main.async { requestAccess(req) }
},
GamepadPromptAction(id: "pin", title: "Pair with PIN…") {
approvalChoice = nil
DispatchQueue.main.async { pairingTarget = req.host }
},
GamepadPromptAction(id: "cancel", title: "Cancel", isCancel: true) {
approvalChoice = nil
},
])
}
if let req = awaitingApproval {
return GamepadPrompt(
id: "awaiting-approval",
title: "Waiting for approval",
message: "Approve \u{201C}\(localDeviceName)\u{201D} in \(req.host.displayName)'s "
+ "web console (port 47992 → Pairing). This device connects automatically "
+ "once you approve it — no need to reconnect.",
actions: [
GamepadPromptAction(id: "cancel", title: "Cancel", isCancel: true) {
awaitingApproval = nil
model.disconnect()
},
],
busy: true)
}
if connectionErrorReady {
return GamepadPrompt(
id: "connection-failed",
title: "Connection failed",
message: model.errorMessage ?? "",
actions: [
GamepadPromptAction(id: "ok", title: "OK", isCancel: true) {
model.errorMessage = nil
},
])
}
if let notice = deepLinkNotice {
return GamepadPrompt(
id: "cant-open",
title: "Can't open",
message: notice,
actions: [
GamepadPromptAction(id: "ok", title: "OK", isCancel: true) {
deepLinkNotice = nil
},
])
}
return nil
}
#endif
/// The iOS library cover's item: `libraryTarget`, hidden while the gamepad shell presents
/// the library in place (see the cover's comment).
private var touchLibraryTarget: Binding<StoredHost?> {
@@ -420,19 +579,37 @@ struct ContentView: View {
set: { libraryTarget = $0 })
}
/// The pairing sheet's item. On iOS it hides while the gamepad shell presents the pair screen
/// in place the same proxy the library uses, and for the same reason: every writer keeps
/// writing `pairingTarget`, and whichever presentation the current mode owns picks it up.
/// macOS has no shell, so the sheet stays and switches its CONTENT by mode instead.
private var touchPairingTarget: Binding<StoredHost?> {
#if os(macOS)
Binding(get: { pairingTarget }, set: { pairingTarget = $0 })
#else
Binding(
get: { gamepadUIActive ? nil : pairingTarget },
set: { pairingTarget = $0 })
#endif
}
private var approvalChoicePresented: Binding<Bool> {
Binding(get: { approvalChoice != nil }, set: { if !$0 { approvalChoice = nil } })
Binding(
get: { approvalChoice != nil && !consolePromptShowing },
set: { if !$0 { approvalChoice = nil } })
}
private var awaitingApprovalPresented: Binding<Bool> {
Binding(get: { awaitingApproval != nil }, set: { if !$0 { awaitingApproval = nil } })
Binding(
get: { awaitingApproval != nil && !consolePromptShowing },
set: { if !$0 { awaitingApproval = nil } })
}
private var connectionErrorPresented: Binding<Bool> {
Binding(
get: {
guard model.errorMessage != nil else { return false }
#if os(macOS)
/// Whether the "Connection failed" state is ready to be shown at all shared by the system
/// alert and the console prompt so the two can never disagree about the macOS deferral below.
private var connectionErrorReady: Bool {
guard model.errorMessage != nil else { return false }
#if os(macOS)
// Defer the alert while a forced-fullscreen exit is still pending: a sheet
// attached to a fullscreen window makes AppKit drop `-toggleFullScreen:`, so
// presenting it now strands the window fullscreen on the home screen after a
@@ -441,10 +618,14 @@ struct ContentView: View {
// once the window leaves fullscreen and `isFullscreen` flips, the alert shows
// over the windowed home UI. Not gated when fullscreen is the user's own manual
// choice (opt-out setting) nothing is auto-exiting there to conflict with.
if fullscreenForSession && isFullscreen { return false }
#endif
return true
},
if fullscreenForSession && isFullscreen { return false }
#endif
return true
}
private var connectionErrorPresented: Binding<Bool> {
Binding(
get: { connectionErrorReady && !consolePromptShowing },
set: { if !$0 { model.errorMessage = nil } })
}
@@ -487,10 +668,20 @@ struct ContentView: View {
?? "That link is malformed and was ignored."
return
}
guard link.route == .connect else {
// `wake` and `browse` are reserved in the grammar and parse today; this build routes
// neither, and saying so beats silently connecting instead.
deepLinkNotice = "Punktfunk links can't do “\(link.route.rawValue)” yet."
switch link.route {
case .connect:
break
case .browse:
// The reserved library route, now real: open the host's game library without starting
// a session. `launch=`/`profile=` are meaningless on a browse (nothing streams until a
// title is picked, and that connect resolves its own profile) ignored, not refused,
// per the unknown-parameter rule.
openLibrary(from: link)
return
case .wake:
// Still reserved: saying so beats silently connecting instead. (Shortcuts users have
// the Wake Host intent, which never round-trips through a URL.)
deepLinkNotice = "Punktfunk links can't do “wake” yet."
return
}
// Resolve the one-off profile BEFORE anything happens: an unknown or ambiguous reference
@@ -544,6 +735,38 @@ struct ContentView: View {
}
}
/// `punktfunk://browse/<host-ref>` jump into a host's game library. Drives the SAME
/// `libraryTarget` every internal surface writes, so the link lands in whichever presentation
/// the current mode owns: the gamepad console's in-place library screen, the touch cover, the
/// macOS sheet, or tvOS's cover. Connect's posture minus the connect itself: a pin conflict
/// refuses, a live session is never preempted, and an unsaved host can't be browsed the
/// library fetch rides the paired mTLS identity, so there is nothing to show before the host
/// is saved (the notice says what to do instead).
private func openLibrary(from link: DeepLink) {
switch link.resolveHost(in: store.hosts) {
case .known(let host):
guard !link.pinConflict(with: host) else {
deepLinkNotice = "That link's fingerprint doesn't match the identity saved for "
+ "\(host.displayName). It's out of date, or it isn't pointing where it says."
return
}
guard model.phase == .idle else {
let current = model.activeHost?.displayName ?? "a host"
deepLinkNotice = "Already streaming \(current). End that session first."
return
}
libraryTarget = host
case .unknown(let address, _, let name, _):
deepLinkNotice = "\(name ?? address) isn't saved on this device yet. "
+ "Add it with the + button first — a library can only be browsed on a saved host."
case .ambiguous:
deepLinkNotice = "More than one saved host is called “\(link.hostRef)”. "
+ "Rename one, or link to it by its address."
case .unresolvable:
deepLinkNotice = "That host isn't saved on this device."
}
}
private var home: some View {
// The full-screen connect takeover rides over BOTH home UIs (and the pre-connect window is
// still `home`, so it covers the whole dial wake online connect sequence): instant
@@ -576,9 +799,11 @@ struct ContentView: View {
if gamepadUIActive {
GamepadHomeView(
store: store, model: model, discovery: discovery,
libraryTarget: $libraryTarget, waker: waker,
libraryTarget: $libraryTarget, pairingTarget: $pairingTarget,
onPaired: handlePaired, waker: waker,
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered,
launchTitle: launchTitle)
launchTitle: launchTitle,
promptActive: consolePromptShowing)
} else {
HomeView(
store: store, model: model, discovery: discovery,
@@ -593,9 +818,11 @@ struct ContentView: View {
if gamepadUIActive {
GamepadHomeView(
store: store, model: model, discovery: discovery,
libraryTarget: $libraryTarget, waker: waker,
libraryTarget: $libraryTarget, pairingTarget: $pairingTarget,
onPaired: handlePaired, waker: waker,
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered,
launchTitle: launchTitle)
launchTitle: launchTitle,
promptActive: consolePromptShowing)
// On tvOS pairing/library normally present from HomeView's navigationDestinations
// which aren't mounted while the gamepad launcher is up. Give the launcher its
// own presenters (exactly one of the two homes is mounted at a time, so these can
@@ -13,6 +13,8 @@ import SwiftUI
struct GamepadAddHostView: View {
@Environment(\.gamepadInk) private var ink
@Environment(\.gamepadMetrics) private var metrics
@Environment(\.displayBottomInset) private var displayBottomInset
@Environment(\.dismiss) private var dismiss
@Environment(\.gamepadHostedInShell) private var hostedInShell
let onAdd: (StoredHost) -> Void
@@ -48,7 +50,7 @@ struct GamepadAddHostView: View {
isActive: controllerActive && editing == nil
) { row, focused in
rowView(row, focused: focused)
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
.frame(maxWidth: metrics.rowMaxWidth)
.padding(.horizontal, 24)
}
.frame(maxWidth: .infinity)
@@ -61,25 +63,28 @@ struct GamepadAddHostView: View {
if !compact {
Text("Hosts on this network appear automatically — add one by address "
+ "for everything else.")
.font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption))
.font(.geist(metrics.detailFont, relativeTo: .caption))
.foregroundStyle(ink.fg(0.55))
.multilineTextAlignment(.leading)
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth * 0.72, alignment: .leading)
.frame(maxWidth: metrics.rowMaxWidth * 0.72, alignment: .leading)
}
}
.padding(.horizontal, 24)
.padding(.top, gamepadTitleTopPadding(compact: compact))
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
.frame(maxWidth: .infinity, alignment: .leading)
.background { GamepadTrayScrim(edge: .top) }
.background { GamepadTrayBlur(edge: .top) }
}
.safeAreaInset(edge: .bottom, spacing: 0) {
bottomTray
// Equal distance from the left and bottom edges for the legend pill (see GamepadHomeView).
.padding(.horizontal, compact ? 12 : 18)
.padding(.bottom, compact ? 12 : 18)
.padding(
.bottom,
gamepadLegendBottomPadding(
compact ? 12 : 18, tier: metrics.tier, displayBottom: displayBottomInset))
.padding(.top, compact ? 6 : 10)
.background { GamepadTrayScrim(edge: .bottom) }
.background { GamepadTrayBlur(edge: .bottom) }
}
// No aurora the same clean Liquid-Glass-over-dark base as the gamepad settings screen.
// Hosted in the shell, the field is the shell's (see GamepadSettingsView's twin).
@@ -148,17 +153,28 @@ struct GamepadAddHostView: View {
// binding on appear new identity forces a rewire to the new field.
.id(editing)
GamepadHintBar(hints: [
// "Type" names what A does to the key under the keyboard's cursor. There is
// no tap equivalent a touch user types by tapping the keycap itself so
// this one cell stays a label.
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Type"),
.init(glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Delete"),
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"),
.init(
glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Delete",
action: { backspace(editing) }),
.init(
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done",
action: { closeKeyboard() }),
])
.frame(maxWidth: .infinity, alignment: .leading)
}
.transition(.move(edge: .bottom).combined(with: .opacity))
} else {
GamepadHintBar(hints: [
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Select"),
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Cancel"),
.init(
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Select",
action: { if let focusID { activate(id: focusID) } }),
.init(
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Cancel",
action: { performClose() }),
])
.frame(maxWidth: .infinity, alignment: .leading)
}
@@ -191,7 +207,7 @@ struct GamepadAddHostView: View {
}
private func rowView(_ row: Row, focused: Bool) -> some View {
let m = GamepadFormMetrics.self
let m = metrics
return HStack(spacing: 14) {
if row.isAction {
Label("Add Host", systemImage: "plus.circle.fill")
@@ -268,6 +284,15 @@ struct GamepadAddHostView: View {
withAnimation(.spring(response: 0.32, dampingFraction: 0.86)) { editing = nil }
}
/// The legend's Delete cell (iOS/macOS). Applied to the field's binding rather than routed
/// into `GamepadKeyboard`: the keyboard's X does exactly this to the same binding, and
/// reaching into its state to trigger it would need a whole callback channel for one edit.
private func backspace(_ id: String) {
let binding = editingBinding(id)
guard !binding.wrappedValue.isEmpty else { return }
binding.wrappedValue.removeLast()
}
private func editingBinding(_ id: String) -> Binding<String> {
switch id {
case "name": return $name
@@ -191,6 +191,16 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
.sensoryFeedback(.selection, trigger: cursor)
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
.sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.7), trigger: boundaryTick)
#if os(iOS) || os(macOS)
// A hardware keyboard drives the same cursor as the pad arrows step, Return activates,
// Esc backs out (iPad on a Magic Keyboard, couch Mac). tvOS routes arrows through the
// focus engine instead, which owns navigation there.
.gamepadKeyNavigation(
active: isActive,
onMove: { move($0) },
onConfirm: { activate() },
onBack: onBack)
#endif
.onAppear {
reconcile()
wire()
@@ -432,8 +442,8 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
/// one, so the strip FANS OPEN from the cursor rather than sweeping past it; the anchor card
/// itself only grows, since it is already facing you. Each card carries its own delay (see
/// `entrance(_:)`) that stagger is what makes the strip read as one gesture instead of a
/// simultaneous flash, and it is the same hinge/perspective language the coverflow's own recede
/// speaks, so the arrival and the scrolling feel like one object.
/// simultaneous flash, and it is the same hinge language the coverflow's own recede speaks, so the
/// arrival and the scrolling feel like one object.
///
/// APPLY THIS UNDERNEATH THE CARD'S OWN `.scrollTransition`, never around it. A scroll
/// transition derives its phase from the geometry of the view it wraps, so an entrance layered
@@ -442,6 +452,23 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
/// collapsed into its focused look as the entrance ended arriving as a jump. Underneath, the
/// transition measures a card that never moves and simply composes its own scale/rotation on top.
///
/// NO `rotation3DEffect` HERE, however much the drum language invites one. It was the cause of
/// the strip's "flash as the cards settle": a real 3D transform renders the card through an
/// offscreen layer, and a card carries translucent glass, which resolves differently in there
/// so every card sat at the wrong fill for as long as the master animation ran and then snapped
/// to its true one in a SINGLE frame the moment SwiftUI dropped that layer.
///
/// Measured on an iPad Pro 13": the centred tile held #4a3d87 for twelve frames in which nothing
/// moved, then stepped to #423970 (23 blue) in one. It is the ANIMATION ending, not the motion:
/// stretching the timeline from 1.02 s to 2.82 s moved the step from 0.70 s to 2.50 s after the
/// launcher appeared the same 0.32 s before the end both times. Removing the rotation removed
/// the step outright; `compositingGroup()` above or below the transforms did nothing.
///
/// So the turn is PROJECTED instead: `cos(angle)` as a horizontal squeeze is exactly the
/// orthographic projection of a Y-axis rotation, hinged on the edge the card fans from. Affine,
/// so no offscreen pass and no layer to drop and it reads as the same gesture, losing only the
/// perspective trapezoid, which at these card sizes was never what sold the motion.
///
/// Transforms only nothing here touches layout, so the scroll view's snapping and the tvOS
/// focus engine are untouched either. Reduce Motion drops every bit of travel for a plain,
/// unstaggered cross-fade.
@@ -485,14 +512,15 @@ struct CardEntrance: ViewModifier, Animatable {
// leading edge), so the arrival deepens the turn the card wears at rest and unwinds into
// it instead of swinging the opposite way.
let away = reduceMotion ? 0 : 1 - travel
// The turn, projected rather than rendered in 3D see the type's note on the flash.
// `cos` of the angle IS the orthographic projection of a Y-axis rotation, and hinging it
// on the edge the card fans from restores the direction that the rotation's sign carried
// (cos is even, so the sign alone would read the same both ways).
let turn = cos(Angle.degrees(64 * away).radians)
return content
.opacity(reduceMotion ? raw : fade)
.scaleEffect(1 - 0.26 * away)
.rotation3DEffect(
.degrees(side * -64 * away),
axis: (x: 0, y: 1, z: 0),
anchor: .center,
perspective: 0.65)
.scaleEffect(x: turn, y: 1, anchor: side < 0 ? .trailing : .leading)
.offset(y: 34 * away)
}
@@ -5,20 +5,36 @@
// iOS/iPadOS, macOS (the couch Mac-mini case), and tvOS where the same screens are driven by
// the native focus engine instead of the controller poll (see GamepadCarousel/GamepadMenuList).
import Glur
import GlurBackdrop
import PunktfunkKit
import SwiftUI
#if os(iOS) || os(macOS) || os(tvOS)
import GameController
/// The active controller's real glyph for a button (Xbox "A", DualSense , ) via
/// `sfSymbolsName`; a generic fallback before a controller profile resolves.
/// The glyph a button wears in a legend: the ACTIVE controller's own (Xbox "A", DualSense , )
/// via `sfSymbolsName` while one is attached, else the glyph of the last pad this device ever saw
/// (`GamepadManager.lastKnownKind` `GamepadGlyphs`), else the caller's generic fallback.
///
/// The middle rung is the whole point. `active` is nil whenever the pad sleeps, disconnects or
/// runs flat and permanently under `gamepadUIMode == "always"`, which puts the console UI up
/// with no pad by design and the fallbacks are letter glyphs, so a DualSense user's / legends
/// used to turn into A/B the moment the controller dozed off. The remembered kind keeps the
/// legends speaking the pad the user actually owns. The `fallback` still covers the genuinely
/// unknown case: a fresh install that has never seen a controller, and any button outside the six
/// `GamepadButtonRole` names.
///
/// @MainActor: GamepadManager is main-actor-bound (inside a View body this was implicit).
@MainActor
func buttonGlyph(
_ button: KeyPath<GCExtendedGamepad, GCControllerButtonInput>, fallback: String
) -> String {
GamepadManager.shared.active?.controller.extendedGamepad?[keyPath: button].sfSymbolsName
?? fallback
let manager = GamepadManager.shared
if let live = manager.active?.controller.extendedGamepad?[keyPath: button].sfSymbolsName {
return live
}
guard let role = GamepadButtonRole(keyPath: button) else { return fallback }
return GamepadGlyphs.symbol(role, for: manager.lastKnownKind)
}
/// Top padding for a gamepad screen's pinned title. macOS gets extra clearance the launcher
@@ -68,43 +84,251 @@ func gamepadTitleSize(compact: Bool) -> CGFloat {
}
/// Metrics shared by the gamepad form screens' glass rows (GamepadSettingsView,
/// GamepadAddHostView) one set of numbers so the two screens read as the same surface,
/// sized for the couch on tvOS and for the hand elsewhere.
enum GamepadFormMetrics {
#if os(tvOS)
static let headerFont: CGFloat = 17
static let labelFont: CGFloat = 23
static let valueFont: CGFloat = 21
static let iconFont: CGFloat = 24
static let iconWidth: CGFloat = 40
static let chevronFont: CGFloat = 16
static let rowHPad: CGFloat = 24
static let rowVPad: CGFloat = 19
static let rowCorner: CGFloat = 18
static let rowMaxWidth: CGFloat = 920
static let detailFont: CGFloat = 19
static let bandWidth: CGFloat = 380
#else
static let headerFont: CGFloat = 12
static let labelFont: CGFloat = 16
static let valueFont: CGFloat = 15
static let iconFont: CGFloat = 17
static let iconWidth: CGFloat = 28
static let chevronFont: CGFloat = 12
static let rowHPad: CGFloat = 16
static let rowVPad: CGFloat = 13
static let rowCorner: CGFloat = 14
static let rowMaxWidth: CGFloat = 620
static let detailFont: CGFloat = 13
/// GamepadAddHostView) one set of numbers so the screens read as the same surface, at the size
/// the screen they are on calls for.
///
/// Three tiers, not two. The phone numbers used to serve every non-TV device, so an iPad Pro drew
/// a settings list at iPhone scale in the middle of a 13" display the field verdict was that the
/// sizing "does not adapt to larger screens". `pad` sits between the in-hand and 10-foot sets.
///
/// Chosen from the SIZE CLASSES rather than the device idiom, so an iPad running a narrow Stage
/// Manager or Split View window correctly gets the in-hand numbers the window is what the user
/// is reading, not the panel it sits on.
struct GamepadFormMetrics {
/// Which set this is, for the few things that are a KIND of layout rather than a number.
enum Tier { case phone, pad, tv }
let tier: Tier
let headerFont: CGFloat
let labelFont: CGFloat
let valueFont: CGFloat
let iconFont: CGFloat
let iconWidth: CGFloat
let chevronFont: CGFloat
let rowHPad: CGFloat
let rowVPad: CGFloat
let rowCorner: CGFloat
let rowMaxWidth: CGFloat
let detailFont: CGFloat
/// The option band's (GamepadOptionBand) fixed stage inside a choice row.
static let bandWidth: CGFloat = 240
let bandWidth: CGFloat
/// The settings screen's section-tab pills.
let tabFont: CGFloat
/// The pinned controls legend (GamepadHintBar).
let hintGlyphFont: CGFloat
let hintTextFont: CGFloat
let hintPad: CGFloat
/// In-hand: a phone, or any window narrow enough to read like one.
static let phone = GamepadFormMetrics(
tier: .phone,
headerFont: 12, labelFont: 16, valueFont: 15, iconFont: 17, iconWidth: 28,
chevronFont: 12, rowHPad: 16, rowVPad: 13, rowCorner: 14, rowMaxWidth: 620,
detailFont: 13, bandWidth: 240,
tabFont: 13, hintGlyphFont: 19, hintTextFont: 14, hintPad: 13)
/// A tablet-sized window an arm's length away rather than in the palm.
static let pad = GamepadFormMetrics(
tier: .pad,
headerFont: 14, labelFont: 20, valueFont: 19, iconFont: 21, iconWidth: 34,
chevronFont: 14, rowHPad: 20, rowVPad: 16, rowCorner: 16, rowMaxWidth: 820,
detailFont: 16, bandWidth: 320,
tabFont: 16, hintGlyphFont: 23, hintTextFont: 17, hintPad: 15)
/// 10-foot.
static let tv = GamepadFormMetrics(
tier: .tv,
headerFont: 17, labelFont: 23, valueFont: 21, iconFont: 24, iconWidth: 40,
chevronFont: 16, rowHPad: 24, rowVPad: 19, rowCorner: 18, rowMaxWidth: 920,
detailFont: 19, bandWidth: 380,
tabFont: 17, hintGlyphFont: 27, hintTextFont: 20, hintPad: 18)
/// What a screen gets before anything publishes a tier and the only tier tvOS and macOS ever
/// use (an Apple TV is always 10-foot; a Mac window is read at desk distance).
static var platformDefault: GamepadFormMetrics {
#if os(tvOS)
tv
#else
phone
#endif
}
#if os(iOS)
/// The tier a window's size classes call for. REGULAR on both axes is the tablet case.
static func forWindow(
h: UserInterfaceSizeClass?, v: UserInterfaceSizeClass?
) -> GamepadFormMetrics {
h == .regular && v == .regular ? .pad : .phone
}
#endif
}
private struct GamepadMetricsKey: EnvironmentKey {
static let defaultValue = GamepadFormMetrics.platformDefault
}
extension EnvironmentValues {
/// The form metrics for the screen currently drawing. Published from ContentView the app
/// ROOT rather than only from `gamepadPaletteInk`, because a screen that applies that
/// modifier itself sits ABOVE its own copy: its `@Environment` resolves against its parent, so
/// it would read the bare default instead of its own window's tier.
var gamepadMetrics: GamepadFormMetrics {
get { self[GamepadMetricsKey.self] }
set { self[GamepadMetricsKey.self] = newValue }
}
}
private struct DisplayBottomInsetKey: EnvironmentKey {
static let defaultValue: CGFloat = 0
}
extension EnvironmentValues {
/// The display's bottom safe-area inset the home-indicator strip measured by
/// `DisplayBottomInsetProbe` and published from ContentView. 0 until UIKit's first callback
/// lands (the legend keeps its plain margin for that first frame) and always 0 on
/// macOS/tvOS, where nothing publishes it.
var displayBottomInset: CGFloat {
get { self[DisplayBottomInsetKey.self] }
set { self[DisplayBottomInsetKey.self] = newValue }
}
}
#if os(iOS)
/// Reports the hosting window's bottom safe-area inset from UIKit's OWN callbacks never
/// during a SwiftUI render.
///
/// This number has a history of wrong spellings, each failing silently:
/// - a `GeometryReader` carrying `.ignoresSafeArea()` a proxy reports NO insets for an edge it
/// has been told to ignore, so that spelling can only ever answer 0;
/// - `.ignoresSafeArea(.container, edges: .bottom)` on `safeAreaInset` CONTENT, which does not
/// move content the inset mechanism itself placed; and
/// - asking UIKit for the key window (`UIApplication.shared.connectedScenes`) DURING body,
/// which answered correctly and then KILLED the calling view: on an iPad (never the
/// simulator) the walk re-enters UIKit layout mid-render and the view's update graph is
/// silently severed every later `@State` write lands in storage without ever re-running
/// `body` again, which is how Settings and Add Host stopped opening while their triggers
/// kept firing. No AttributeGraph warning, no log line; found by bisecting builds on glass.
/// So: UIKit tells THIS view when the window or its insets change, on UIKit's schedule, and the
/// answer hops out of the current update before anyone in SwiftUI reads it.
struct DisplayBottomInsetProbe: UIViewRepresentable {
let onChange: (CGFloat) -> Void
func makeUIView(context: Context) -> ProbeView {
let view = ProbeView()
view.onChange = onChange
// Mounted as a full-size `.background`; it must never eat a touch meant for the UI.
view.isUserInteractionEnabled = false
return view
}
func updateUIView(_ view: ProbeView, context: Context) {
view.onChange = onChange
}
final class ProbeView: UIView {
var onChange: ((CGFloat) -> Void)?
private var last: CGFloat?
override func didMoveToWindow() {
super.didMoveToWindow()
report()
}
override func safeAreaInsetsDidChange() {
super.safeAreaInsetsDidChange()
report()
}
// Rotation reshuffles the window's insets without necessarily touching this view's own.
override func layoutSubviews() {
super.layoutSubviews()
report()
}
private func report() {
// The WINDOW's inset, not this view's: the probe sits inside the safe area, so its
// own inset is 0 the number the legend needs is the strip the window reserves.
guard let bottom = window?.safeAreaInsets.bottom, bottom != last else { return }
last = bottom
let onChange = onChange
// Out of the current UIKit/SwiftUI update before any state write.
DispatchQueue.main.async { onChange?(bottom) }
}
}
}
#endif
/// The bottom padding that puts a pinned legend the same distance from the bottom of the DISPLAY
/// as it sits from the leading edge so it lands on the diagonal of the display's rounded corner,
/// which is what the corner asks for.
///
/// A `safeAreaInset` places its content INSIDE the safe area, so a plain margin stacks on top of
/// the device's own bottom inset and the pill ends up two to three times further from the bottom
/// than from the left. On a tablet this therefore goes NEGATIVE, pulling the pill back down
/// through the home-indicator strip; the pill is left-aligned and an iPad's indicator is a short
/// bar in the middle, so the two never meet.
///
/// Phones keep the plain margin. Their inset is the taller indicator bar and their legend runs
/// most of the width, so sitting it that low would cross the indicator rather than tuck beside it.
///
/// `displayBottom` is `\.displayBottomInset` measured by `DisplayBottomInsetProbe`, NEVER asked
/// of UIKit here: this runs during body, and a key-window walk mid-render severs the calling
/// view's updates (see the probe's comment). Pure arithmetic only.
func gamepadLegendBottomPadding(
_ margin: CGFloat, tier: GamepadFormMetrics.Tier, displayBottom: CGFloat
) -> CGFloat {
guard tier == .pad else { return margin }
// Floored at -inset: at worst the pill sits flush with the physical edge, never past it.
return max(-displayBottom, margin - displayBottom)
}
/// The tray gradient blur, back as a real progressive BACKDROP blur this time.
///
/// GamepadTrayScrim did this with `.ultraThinMaterial`, and a material by definition lifts and
/// tints whatever it blurs: it read grey over the aurora, and washed with the palette's ground it
/// read coloured, which is why 2590238b deleted it. Glur's `GlurView` blurs the backdrop through
/// a gradient with NO material stage on top, so the rows soften as they slide under the pinned
/// title and legend and nothing carries a colour. It is the library's PRIVATE-API product
/// (`GlurBackdrop`) the public `.glur()` modifier is a shader on a view's own content and
/// silently no-ops over platform-backed views like ScrollView, so it cannot reach a backdrop at
/// all. Hit testing is disabled inside GlurView; the band never eats a touch.
///
/// Mounted exactly where the scrim was: `.background` of each form screen's safe-area tray.
struct GamepadTrayBlur: View {
let edge: VerticalEdge
var body: some View {
// offset 0 puts the ramp's LITERAL ZERO exactly at the band's content edge, so nothing
// in the open field is touched which is why, unlike the scrim, this band takes NO
// content-side overhang. The scrim's -44/-72 runway existed because a material carries
// body at every alpha and had to dissolve OUTSIDE the tray; carrying those numbers over
// here blurred fully-visible rows at rest (field verdict on the first cut). Full
// strength lands at 60% of the band, so the tray's own text always sits on the strong
// region while the ramp still reads as a gradient, not an edge.
GlurView(
radius: 14, offset: 0, interpolation: 0.6,
direction: edge == .top ? .up : .down)
// Full-bleed by LAYOUT, not `.ignoresSafeArea()`: safe-area expansion resolves a
// beat after insertion (outside any geometry group and outside this view's own
// transaction), which reads as a visible pop. 80 pt clears every inset on every
// device, and backgrounds never clip the overhang simply draws.
.padding(edge == .top ? .top : .bottom, -80)
.padding(.horizontal, -80)
// And the shape must NEVER animate: mounted inside a pushed shell layer, any late
// geometry would ride the push's transaction and visibly grow into place. The
// layer's own fade/slide still carries the band; only its SHAPE is pinned.
.transaction { $0.animation = nil }
}
}
/// One glyph + label cell in a hint bar.
struct GamepadHint: Identifiable {
let glyph: String
let text: String
/// What tapping/clicking this cell does the same thing its button does. Optional because a
/// few legend cells NAME an input rather than an action (" Adjust" is the stick itself;
/// there is no single thing a tap on it could mean), and those stay inert labels.
var action: (() -> Void)? = nil
var id: String { glyph + text }
}
@@ -114,39 +338,75 @@ struct GamepadHint: Identifiable {
/// the backdrop instead of dissolving into it.
struct GamepadHintBar: View {
@Environment(\.gamepadInk) private var ink
/// Sized with the screen it pins to a legend at phone scale on a 13" iPad is the same
/// mismatch the form rows had (see GamepadFormMetrics).
@Environment(\.gamepadMetrics) private var metrics
let hints: [GamepadHint]
// 10-foot legend on tvOS, in-hand sizes elsewhere.
#if os(tvOS)
private static let glyphFont: CGFloat = 27
private static let textFont: CGFloat = 20
private static let pad: CGFloat = 18
#else
private static let glyphFont: CGFloat = 19
private static let textFont: CGFloat = 14
private static let pad: CGFloat = 13
#endif
var body: some View {
HStack(spacing: 18) {
ForEach(hints) { hint in
HStack(spacing: 7) {
Image(systemName: hint.glyph)
.font(.system(size: Self.glyphFont))
.foregroundStyle(ink.fg)
Text(hint.text)
}
.fixedSize() // keep glyph + label together; never truncate a hint mid-word
cell(hint)
}
}
.font(.geist(Self.textFont, .semibold, relativeTo: .subheadline))
.font(.geist(metrics.hintTextFont, .semibold, relativeTo: .subheadline))
.foregroundStyle(ink.fg(0.85))
.padding(Self.pad)
.padding(metrics.hintPad)
.consoleGlass(Capsule())
.overlay(Capsule().strokeBorder(ink.fg(0.12), lineWidth: 1))
// The hairline is DECORATION and sits on top of the cells, so it must never take a touch.
// Spelled out rather than left to defaults, because a swallowed touch in this bar is
// invisible the legend simply stops doing anything.
.overlay(Capsule().strokeBorder(ink.fg(0.12), lineWidth: 1).allowsHitTesting(false))
}
/// A cell is a button where it has somewhere to go, and a plain label otherwise (see the type
/// comment for why tvOS is always the latter).
@ViewBuilder private func cell(_ hint: GamepadHint) -> some View {
#if os(tvOS)
label(hint)
#else
if let action = hint.action {
Button(action: action) { label(hint) }
.buttonStyle(HintCellStyle())
.accessibilityLabel(hint.text)
} else {
label(hint)
}
#endif
}
private func label(_ hint: GamepadHint) -> some View {
HStack(spacing: 7) {
Image(systemName: hint.glyph)
.font(.system(size: metrics.hintGlyphFont))
.foregroundStyle(ink.fg)
Text(hint.text)
}
.fixedSize() // keep glyph + label together; never truncate a hint mid-word
// The tappable area covers the gap between glyph and label, not just their painted
// pixels a legend cell is small enough already.
.contentShape(Rectangle())
}
}
#if !os(tvOS)
/// Press feedback for a legend cell. Deliberately quiet the bar is chrome, and a cell that lit
/// up like a primary button would pull the eye off the content it describes.
///
/// `contentShape` sits BELOW the scale so the hit region stays the unscaled layout bounds: a press
/// animation that shrinks the artwork must never move the target out from under a resting finger,
/// or the touch-up lands outside and SwiftUI discards the tap.
private struct HintCellStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.opacity(configuration.isPressed ? 0.55 : 1)
.scaleEffect(configuration.isPressed ? 0.94 : 1)
.animation(.smooth(duration: 0.14), value: configuration.isPressed)
.contentShape(Rectangle())
}
}
#endif
/// The console backdrop: a living aurora drifting slowly over black so it reads as ambience behind
/// the cards, never as content. On iOS 18 / macOS 15+ it's an animated `MeshGradient` a continuous
/// silk of colour whose control points wander on slow, out-of-phase sinusoids finished with an
@@ -220,14 +480,20 @@ struct GamepadScreenBackground: View {
colorField(at: t, palette: palette)
// ±8° over ~5 min the whole field very slowly warms and cools.
.hueRotation(.degrees(sin(t * 0.021) * 8))
// Calm = col·0.6 + ground·0.4: over the ground, `.opacity` IS the multiply
// Calm = col·0.6 + ground·0.4. Over the OPAQUE ground beneath, `.opacity` already
// lerps toward it, so this layer alone IS the whole calm mix.
.opacity(1 - 0.4 * calmMix)
// and a plusLighter wash of the palette's own ground IS the add. Chosen so the
// ground lands exactly where it was and the bright pools come down to meet it.
// Mounted unconditionally at opacity 0 a plusLighter layer contributes nothing,
// and an always-present layer is what lets the mix animate instead of popping.
// A further plusLighter wash of the ground, which lets a DARK palette's bright pools
// come down to meet its ground rather than merely fading toward it.
//
// Suppressed on a pale palette (the factor goes to 0), because there it was destroying
// the setting: a pale ground is near-white, so ADDING 0.4 of it on top of a field
// already mixed 0.4 toward that same ground saturated the form screens to flat white
// the field ask was "in bright mode the sub-screens are basically just white". Written
// as a factor rather than an `if` so the layer stays mounted and the calm chase keeps
// animating instead of popping when a screen is pushed.
Self.color(palette.ground)
.opacity(0.4 * calmMix)
.opacity(0.4 * calmMix * (palette.light ? 0 : 1))
.blendMode(.plusLighter)
// Cinematic vignette: the edges settle toward the scrim so the cards sit in the
// pooled light. Soft (extends past the frame) so the corners deepen rather than
@@ -363,59 +629,6 @@ private struct LegacyBlobField: View {
}
}
/// A blur gradient behind a pinned tray (a screen title, the hints/detail bar, the keyboard tray):
/// scrollable rows pass beneath those insets, so without this the tray text and the row underneath
/// render interleaved. Pure blur a dark material faded out by a gradient mask, no dark tint so
/// the tray's text sits on a softly blurred backdrop that dissolves into the rows.
struct GamepadTrayScrim: View {
let edge: VerticalEdge
@Environment(\.gamepadInk) private var ink
var body: some View {
let fromEdge: UnitPoint = edge == .top ? .top : .bottom
let toContent: UnitPoint = edge == .top ? .bottom : .top
Rectangle()
.fill(.ultraThinMaterial)
// Force the frost to match the PALETTE, not the system appearance: the tray exists
// to keep the pinned title legible, so it has to frost dark under white ink and
// light under dark ink.
.environment(\.colorScheme, ink.isLight ? .light : .dark)
// Sink the material's grey luminance lift toward the palette's shade (black on a
// dark field field ask: the frost read GREY over the aurora). Inside the mask, so
// the tint dissolves with the blur.
.overlay(ink.shade(0.35))
// Fade the whole blur out toward the content so it dissolves rather than ending on a
// line. The strong region sits deep (0.65) because the first stretch of the gradient
// now runs over the fixed 80 pt outer overhang below.
.mask {
LinearGradient(
stops: [
.init(color: .black, location: 0),
.init(color: .black.opacity(0.92), location: 0.65),
.init(color: .clear, location: 1),
],
startPoint: fromEdge, endPoint: toContent)
}
// Grow past the tray so the fade-to-clear happens OUTSIDE its bounds the tray's own
// text always sits on the strong part, rows blur out before they reach it. The bottom
// gets the longer runway: its tray sits over SCROLLING rows plus the detail line, and
// the field verdict on the short reach was rows colliding visibly with the legend.
.padding(edge == .top ? .bottom : .top, edge == .top ? -44 : -72)
// Full-bleed by LAYOUT, not by `.ignoresSafeArea()`: safe-area expansion resolves a
// beat after insertion (outside any geometry group and outside this view's own
// transaction), which is exactly the pop the field kept seeing vertically first,
// then, once the vertical runway became padding, on the X axis alone (the landscape
// side insets). 80 pt clears every inset on every device; backgrounds never clip,
// so the overhang simply draws.
.padding(edge == .top ? .top : .bottom, -80)
.padding(.horizontal, -80)
// And the shape must NEVER animate: mounted inside a pushed shell layer, any late
// geometry would ride the push's transaction and visibly grow into place. The
// layer's own fade/slide still carries the scrim; only its SHAPE is pinned.
.transaction { $0.animation = nil }
}
}
/// The backdrop for the gamepad UI's form screens (settings, add-host). It used to be a STILL pair
/// of glows over a deep indigo base deliberately not near-black, because Liquid Glass refracts
/// whatever sits behind it and over black the rows turn invisible. It is now the launcher's own
@@ -65,10 +65,24 @@ private struct HomeTile: Identifiable {
struct GamepadHomeView: View {
@Environment(\.gamepadInk) private var ink
/// Published by ContentView at the app ROOT, so this reads its own window's tier this screen
/// applies `gamepadPaletteInk` itself and so sits above its own copy of the environment.
@Environment(\.gamepadMetrics) private var metrics
/// The home-indicator strip's height, measured by DisplayBottomInsetProbe and published from
/// ContentView an environment READ is safe in body; asking UIKit for it here is not (see
/// the probe's comment: a key-window walk mid-render severed this very view's updates).
@Environment(\.displayBottomInset) private var displayBottomInset
@ObservedObject var store: HostStore
@ObservedObject var model: SessionModel
@ObservedObject var discovery: HostDiscovery
@Binding var libraryTarget: StoredHost?
/// The host awaiting a PIN ceremony, if any. Owned by ContentView (a connect attempt sets it,
/// as does the trust card's "Pair with PIN instead"), presented here as a shell screen
/// PairSheet's `Form` is unreachable with a controller on iOS/macOS, which made pairing the
/// one thing a console-UI user simply could not do. See GamepadPairView.
@Binding var pairingTarget: StoredHost?
/// Pin the verified fingerprint and connect ContentView's `handlePaired`.
let onPaired: (StoredHost, Data) -> Void
/// Wake-and-wait driver gates the carousel while its overlay is up, and the carousel's
/// activate routes an offline+wakeable host through it (see ContentView.startSession).
@ObservedObject var waker: HostWaker
@@ -77,6 +91,11 @@ struct GamepadHomeView: View {
/// Launch a library title on a host the in-place library layer's activate path (iOS; the
/// cover/sheet presentations wire ContentView's `launchTitle` into LibraryView themselves).
let launchTitle: (StoredHost, String) -> Void
/// A console prompt (GamepadPromptView) is up over the home it polls the same controller, so
/// this screen must stand down for as long as it is. Same handoff contract as the connect
/// takeover and the shell's own layers; without it the carousel keeps scrolling underneath the
/// modal and a single A press reaches both.
var promptActive = false
/// The profile catalog pinned host+profile combos render as their own tiles here, which is
/// how a controller picks a profile: one focus-and-press instead of a menu (design §5.4).
@@ -213,19 +232,35 @@ struct GamepadHomeView: View {
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
}
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
GamepadHintBar(hints: hints)
// Equal distance from the left and bottom edges the pill's corner inset was the
// real asymmetry (leading 22 vs bottom 10), not its internal padding.
.padding(.leading, compact ? 12 : 18)
.padding(.bottom, compact ? 12 : 18)
.padding(.top, compact ? 4 : 8)
legend
}
}
/// The pinned controls legend, sitting the SAME distance from the leading and bottom edges of
/// the DISPLAY see `gamepadLegendBottomPadding` for why the bottom number is not simply the
/// margin, and why measuring the inset (rather than trying to opt out of it) is what finally
/// worked.
private var legend: some View {
GamepadHintBar(hints: hints)
.padding(.leading, legendMargin)
.padding(
.bottom,
gamepadLegendBottomPadding(
legendMargin, tier: metrics.tier, displayBottom: displayBottomInset))
.padding(.top, compact ? 4 : 8)
}
/// The legend pill's distance from the screen's leading and bottom edges.
private var legendMargin: CGFloat { compact ? 12 : 18 }
#if os(iOS)
/// The screen the shell shows over the launcher derived from the same triggers every
/// platform sets, so `returnToLibrary`, the tiles, X and Y all keep writing what they wrote.
private var topScreen: GamepadScreen? {
// Pairing leads: it is a ceremony blocking a connect the user already asked for, and it
// can be raised from ON TOP of the library (launching a title on an unpaired host), where
// it has to win. Backing out of it reveals whatever it interrupted.
if let host = pairingTarget { return .pair(host) }
if showSettings { return .settings }
if showAddHost { return .addHost }
if let host = libraryTarget { return .library(host) }
@@ -248,6 +283,12 @@ struct GamepadHomeView: View {
onAdd: { store.add($0) },
close: { if !transitioning { showAddHost = false } },
controllerActive: active)
case .pair(let host):
GamepadPairView(
host: host,
onPaired: { onPaired(host, $0) },
close: { if !transitioning { pairingTarget = nil } },
controllerActive: active)
case .library(let host):
GamepadLibraryScreen(
store: store, host: host,
@@ -294,11 +335,14 @@ struct GamepadHomeView: View {
/// transition's input drop, during which NOBODY polls.
private var homeOwnsController: Bool {
#if os(iOS)
topScreen == nil && !transitioning
topScreen == nil && !transitioning && !promptActive
&& waker.waking == nil && model.phase != .connecting
#else
libraryTarget == nil && !showSettings && !showAddHost
&& waker.waking == nil && model.phase != .connecting
// `pairingTarget` too: macOS presents the pair screen as a sheet and tvOS as a cover, and
// either way the launcher underneath must stop consuming the pad the pair screen's own
// list is polling the same controller.
libraryTarget == nil && pairingTarget == nil && !showSettings && !showAddHost
&& !promptActive && waker.waking == nil && model.phase != .connecting
#endif
}
@@ -412,13 +456,22 @@ struct GamepadHomeView: View {
case .rescan: "Rescan"
default: nil
}
// Every cell's action re-resolves the selection when it FIRES rather than closing over the
// one this render saw: the legend is rebuilt on selection changes, but a tap landing in
// the same frame as a carousel move would otherwise activate the tile that was selected a
// moment ago the one failure mode a launcher cannot afford.
var hints = [GamepadHint(
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"),
text: action ?? (selected?.canWake == true ? "Wake & Connect" : "Connect"))]
text: action ?? (selected?.canWake == true ? "Wake & Connect" : "Connect"),
action: { tiles.first { $0.id == selection }?.activate() })]
if libraryEnabled, selected?.hasLibrary == true {
hints.append(.init(glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library"))
hints.append(.init(
glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library",
action: { openLibraryForSelected() }))
}
hints.append(.init(glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Settings"))
hints.append(.init(
glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Settings",
action: { showSettings = true }))
return hints
}
@@ -573,11 +626,21 @@ private struct GamepadHostTile: View {
}
.padding(Self.pad)
.frame(width: size.width, height: size.height, alignment: .leading)
// Liquid Glass console tile a brand wash marks a saved host as primary; discovered /
// Add-Host tiles stay neutral glass with a dashed edge. Glass clips to the shape itself.
// Console tile a brand wash marks a saved host as primary; discovered / Add-Host tiles
// stay neutral with a dashed edge. The surface clips to the shape itself.
//
// `forceMaterial`: these tiles are the one console surface that gets TRANSFORMED while it
// animates `CardEntrance` swings each card in on a `rotation3DEffect` under an opacity
// ramp, and the carousel's `.scrollTransition` keeps scaling and rotating the neighbours
// forever after. Liquid Glass samples the backdrop through its own layer and cannot do
// that under a 3D transform, so it drew one way through the swing and snapped to another
// as the card landed on glass it read as the tiles being swapped out for different ones
// at the end of their entrance. A material composites flat, so the card looks the same at
// every frame of the travel. (tvOS already takes this path for its own reasons.)
.consoleGlass(
RoundedRectangle(cornerRadius: Self.corner, style: .continuous),
tint: tile.filled ? ink.accent(0.20) : nil)
tint: tile.filled ? ink.accent(0.20) : nil,
forceMaterial: true)
.overlay {
RoundedRectangle(cornerRadius: Self.corner, style: .continuous)
.strokeBorder(
@@ -0,0 +1,79 @@
// Hardware-keyboard navigation for the gamepad UI (iOS/iPadOS/macOS): arrows move, Return/Space
// activate, Esc backs out.
//
// Asked for by a field user on an iPad ("select games with keyboard arrows, enter to launch"). An
// iPad on a Magic Keyboard and a couch Mac are the same situation the console layout was built
// for a screen driven from a distance with a fixed set of directional inputs and the whole
// navigation model (a cursor, a confirm, a back) already exists here for the controller. A
// keyboard is just a third input onto it, alongside the pad poll and touch.
//
// tvOS is excluded: the focus engine already routes hardware-keyboard arrows into focus moves
// there, and these screens hand it navigation authority on purpose.
//
// The view must be FOCUSED to receive key presses, so this takes focus on appear. That is safe on
// exactly these screens because the gamepad UI has no system text fields to steal it from
// GamepadKeyboard is a custom grid of keycaps, not a `TextField`.
import PunktfunkKit
import SwiftUI
#if os(iOS) || os(macOS)
extension View {
/// Route arrows / Return / Esc into the same handlers the controller poll drives.
///
/// `active` mirrors the caller's `isActive` controller gate: a screen that has handed the pad
/// to something on top must not keep eating key presses either, or a covered launcher
/// navigates behind the screen in front of it.
func gamepadKeyNavigation(
active: Bool = true,
onMove: @escaping (GamepadMenuInput.Direction) -> Void,
onConfirm: @escaping () -> Void,
onBack: (() -> Void)? = nil
) -> some View {
modifier(GamepadKeyNav(active: active, onMove: onMove, onConfirm: onConfirm, onBack: onBack))
}
}
private struct GamepadKeyNav: ViewModifier {
let active: Bool
let onMove: (GamepadMenuInput.Direction) -> Void
let onConfirm: () -> Void
let onBack: (() -> Void)?
@FocusState private var focused: Bool
func body(content: Content) -> some View {
content
.focusable(active)
// No focus ring: these screens draw their own cursor (the centred card, the focused
// row), and a system ring around the whole scroll view on top of it reads as a bug.
.focusEffectDisabled()
.focused($focused)
// Claim focus on appear, and re-claim it whenever this screen becomes the active one
// again a pushed screen popping off leaves the one underneath unfocused.
.onAppear { focused = active }
.onChange(of: active) { _, nowActive in
if nowActive { focused = true }
}
.onKeyPress(.upArrow) { handle { onMove(.up) } }
.onKeyPress(.downArrow) { handle { onMove(.down) } }
.onKeyPress(.leftArrow) { handle { onMove(.left) } }
.onKeyPress(.rightArrow) { handle { onMove(.right) } }
.onKeyPress(.return) { handle(onConfirm) }
.onKeyPress(.space) { handle(onConfirm) }
.onKeyPress(.escape) {
guard let onBack else { return .ignored }
return handle(onBack)
}
}
/// Run a handler only while this screen owns input, and report back whether the press was
/// consumed. `.ignored` matters: an unhandled Esc still has to reach the `.cancelAction`
/// shortcut that closes a macOS sheet (see GamepadAddHostView's hidden Cancel button).
private func handle(_ action: () -> Void) -> KeyPress.Result {
guard active else { return .ignored }
action()
return .handled
}
}
#endif
@@ -38,7 +38,7 @@ struct GamepadLibraryScreen: View {
.padding(.horizontal, 24)
.padding(.top, gamepadTitleTopPadding(compact: compact))
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
.background { GamepadTrayScrim(edge: .top) }
.background { GamepadTrayBlur(edge: .top) }
}
// A hardware keyboard's Esc still closes, without chrome.
.background {
@@ -119,6 +119,22 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
.sensoryFeedback(.selection, trigger: adjustTick)
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
.sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.7), trigger: boundaryTick)
#if os(iOS) || os(macOS)
// Hardware keyboard: up/down step the focus bar, left/right adjust the focused row's
// value (exactly what the stick does), Return activates, Esc backs out.
.gamepadKeyNavigation(
active: isActive,
onMove: { direction in
switch direction {
case .up: step(by: -1)
case .down: step(by: 1)
case .left: adjust(by: -1)
case .right: adjust(by: 1)
}
},
onConfirm: { activate() },
onBack: onBack)
#endif
.onAppear {
reconcile()
wire()
@@ -0,0 +1,230 @@
// The gamepad UI's answer to a system alert / confirmation dialog (iOS/iPadOS/macOS).
//
// `.alert` and `.confirmationDialog` are UIKit/AppKit surfaces. A game controller cannot move
// through their buttons or press one so on iOS/macOS every prompt in the connect path was a dead
// end for a pad-only user, and they are not incidental prompts:
//
// - "Pairing required" (Request Access / Pair with PIN) is the FIRST thing an unpaired host
// shows. Pairing was unreachable before it even got to the PIN.
// - "Connection failed" strands the console UI behind a modal only a finger can dismiss.
// - "Waiting for approval" owns the only Cancel for a connect that may never complete.
//
// tvOS keeps the system alerts: the focus engine drives them natively there, which is the whole
// reason this gap was tvOS-invisible.
//
// Deliberately NOT built on GamepadMenuList: that is a ScrollView (right for a settings screen of
// unknown length, wrong for two buttons in a card, where it would need an invented height and
// could clip). A prompt has two or three actions, so it owns a plain VStack and a cursor.
import PunktfunkKit
import SwiftUI
#if os(iOS) || os(macOS)
/// One choice in a console prompt.
struct GamepadPromptAction: Identifiable {
let id: String
let title: String
/// This is the action B (and Esc) performs, and the one the cursor opens on. Exactly one
/// action should carry it `GamepadPrompt` falls back to the LAST action when none does,
/// which matches how a system alert treats its cancel role.
var isCancel = false
/// Drawn as the primary, accent-tinted row. At most one.
var isPrimary = false
let run: () -> Void
}
/// A prompt to show over the console UI: what happened, and what can be done about it.
struct GamepadPrompt: Identifiable {
let id: String
let title: String
let message: String
let actions: [GamepadPromptAction]
/// A wait with no outcome yet (the delegated-approval hold) shows a spinner beside the title
/// the prompt is the UI for something still in flight, not a report that it finished.
var busy = false
}
/// The prompt, worn as the console's own modal: a dimmed field, a glass card, a focus list of
/// actions, and the same legend every other gamepad screen carries.
struct GamepadPromptView: View {
@Environment(\.gamepadInk) private var ink
@Environment(\.gamepadMetrics) private var metrics
let prompt: GamepadPrompt
@State private var cursor = 0
@State private var input = GamepadMenuInput(manager: .shared)
@State private var haptics = MenuHaptics(manager: .shared)
/// `.sensoryFeedback` counters device ticks for confirm and for a refused move at an end.
@State private var activateTick = 0
@State private var boundaryTick = 0
#if os(iOS)
@Environment(\.verticalSizeClass) private var vSizeClass
private var compact: Bool { vSizeClass == .compact }
#else
private let compact = false
#endif
var body: some View {
ZStack {
// Swallows touch to the launcher behind it, which is also gated out of the controller
// poll for as long as this is up (ContentView's `promptActive`).
Rectangle()
.fill(.black.opacity(0.55))
.ignoresSafeArea()
.contentShape(Rectangle())
.onTapGesture {}
card
}
.sensoryFeedback(.selection, trigger: cursor)
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
.sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.7), trigger: boundaryTick)
// A prompt is exactly where a keyboard user gets stuck, so it takes arrows/Return/Esc too.
.gamepadKeyNavigation(
onMove: { direction in
switch direction {
case .up: step(by: -1)
case .down: step(by: 1)
case .left, .right: break
}
},
onConfirm: { activate() },
onBack: { back() })
.onAppear {
cursor = prompt.actions.firstIndex(where: \.isCancel) ?? max(prompt.actions.count - 1, 0)
wire()
input.start()
}
// The prompt's identity is stable across a message change (same `id`), so re-wire rather
// than rely on a remount: the stored closures captured the OLD actions array.
.onChange(of: prompt.actions.map(\.id)) { _, _ in
cursor = min(cursor, max(prompt.actions.count - 1, 0))
wire()
}
.onDisappear {
input.stop()
haptics.stop()
}
}
private var card: some View {
VStack(alignment: .leading, spacing: 14) {
HStack(spacing: 10) {
if prompt.busy {
ProgressView().controlSize(.small).tint(ink.fg(0.8))
}
Text(prompt.title)
.font(.geist(compact ? 19 : 22, .bold, relativeTo: .title3))
.foregroundStyle(ink.fg)
}
Text(prompt.message)
.font(.geist(metrics.detailFont, relativeTo: .callout))
.foregroundStyle(ink.fg(0.62))
.fixedSize(horizontal: false, vertical: true)
VStack(spacing: 6) {
ForEach(Array(prompt.actions.enumerated()), id: \.element.id) { idx, action in
actionRow(action, focused: idx == cursor)
.contentShape(Rectangle())
.onTapGesture { tap(idx) }
}
}
.padding(.top, 2)
GamepadHintBar(hints: hints)
}
.padding(compact ? 20 : 26)
.frame(maxWidth: 460)
.consoleGlass(RoundedRectangle(cornerRadius: 24, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 24, style: .continuous)
.strokeBorder(ink.fg(0.12), lineWidth: 1)
}
.padding(24)
}
private func actionRow(_ action: GamepadPromptAction, focused: Bool) -> some View {
let m = metrics
return Text(action.title)
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
.foregroundStyle(action.isPrimary ? ink.accent : ink.fg)
.frame(maxWidth: .infinity)
.padding(.horizontal, m.rowHPad)
.padding(.vertical, m.rowVPad)
.consoleGlass(
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous),
tint: focused ? ink.accent(0.30) : nil,
interactive: focused)
.overlay {
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous)
.strokeBorder(ink.fg(focused ? 0.28 : 0.06), lineWidth: 1)
}
.scaleEffect(focused ? 1.0 : 0.98)
.animation(.smooth(duration: 0.18), value: focused)
}
private var hints: [GamepadHint] {
var hints: [GamepadHint] = [.init(
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Select",
action: { activate() })]
// Only where B has somewhere to go: a one-action prompt ("OK") is dismissed by that
// action, and B does it too naming it twice would just be noise.
if prompt.actions.count > 1, let cancel = prompt.actions.first(where: \.isCancel) {
hints.append(.init(
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: cancel.title,
action: { back() }))
}
return hints
}
// MARK: - Input
private func wire() {
input.onMove = { direction in
switch direction {
case .up: step(by: -1)
case .down: step(by: 1)
// A prompt's actions are a vertical list; left/right have nothing to mean here, and
// silently treating them as up/down would make a nudged stick pick a different button.
case .left, .right: break
}
}
input.onConfirm = { activate() }
input.onBack = { back() }
}
private func step(by delta: Int) {
let target = cursor + delta
guard target >= 0, target < prompt.actions.count else {
boundaryTick &+= 1
haptics.boundary()
return
}
cursor = target
haptics.move()
}
private func activate() {
guard cursor >= 0, cursor < prompt.actions.count else { return }
activateTick &+= 1
haptics.confirm()
prompt.actions[cursor].run()
}
/// B: the cancel action, else the last one the same fallback a system alert applies when
/// nothing carries the cancel role, so B always has a way out rather than doing nothing.
private func back() {
guard let action = prompt.actions.first(where: \.isCancel) ?? prompt.actions.last
else { return }
activateTick &+= 1
haptics.confirm()
action.run()
}
/// Touch fallback matching the rest of the gamepad UI: a tap focuses AND activates.
private func tap(_ idx: Int) {
guard idx >= 0, idx < prompt.actions.count else { return }
cursor = idx
activate()
}
}
#endif
@@ -21,12 +21,14 @@ import SwiftUI
enum GamepadScreen: Identifiable {
case settings
case addHost
case pair(StoredHost)
case library(StoredHost)
var id: String {
switch self {
case .settings: return "settings"
case .addHost: return "addHost"
case .pair(let host): return "pair-\(host.id.uuidString)"
case .library(let host): return "library-\(host.id.uuidString)"
}
}
@@ -35,7 +37,7 @@ enum GamepadScreen: Identifiable {
/// (`Bg::Form` in the console); the library keeps the launcher's full aurora.
var isForm: Bool {
switch self {
case .settings, .addHost: return true
case .settings, .addHost, .pair: return true
case .library: return false
}
}
@@ -125,7 +125,7 @@ struct LibraryCoverflowView: View {
) -> some View {
PosterImage(
candidates: game.art.posterCandidates, title: game.title, loader: artLoader,
onLoaded: { artSettled += 1 })
icon: game.iconToken, onLoaded: { artSettled += 1 })
.frame(width: width, height: height)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay(alignment: .topLeading) {
@@ -204,13 +204,19 @@ struct LibraryCoverflowView: View {
private var hints: [GamepadHint] {
var hints: [GamepadHint] = []
if onLaunch != nil {
if let onLaunch {
// You *open* a launcher and *launch* a game the hint follows the focused entry.
let opens = games.first { $0.id == selection }?.isLauncher == true
hints.append(
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: opens ? "Open" : "Launch"))
hints.append(.init(
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: opens ? "Open" : "Launch",
// Reads `selection` when it fires, not when the legend was built (see the
// launcher's twin) and does nothing with no title centred, which is exactly
// what A does.
action: { if let id = selection { onLaunch(id) } }))
}
hints.append(.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Close"))
hints.append(.init(
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Close",
action: { onDismiss?() }))
return hints
}
}
@@ -27,6 +27,13 @@ struct LibraryView: View {
/// 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?
#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
/// they didn't ask for.
@State private var keyCursor: String?
@State private var gridWidth: CGFloat = 0
#endif
#if os(iOS) || os(macOS) || os(tvOS)
// Gamepad-driven browsing see ContentView's identical gate. With no controller (or the
// setting off) every platform keeps the plain-grid presentation of this same view.
@@ -120,34 +127,103 @@ struct LibraryView: View {
let launchers = games.filter(\.isLauncher)
let titles = games.filter { !$0.isLauncher }
let both = !launchers.isEmpty && !titles.isEmpty
return ScrollView {
VStack(alignment: .leading, spacing: 18) {
if !launchers.isEmpty {
if both { sectionHeader("Launchers") }
tiles(launchers)
return ScrollViewReader { proxy in
ScrollView {
VStack(alignment: .leading, spacing: 18) {
if !launchers.isEmpty {
if both { sectionHeader("Launchers") }
tiles(launchers)
}
if !titles.isEmpty {
if both { sectionHeader("Games") }
tiles(titles)
}
}
if !titles.isEmpty {
if both { sectionHeader("Games") }
tiles(titles)
.padding()
#if os(iOS) || os(macOS)
// The grid's own width, reported without affecting layout a GeometryReader
// SIBLING inside a ScrollView would claim the whole viewport. It's what tells the
// keyboard cursor how many columns `.adaptive` actually produced, so it is only
// measured where that cursor exists.
.background {
GeometryReader { geo in
Color.clear
.onAppear { gridWidth = geo.size.width }
.onChange(of: geo.size.width) { _, w in gridWidth = w }
}
}
#endif
}
.padding()
#if os(iOS) || os(macOS)
// Hardware keyboard: arrows pick a title, Return launches it a field ask from an
// iPad user on a Magic Keyboard. The gamepad UI's coverflow has had this via the
// controller all along; this is the same thing for the plain grid, which is what an
// iPad with a keyboard and NO pad actually sees.
.gamepadKeyNavigation(
active: onLaunch != nil,
onMove: { direction in
guard let next = gridNav(launchers: launchers, titles: titles)
.move(from: keyCursor, direction) else { return }
keyCursor = next
withAnimation(.easeOut(duration: 0.18)) { proxy.scrollTo(next, anchor: .center) }
},
onConfirm: {
guard let onLaunch, let id = keyCursor else { return }
onLaunch(id)
})
#endif
}
}
#if os(iOS) || os(macOS)
/// The keyboard cursor's model over the two grid sections. Rebuilt per press from the live
/// sections so it can never point into a stale list.
private func gridNav(launchers: [GameEntry], titles: [GameEntry]) -> LibraryGridNav {
LibraryGridNav(
sections: [launchers, titles].filter { !$0.isEmpty }.map { $0.map(\.id) },
columns: columnCount)
}
/// How many columns `.adaptive(minimum:spacing:)` fits into the measured width the same
/// arithmetic the layout does, so up/down move exactly one visual row rather than a guess.
/// Falls back to one column before the first measurement lands.
private var columnCount: Int {
let minimum: CGFloat = 130 // matches `columns` below on iOS/macOS
let spacing: CGFloat = 18
// The VStack's `.padding()` is inside the measured width, so take it back off.
let usable = gridWidth - 32
guard usable > 0 else { return 1 }
return max(1, Int((usable + spacing) / (minimum + spacing)))
}
#endif
private func tiles(_ entries: [GameEntry]) -> some View {
LazyVGrid(columns: columns, spacing: 18) {
ForEach(entries) { game in
if let onLaunch {
Button { onLaunch(game.id) } label: { GameCard(game: game, artLoader: artLoader) }
.buttonStyle(.plain)
Button { onLaunch(game.id) } label: {
GameCard(game: game, artLoader: artLoader, selected: isKeyCursor(game))
}
.buttonStyle(.plain)
.id(game.id)
} else {
GameCard(game: game, artLoader: artLoader)
GameCard(game: game, artLoader: artLoader, selected: isKeyCursor(game))
.id(game.id)
}
}
}
}
/// Whether the keyboard cursor is on this tile (always false where there is no keyboard
/// navigation to have moved it).
private func isKeyCursor(_ game: GameEntry) -> Bool {
#if os(iOS) || os(macOS)
keyCursor == game.id
#else
false
#endif
}
private func sectionHeader(_ text: String) -> some View {
Text(text)
.font(.geist(12, .semibold, relativeTo: .caption))
@@ -264,13 +340,24 @@ private struct LibraryBackCatcher: View {
private struct GameCard: View {
let game: GameEntry
let artLoader: LibraryArtLoader?
/// 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
var body: some View {
VStack(alignment: .leading, spacing: 6) {
PosterImage(candidates: game.art.posterCandidates, title: game.title, loader: artLoader)
PosterImage(
candidates: game.art.posterCandidates, title: game.title, loader: artLoader,
icon: game.iconToken)
.aspectRatio(2.0 / 3.0, contentMode: .fit)
.frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.overlay {
if selected {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(.tint, lineWidth: 3)
}
}
.overlay(alignment: .topLeading) {
StoreBadge(label: game.storeLabel, isLauncher: game.isLauncher)
}
@@ -71,6 +71,9 @@ struct PosterImage: View {
let candidates: [URL]
let title: String
let loader: LibraryArtLoader?
/// 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?
/// Fires once this poster has settled art loaded, or every candidate exhausted and the
/// placeholder is what it will be. The gamepad coverflow waits on a few of these before
/// playing its entrance, so the cards swing in carrying artwork rather than grey rectangles.
@@ -121,11 +124,26 @@ struct PosterImage: View {
private var placeholder: some View {
ZStack {
Rectangle().fill(.quaternary)
Text(title)
.font(.geist(17, .semibold, relativeTo: .headline))
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
.padding(8)
// A launcher's brand mark, drawn at poster size and tinted like the text it replaces.
// `scaledToFit` inside a fraction of the card keeps a non-square master (the Steam mark
// is 496×512, Playnite's 1024×1024) in its own aspect ratio rather than stretched.
// Falling back to the title is the pre-icon design, so an unshipped mark loses nothing.
if let mark = launcherIconImage(for: icon) {
GeometryReader { geo in
mark
.resizable()
.scaledToFit()
.foregroundStyle(.secondary)
.frame(width: geo.size.width * 0.44, height: geo.size.height * 0.44)
.frame(width: geo.size.width, height: geo.size.height)
}
} else {
Text(title)
.font(.geist(17, .semibold, relativeTo: .headline))
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
.padding(8)
}
}
}
}
@@ -1,7 +1,8 @@
// Siri / Shortcuts / Spotlight surface (design §M4, extended by client-deep-links.md §6).
// Deliberately thin: every action already has an internal entry point the deep-link router
// (connect / connect-and-launch / connect-with-a-profile), the in-process end-session hook, and
// the existing Wake-on-LAN path so these intents only wrap them.
// (connect / connect-and-launch / connect-with-a-profile, and the `browse` route into a host's
// library), the in-process end-session hook, and the existing Wake-on-LAN path so these
// intents only wrap them.
//
// Connect and Wake compile on macOS and tvOS too: AppIntents is genuinely available there
// (macOS 13+ / tvOS 16+), and "Stream Desktop with Work" from Spotlight on a Mac is part of the
@@ -51,6 +52,28 @@ struct ConnectToHostIntent: AppIntent {
}
}
/// Jump straight into a host's game library no session. Foregrounds the app and routes the
/// `browse` route through the same `.onOpenURL` path a widget tap uses, which drives the one
/// `libraryTarget` every surface shares so the shortcut lands in whichever library presentation
/// the current mode owns: the gamepad console's library screen when the gamepad UI is active, the
/// touch/desktop library otherwise. A session starts only when a title is picked there.
struct OpenLibraryIntent: AppIntent {
static let title: LocalizedStringResource = "Open Game Library"
static let description = IntentDescription(
"Open a host's game library in Punktfunk, without starting a stream.")
static let openAppWhenRun = true
@Parameter(title: "Host") var host: HostEntity
func perform() async throws -> some IntentResult {
let url = DeepLink.browse(host: host.id).url
await MainActor.run {
NotificationCenter.default.post(name: .punktfunkOpenDeepLink, object: url)
}
return .result()
}
}
/// Wake a sleeping host (magic packet). No `openAppWhenRun` usable in automations ("when I get
/// home, wake the tower") without foregrounding the app.
struct WakeHostIntent: AppIntent {
@@ -97,6 +120,13 @@ struct PunktfunkShortcuts: AppShortcutsProvider {
"Stream \(\.$host) with \(.applicationName)",
],
shortTitle: "Connect", systemImageName: "play.tv.fill")
AppShortcut(
intent: OpenLibraryIntent(),
phrases: [
"Open \(\.$host) library in \(.applicationName)",
"Show \(\.$host) games in \(.applicationName)",
],
shortTitle: "Game Library", systemImageName: "square.grid.2x2.fill")
AppShortcut(
intent: WakeHostIntent(),
phrases: [
@@ -43,10 +43,27 @@ enum ScreenshotMode {
/// readiness ping for the capture script.
struct ScreenshotHostView: View {
let scene: ShotScene
#if os(iOS)
@Environment(\.horizontalSizeClass) private var hSizeClass
@Environment(\.verticalSizeClass) private var vSizeClass
#endif
/// The gamepad UI's form-metric tier, published here for the same reason ContentView does it:
/// this harness mounts those screens DIRECTLY, with no ContentView in the tree, so without it
/// an iPad capture renders every gamepad screen at iPhone scale a capture that doesn't look
/// like the app.
private var gamepadMetrics: GamepadFormMetrics {
#if os(iOS)
.forWindow(h: hSizeClass, v: vSizeClass)
#else
.platformDefault
#endif
}
var body: some View {
scene.make()
.environment(\.colorScheme, scene.colorScheme)
.environment(\.gamepadMetrics, gamepadMetrics)
.frame(maxWidth: .infinity, maxHeight: .infinity)
// Black fills the display, but the SCENE keeps its safe area. Ignoring it wholesale
// here pushed the stream hero's HUD under the Dynamic Island (the resolution/bitrate
@@ -242,7 +242,8 @@ private struct ShotGamepadHome: View {
var body: some View {
GamepadHomeView(
store: store, model: model, discovery: discovery,
libraryTarget: .constant(nil), waker: waker,
libraryTarget: .constant(nil), pairingTarget: .constant(nil),
onPaired: { _, _ in }, waker: waker,
connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in })
}
}
@@ -300,7 +301,8 @@ private struct ShotConnect: View {
if gamepadUI {
GamepadHomeView(
store: store, model: model, discovery: discovery,
libraryTarget: .constant(nil), waker: waker,
libraryTarget: .constant(nil), pairingTarget: .constant(nil),
onPaired: { _, _ in }, waker: waker,
connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in })
} else {
ShotHome()
@@ -54,6 +54,15 @@ struct AcknowledgementsView: View {
Divider()
Text("Swift packages")
.font(.geist(Self.headlineFont, .semibold, relativeTo: .headline))
Text("Punktfunk uses Glur (progressive backdrop blur), "
+ "© 2023 João Gabriel, under the MIT License.")
.font(.geist(Self.captionFont, relativeTo: .caption))
.foregroundStyle(.secondary)
Divider()
Text("Third-party software")
.font(.geist(Self.headlineFont, .semibold, relativeTo: .headline))
Text(
@@ -66,22 +66,20 @@ struct GamepadOptionBand: View {
rotation: drumPosition,
target: drumPosition,
// Puts the ±1 neighbour ~40 % of the band off-centre, curling to the edge.
radius: width * 0.72)
radius: width * 0.72,
width: width)
}
}
.frame(width: width)
.clipped()
// Soft edges: the drum dissolves before it reaches the chevrons instead of ending on a cut.
.mask {
LinearGradient(
stops: [
.init(color: .clear, location: 0),
.init(color: .black, location: 0.12),
.init(color: .black, location: 0.88),
.init(color: .clear, location: 1),
],
startPoint: .leading, endPoint: .trailing)
}
// NO `.mask` here. The soft edges used to be a gradient mask over the whole band, and a
// mask RASTERISES what it covers which flattens `rotation3DEffect`'s perspective, so the
// drum was being composited as a flat sideways slide rather than a turning cylinder. That
// is the "3D effect isn't what it should be" the field kept seeing: the geometry was
// always right, and the mask was throwing the projection away every frame.
//
// The same soft edge is folded into each option's own opacity instead (see `Drum.option`),
// which costs nothing and leaves the projection intact.
.onChange(of: selection) { old, new in step(from: old, to: new) }
// The options list itself can mutate under the drum (a custom resolution appears, a
// controller connects, the buffer options re-derive from a new refresh rate) re-seat
@@ -131,6 +129,9 @@ private struct Drum: View, Animatable {
let target: Double
/// Drum radius in points (from the band width see the caller).
let radius: Double
/// The band's own width the stage the options turn on, and what the edge fade is measured
/// against now that the container no longer carries a mask.
let width: Double
var animatableData: Double {
get { rotation }
@@ -140,6 +141,17 @@ private struct Drum: View, Animatable {
/// Angular pitch between adjacent options on the drum.
private static let stepAngle = 34.0 * .pi / 180.0
// Neighbours exist only while the drum is MOVING, and that is not a compromise it is the
// documented field fix this file was written around. Showing them at rest was tried (to make a
// settled row look more like a cylinder) and immediately reproduced the original defect: on the
// simulator, "This device · 2752 × 2064" rendered with "280 ×" sitting on top of it, and
// "Automatic" with "10 Mbps" through it. A long value and its neighbour occupy the same
// pixels, and no opacity low enough to fix that is high enough to be worth drawing.
//
// The cylinder is meant to be READ WHILE IT TURNS. What was actually broken is fixed above:
// the band used to mask itself, and the mask rasterised the drum and threw its perspective
// away every frame, so the turn never looked like a turn.
var body: some View {
let flight = min(1, abs(rotation - target) * 3)
let content = ZStack {
@@ -147,14 +159,18 @@ private struct Drum: View, Animatable {
// Plain signed distance the band is linear, so option i has ONE home and the
// ends are the ends (nothing waits beyond the last option).
let d = Double(i) - rotation
// Only the facing option at rest; its neighbours join it for the travel (see the
// note on `restingNeighbour`'s removal above).
if abs(d) < 0.5 || (flight > 0.001 && abs(d) <= 2.5) {
option(i, distance: d, gate: flight)
}
}
}
#if os(tvOS)
// Flatten the transform stack while travelling the 10-foot GPU already made these
// rows drop Liquid Glass, and five projected texts per step is the same class of cost.
// Flatten the transform stack the 10-foot GPU already made these rows drop Liquid
// Glass, and several projected texts per step is the same class of cost. It costs the
// projection (a rasterised layer has no perspective), which is the trade tvOS already
// makes elsewhere on this screen.
content.drawingGroup()
#else
content
@@ -164,17 +180,30 @@ private struct Drum: View, Animatable {
@ViewBuilder private func option(_ i: Int, distance d: Double, gate: Double) -> some View {
let angle = d * Self.stepAngle
let depth = cos(angle)
let x = radius * sin(angle)
// The facing option never gates: a resting row still shows its value.
let alpha = pow(max(depth, 0), 3) * (abs(d) < 0.5 ? 1 : gate)
let alpha = pow(max(depth, 0), 3) * (abs(d) < 0.5 ? 1 : gate) * edgeFade(x)
Text(options[i])
.lineLimit(1)
.fixedSize() // never let a turning label re-wrap to the band's width mid-flight
.scaleEffect(0.70 + 0.30 * depth)
// Foreshorten the label as it turns away this is what sells the cylinder.
.rotation3DEffect(.radians(angle), axis: (x: 0, y: 1, z: 0), perspective: 0.4)
.offset(x: radius * sin(angle))
.rotation3DEffect(.radians(angle), axis: (x: 0, y: 1, z: 0), perspective: 0.55)
.offset(x: x)
.opacity(alpha)
.zIndex(depth)
}
/// The soft edge, per option, replacing the container mask that used to flatten the
/// projection: full strength through the middle of the band, dissolving to nothing by the
/// time an option reaches its rim, so the drum never ends on a cut.
private func edgeFade(_ x: Double) -> Double {
let halfWidth = width / 2
guard halfWidth > 0 else { return 1 }
let fadeStart = halfWidth * 0.55
guard abs(x) > fadeStart else { return 1 }
return max(0, min(1, (halfWidth - abs(x)) / (halfWidth - fadeStart)))
}
}
#endif
@@ -46,6 +46,8 @@ enum GpSettingsTab: String, CaseIterable, Hashable {
struct GamepadSettingsView: View {
@Environment(\.gamepadInk) private var ink
@Environment(\.gamepadMetrics) private var metrics
@Environment(\.displayBottomInset) private var displayBottomInset
@Environment(\.dismiss) private var dismiss
@Environment(\.gamepadHostedInShell) private var hostedInShell
/// The saved-host store the pin picker writes `setPinned` through it and the profile rows
@@ -142,7 +144,7 @@ struct GamepadSettingsView: View {
isActive: controllerActive
) { row, focused in
rowView(row, focused: focused)
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
.frame(maxWidth: metrics.rowMaxWidth)
.padding(.horizontal, 24)
}
.frame(maxWidth: .infinity)
@@ -161,12 +163,12 @@ struct GamepadSettingsView: View {
}
.padding(.top, gamepadTitleTopPadding(compact: compact))
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
.background { GamepadTrayScrim(edge: .top) }
.background { GamepadTrayBlur(edge: .top) }
}
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 8) {
Text(focusedDetail)
.font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption))
.font(.geist(metrics.detailFont, relativeTo: .caption))
.foregroundStyle(ink.fg(0.55))
.lineLimit(2, reservesSpace: true)
.animation(.smooth(duration: 0.2), value: focusID)
@@ -175,10 +177,13 @@ struct GamepadSettingsView: View {
// Equal distance from the left and bottom edges for the legend pill (see GamepadHomeView).
.padding(.leading, compact ? 12 : 18)
.padding(.trailing, 22)
.padding(.bottom, compact ? 12 : 18)
.padding(
.bottom,
gamepadLegendBottomPadding(
compact ? 12 : 18, tier: metrics.tier, displayBottom: displayBottomInset))
.padding(.top, compact ? 6 : 10)
.frame(maxWidth: .infinity, alignment: .leading)
.background { GamepadTrayScrim(edge: .bottom) }
.background { GamepadTrayBlur(edge: .bottom) }
}
// The launcher's living field, calmed (GamepadFormBackground) the glass rows keep real
// colour and luminance to lens without the launcher's contrast, and the palette setting
@@ -254,10 +259,18 @@ struct GamepadSettingsView: View {
private func pill(_ t: GpSettingsTab) -> some View {
let selected = t == tab
return Text(t.rawValue)
.font(.geist(compact ? 12 : 13, .semibold, relativeTo: .footnote))
.foregroundStyle(selected ? ink.fg : ink.fg(0.55))
.padding(.horizontal, 13)
.padding(.vertical, 7)
.font(.geist(compact ? 12 : metrics.tabFont, .semibold, relativeTo: .footnote))
// `onAccent`, not `fg` the selected pill is FILLED with the palette accent, and
// `onAccent` is the colour picked (by the accent's own luminance) to read on top of
// it; its doc calls out "a filled pill's label" for exactly this surface. Using the
// foreground meant white-on-white wherever a palette's accent is pale: Graphite's is
// a light grey (luma 0.80), so its selected tab was unreadable.
.foregroundStyle(selected ? ink.onAccent : ink.fg(0.55))
// Proportional to the row metrics rather than fixed, so the strip grows with the
// fields under it a tab bar at phone scale above iPad-scale rows was half the
// "does not adapt to larger screens" complaint.
.padding(.horizontal, metrics.rowHPad * 0.8)
.padding(.vertical, metrics.rowVPad * 0.55)
.background {
// One shared capsule that MOVES between pills, rather than one per pill fading
// in and out the highlight travels the way the press did. A Liquid Glass
@@ -328,26 +341,39 @@ struct GamepadSettingsView: View {
// shoulders exist at all (see `showsSectionHint`).
let sections: [GamepadHint] = showsSectionHint
? [.init(glyph: buttonGlyph(\.leftShoulder, fallback: "l1.rectangle.roundedbottom"),
text: "Section")]
text: "Section", action: { step(tabBy: 1) })]
: []
// A dimmed row takes neither, so offering them would be the same lie the row itself
// used to tell only Done remains, and the detail line says what to turn on first.
guard rows.first(where: { $0.id == focusID })?.enabled ?? true else {
return sections
+ [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done")]
+ [.init(
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done",
action: { back() })]
}
return sections + [
// The stick itself, not an action nothing to tap (see GamepadHint.action).
.init(glyph: "arrow.left.and.right", text: "Adjust"),
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change"),
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"),
.init(
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change",
action: { if let focusID { activate(id: focusID) } }),
.init(
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done",
action: { back() }),
]
}
guard !store.hosts.isEmpty else {
return [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back")]
return [.init(
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back",
action: { back() })]
}
return [
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Pin / Unpin"),
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back"),
.init(
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Pin / Unpin",
action: { if let focusID { activate(id: focusID) } }),
.init(
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back",
action: { back() }),
]
}
@@ -365,7 +391,7 @@ struct GamepadSettingsView: View {
// MARK: - Row rendering
private func rowView(_ row: Row, focused: Bool) -> some View {
let m = GamepadFormMetrics.self
let m = metrics
// No section header: the tab strip names the section now, and repeating it above the
// first row of every tab was just a second label saying the same word.
return VStack(alignment: .leading, spacing: 6) {
@@ -443,9 +469,9 @@ struct GamepadSettingsView: View {
/// narrows the stage.
private var bandWidth: CGFloat {
#if os(iOS)
hSizeClass == .compact && vSizeClass == .regular ? 170 : GamepadFormMetrics.bandWidth
hSizeClass == .compact && vSizeClass == .regular ? 170 : metrics.bandWidth
#else
GamepadFormMetrics.bandWidth
metrics.bandWidth
#endif
}
@@ -200,15 +200,16 @@ final class HostStore: ObservableObject {
if let data = try? JSONEncoder().encode(hosts) {
defaults.set(data, forKey: Self.key)
}
reloadHostsWidget() // the widget reads this store; any change refreshes its timeline
reloadHostsWidget() // the widgets read this store; any change refreshes their timelines
}
/// Ask WidgetKit to rebuild the hosts widget's timeline after any store change (add/remove/pin/
/// last-connected). iOS-only and a no-op where WidgetKit is absent; the widget uses
/// `.never`-refresh entries and relies on this push.
/// Ask WidgetKit to rebuild the launcher widgets' timelines after any store change (add/remove/
/// pin/last-connected). iOS-only and a no-op where WidgetKit is absent; both widgets use
/// `.never`-refresh entries and rely on this push.
private func reloadHostsWidget() {
#if canImport(WidgetKit) && os(iOS)
WidgetCenter.shared.reloadTimelines(ofKind: "PunktfunkHosts")
WidgetCenter.shared.reloadTimelines(ofKind: "PunktfunkLibrary")
#endif
}
}
@@ -85,6 +85,12 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
let shape: S
var tint: Color?
var interactive = false
/// Take the MATERIAL path even where real Liquid Glass is available. For surfaces that get
/// transformed while they animate: glass samples the backdrop through its own layer, and under
/// a `rotation3DEffect` / `opacity` it cannot, so it renders one way mid-animation and snaps to
/// another the instant the transform ends on glass that reads as the tile being SWAPPED for a
/// different one as it lands. A material is a flat composite and looks identical throughout.
var forceMaterial = false
/// The console surface follows the background palette: a PALE field needs the material to
/// frost light and the glass to read as white, or the dark ink on top of it disappears.
/// Defaults to the dark ink, so every non-gamepad caller is unchanged.
@@ -117,8 +123,18 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
}
.environment(\.colorScheme, scheme)
#else
if #available(iOS 26, macOS 26, *) {
content.glassEffect(glass, in: shape).environment(\.colorScheme, scheme)
if #available(iOS 26, macOS 26, *), !forceMaterial {
content
// The caller's tint rides HERE, not in `Glass.tint`, so it can ANIMATE. A Glass
// value is opaque to SwiftUI's animation system: changing its tint swaps one
// effect for another, which is why a focused row's accent used to appear (and,
// worse, disappear a beat late) as a hard jump while the row's scale animated
// smoothly beside it. A plain fill interpolates, so `.animation(value: focused)`
// at the call site now covers the whole row. Sits between the glass and the
// content: `.background` is behind the label, `glassEffect` behind both.
.background { shape.fill(tint ?? .clear) }
.glassEffect(glass, in: shape)
.environment(\.colorScheme, scheme)
} else {
content
.background {
@@ -137,13 +153,21 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
#if !os(tvOS)
@available(iOS 26, macOS 26, *)
private var glass: Glass {
// Liquid Glass has ONE tint channel, so the palette wash and the caller's tint share
// it: mixed 60 % toward the caller's (the focused row must still read accented on
// every palette) over the palette base. If device QA finds the mixed focus wash too
// weak, the escape hatch is `tint ?? wash` today's focused look, bit for bit.
let wash = ink.glass(ink.isLight ? 0.60 : 0.45)
var g: Glass = .regular.tint(
tint.map { wash.mix(with: $0, by: 0.6) } ?? wash)
// The glass carries the PALETTE wash only the caller's focus tint is an animatable fill
// above it now (see `body`).
//
// A pale palette gets `.clear` glass, not `.regular`. Its `ink.glass` is literal white, so
// over `.regular` which is already a bright, high-body material even a light white
// wash lands as a flat white slab: the refraction and the blurred field behind stop
// reading entirely, which is the "opaque fully white bg" on every row, pill and legend.
// Lowering the tint alone did NOT fix it, because the opacity was coming from the glass
// BODY rather than from the tint. `.clear` is the variant meant for exactly this a
// surface over content that must stay visible through it and a small white wash on top
// of it is enough to keep the dark ink legible without closing the surface up.
let wash = ink.glass(ink.isLight ? 0.18 : 0.45)
// Spelled out rather than `.clear`/`.regular`: a ternary between two leading-dot members
// gives the compiler no base type to infer from.
var g: Glass = (ink.isLight ? Glass.clear : Glass.regular).tint(wash)
if interactive { g = g.interactive() }
return g
}
@@ -154,8 +178,13 @@ extension View {
/// Liquid Glass for a console surface (a host tile / settings row), or `.ultraThinMaterial`
/// pre-26 both washed with the palette's own glass colour, both frosting to the palette's
/// scheme. Pass the surface's shape explicitly glass defaults to a Capsule.
func consoleGlass<S: Shape>(_ shape: S, tint: Color? = nil, interactive: Bool = false) -> some View {
modifier(ConsoleGlass(shape: shape, tint: tint, interactive: interactive))
///
/// `forceMaterial` opts a TRANSFORMED surface out of live glass; see the property.
func consoleGlass<S: Shape>(
_ shape: S, tint: Color? = nil, interactive: Bool = false, forceMaterial: Bool = false
) -> some View {
modifier(ConsoleGlass(
shape: shape, tint: tint, interactive: interactive, forceMaterial: forceMaterial))
}
}
@@ -0,0 +1,328 @@
// The gamepad-driven PIN pairing screen (iOS/iPadOS/macOS) the controller counterpart of
// PairSheet, and the reason a console-UI user can pair at all.
//
// PairSheet is a `Form` with two `TextField`s. On tvOS the focus engine drives those natively, but
// on iOS/macOS a controller cannot reach a text field, type into it, or press the button
// underneath so for anyone in the console UI, pairing (the ONE thing standing between a fresh
// install and a first stream) ended at "now touch the screen". This screen is the same ceremony
// wearing the gamepad UI's own vocabulary: the vertical focus list from the settings/add-host
// screens, A on a field to open GamepadKeyboard in a bottom tray, B to peel one layer.
//
// Structure deliberately mirrors GamepadAddHostView field for field the two screens are the same
// interaction (a short form, typed with a pad, committed by an action row) and a user who has
// added a host should recognise this immediately. The ceremony itself is shared with PairSheet
// (`PairCeremony`), so the two presentations can never disagree about what a wrong PIN means.
import PunktfunkKit
import SwiftUI
#if os(iOS) || os(macOS)
struct GamepadPairView: View {
@Environment(\.gamepadInk) private var ink
@Environment(\.gamepadMetrics) private var metrics
@Environment(\.displayBottomInset) private var displayBottomInset
@Environment(\.dismiss) private var dismiss
@Environment(\.gamepadHostedInShell) private var hostedInShell
let host: StoredHost
/// Called with the verified host fingerprint after a successful ceremony the caller pins it
/// and connects (ContentView's `handlePaired`).
let onPaired: (Data) -> Void
/// How the in-place shell (iOS) closes this screen; nil (the macOS sheet) falls back to the
/// environment dismiss.
var close: (() -> Void)?
/// Whether this screen owns the controller false while the shell is mid-transition or the
/// connect takeover is up (see GamepadAddHostView's twin).
var controllerActive = true
#if os(iOS)
/// `.compact` in a landscape phone window tighter chrome so the keyboard tray still fits.
@Environment(\.verticalSizeClass) private var vSizeClass
private var compact: Bool { vSizeClass == .compact }
#else
private let compact = false // no size classes on macOS; the sheet is sized to fit the tray
#endif
@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
@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?
var body: some View {
GamepadMenuList(
items: rows,
focusID: $focusID,
onActivate: { activate(id: $0.id) },
onBack: { performClose() },
// A ceremony in flight also takes the list out of the loop: `pair()` blocks on a
// background thread and its result rewrites this screen, so letting B peel a layer
// or A fire a second ceremony underneath it would race the completion.
isActive: controllerActive && editing == nil && !ceremony.busy
) { row, focused in
rowView(row, focused: focused)
.frame(maxWidth: metrics.rowMaxWidth)
.padding(.horizontal, 24)
}
.frame(maxWidth: .infinity)
.safeAreaInset(edge: .top, spacing: 0) {
header
.padding(.horizontal, 24)
.padding(.top, gamepadTitleTopPadding(compact: compact))
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
.frame(maxWidth: .infinity, alignment: .leading)
.background { GamepadTrayBlur(edge: .top) }
}
.safeAreaInset(edge: .bottom, spacing: 0) {
bottomTray
// Equal distance from the left and bottom edges for the legend pill (see
// GamepadHomeView).
.padding(.horizontal, compact ? 12 : 18)
.padding(
.bottom,
gamepadLegendBottomPadding(
compact ? 12 : 18, tier: metrics.tier, displayBottom: displayBottomInset))
.padding(.top, compact ? 6 : 10)
.background { GamepadTrayBlur(edge: .bottom) }
}
// Hosted in the shell, the field is the shell's own (see GamepadAddHostView's twin).
.background {
if !hostedInShell { GamepadFormBackground() }
}
// Publish the palette's ink to this screen (text, glass, accent, scrims) a
// pale palette flips all of them, and no leaf should have to read the setting.
.gamepadPaletteInk()
// A PIN is short; cap it so the row can't grow absurd on a stuck key.
.onChange(of: pin) { _, value in
if value.count > Self.maxPINLength { pin = String(value.prefix(Self.maxPINLength)) }
}
// Any dismissal path abandons an in-flight ceremony a late success must not pin and
// connect to a host the user backed out of.
.onDisappear { ceremony.abandon() }
// The visible close is gone (a gamepad UI exits with B) this keeps a hardware
// keyboard's Esc and the macOS sheet's cancel working without chrome.
.background {
Button("Cancel") { performClose() }
.keyboardShortcut(.cancelAction)
.buttonStyle(.plain)
.frame(width: 0, height: 0)
.opacity(0)
.accessibilityHidden(true)
}
}
/// Generous next to the host's 4 digits: the PIN length is the HOST's business (a future one
/// may well be longer), so this is a runaway guard, not a validator. Rejecting a correct PIN
/// locally would be a far worse failure than sending a wrong one, which the host just refuses.
private static let maxPINLength = 12
private var header: some View {
VStack(alignment: .leading, spacing: gamepadHeaderSpacing(compact: compact)) {
// Leading, like every gamepad heading and no close chrome (B is the exit).
Text("Pair with \(host.displayName)")
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(ink.fg)
.lineLimit(1)
.minimumScaleFactor(0.7)
if !compact {
Text("The PIN is shown in the host's web console (port 47992 → Pairing). "
+ "Pairing verifies both sides at once — no fingerprint comparison needed.")
.font(.geist(metrics.detailFont, relativeTo: .caption))
.foregroundStyle(ink.fg(0.55))
.multilineTextAlignment(.leading)
.frame(maxWidth: metrics.rowMaxWidth * 0.72, alignment: .leading)
}
}
}
/// The keyboard tray while editing, the status line + controls legend otherwise.
@ViewBuilder private var bottomTray: some View {
if let editing {
VStack(spacing: 10) {
GamepadKeyboard(
text: editingBinding(editing),
allowed: allowedCharacters(editing),
onDone: { closeKeyboard() })
// Fresh keyboard per field (see GamepadAddHostView) the tray's input wiring
// captured the previous binding on appear.
.id(editing)
GamepadHintBar(hints: [
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Type"),
.init(
glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Delete",
action: { backspace(editing) }),
.init(
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done",
action: { closeKeyboard() }),
])
.frame(maxWidth: .infinity, alignment: .leading)
}
.transition(.move(edge: .bottom).combined(with: .opacity))
} else {
VStack(alignment: .leading, spacing: 8) {
statusLine
GamepadHintBar(hints: [
.init(
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Select",
action: { if let focusID { activate(id: focusID) } }),
.init(
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Cancel",
action: { performClose() }),
])
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
/// What the ceremony is doing, in the slot the settings screen gives its detail line. Reserves
/// its space so the legend never jumps when a failure arrives.
@ViewBuilder private var statusLine: some View {
Group {
if ceremony.busy {
HStack(spacing: 8) {
ProgressView().controlSize(.small).tint(ink.fg(0.7))
Text("Pairing with \(host.displayName)").foregroundStyle(ink.fg(0.7))
}
} else if let error = ceremony.errorText {
Text(error).foregroundStyle(.red)
} else {
// Placeholder keeps the reserved height honest under `lineLimit(2)`.
Text(" ").foregroundStyle(.clear)
}
}
.font(.geist(metrics.detailFont, relativeTo: .caption))
.lineLimit(2, reservesSpace: true)
.multilineTextAlignment(.leading)
.frame(maxWidth: metrics.rowMaxWidth, alignment: .leading)
.animation(.smooth(duration: 0.2), value: ceremony.errorText)
}
/// Close this screen through whichever mechanism presents it: the shell's layer pop on iOS,
/// the environment dismiss under a macOS sheet.
private func performClose() {
ceremony.abandon()
if let close { close() } else { dismiss() }
}
// MARK: - Rows
private struct Row: Identifiable {
let id: String
let label: String
var value = ""
var placeholder = ""
var isAction = false
}
private var rows: [Row] {
[
Row(id: "pin", label: "PIN", value: pin, placeholder: "Shown in the web console"),
Row(
id: "name", label: "Device name", value: clientName,
placeholder: "How the host lists this device"),
Row(id: "pair", label: "Pair & Connect", isAction: true),
]
}
private func rowView(_ row: Row, focused: Bool) -> some View {
let m = metrics
return HStack(spacing: 14) {
if row.isAction {
Label("Pair & Connect", systemImage: "lock.shield")
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
.foregroundStyle(canPair ? ink.accent : ink.fg(0.35))
.frame(maxWidth: .infinity)
} else {
Text(row.label)
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
.foregroundStyle(ink.fg)
Spacer(minLength: 12)
Text(row.value.isEmpty ? row.placeholder : row.value)
.font(.geistFixed(m.valueFont, .medium))
.foregroundStyle(row.value.isEmpty ? ink.fg(0.35) : ink.fg)
.lineLimit(1)
.truncationMode(.head) // keep the end of a long name visible while typing
if editing == row.id {
// The live-edit caret: this row is what the keyboard tray is typing into.
Rectangle()
.fill(ink.accent)
.frame(width: 2, height: m.labelFont + 2)
}
}
}
.padding(.horizontal, m.rowHPad)
.padding(.vertical, m.rowVPad)
.consoleGlass(
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous),
tint: (focused || editing == row.id) ? ink.accent(0.30) : nil,
interactive: focused)
.overlay {
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous)
.strokeBorder(
editing == row.id ? ink.accent(0.7) : ink.fg(focused ? 0.28 : 0.06),
lineWidth: 1)
}
.scaleEffect(focused ? 1.0 : 0.98)
.animation(.smooth(duration: 0.18), value: focused)
}
// MARK: - Actions
private func activate(id: String) {
guard !ceremony.busy else { return }
switch id {
case "pair":
guard canPair else {
// Not pairable yet jump straight to what's missing instead of a dead press,
// matching the add-host screen's Add row.
focusID = "pin"
openKeyboard("pin")
return
}
ceremony.run(host: host.address, port: host.port, pin: pin, clientName: clientName) {
fingerprint in
onPaired(fingerprint)
// NOT `performClose()`: that abandons the ceremony, and this IS the ceremony's
// success. Closing is all that's left to do.
if let close { close() } else { dismiss() }
}
default:
openKeyboard(id)
}
}
private var canPair: Bool {
!pin.trimmingCharacters(in: .whitespaces).isEmpty && !ceremony.busy
}
private func openKeyboard(_ id: String) {
withAnimation(.spring(response: 0.32, dampingFraction: 0.86)) { editing = id }
}
private func closeKeyboard() {
withAnimation(.spring(response: 0.32, dampingFraction: 0.86)) { editing = nil }
}
private func editingBinding(_ id: String) -> Binding<String> {
id == "pin" ? $pin : $clientName
}
/// The legend's Delete cell see GamepadAddHostView's twin for why this edits the binding
/// rather than reaching into the keyboard.
private func backspace(_ id: String) {
let binding = editingBinding(id)
guard !binding.wrappedValue.isEmpty else { return }
binding.wrappedValue.removeLast()
}
/// What the keyboard may type per field: a PIN is digits; a device name is free-form.
private func allowedCharacters(_ id: String) -> CharacterSet? {
id == "pin" ? CharacterSet(charactersIn: "0123456789") : nil
}
}
#endif
@@ -0,0 +1,86 @@
// The SPAKE2 PIN ceremony itself, with no opinion about how it's presented. Two screens run it:
// `PairSheet` (the touch/desktop Form, and tvOS's focus-engine layout) and `GamepadPairView` (the
// controller-driven console screen). The ceremony is the part that must not diverge between them
// it decides what counts as a wrong PIN, what a rejection means, and which failures are worth
// telling the user apart so it lives here once rather than being copied into the second caller.
//
// Threading: `pair()` and the identity load both BLOCK, so they run off the main actor; every
// published mutation lands back on it.
import Foundation
import PunktfunkKit
import SwiftUI
@MainActor
final class PairCeremony: ObservableObject {
/// A ceremony is in flight callers disable their commit action and show a spinner.
@Published private(set) var busy = false
/// The last failure, in user-facing terms; cleared when a new attempt starts.
@Published var errorText: String?
/// Dismissing the presenting screen must abandon an in-flight ceremony: the blocking `pair()`
/// call can't be interrupted, so its completion checks this token and self-discards a late
/// success must NOT pin and auto-connect to a host the user cancelled out of. A fresh token
/// per attempt, so abandoning one attempt can't silence the next.
private var token = Token()
private final class Token: @unchecked Sendable {
var cancelled = false
}
/// Run the ceremony. `onPaired` receives the host's now-VERIFIED fingerprint the caller pins
/// it and connects; no manual fingerprint comparison is needed, because the host proved itself
/// with the same PIN.
func run(
host address: String, port: UInt16, pin rawPIN: String, clientName rawName: String,
onPaired: @escaping (Data) -> Void
) {
busy = true
errorText = nil
let pin = rawPIN.trimmingCharacters(in: .whitespaces)
let name = rawName.trimmingCharacters(in: .whitespaces)
token = Token()
let token = token
Task.detached(priority: .userInitiated) {
// Identity load + the ceremony both block keep them off the main actor.
// loadForPairing is the strict variant: the host durably trusts this
// identity, so it must have made it into the Keychain.
let result = Result {
let identity = try ClientIdentityStore.shared.loadForPairing()
return try PunktfunkKit.pair(
host: address, port: port, identity: identity,
pin: pin, name: name.isEmpty ? "Mac" : name)
}
await MainActor.run {
guard !token.cancelled else { return } // screen dismissed mid-ceremony
self.busy = false
switch result {
case .success(let fingerprint):
onPaired(fingerprint)
case .failure(PunktfunkClientError.wrongPIN):
self.errorText = "Wrong PIN — check the host's web console (port 47992) "
+ "and try again."
case .failure(PunktfunkClientError.rejected(let rejection)):
// The host answered and said why (not armed / rate-limited / armed for
// another device) show that instead of the guessing-game fallback.
self.errorText = rejection.userMessage
case .failure(is ClientIdentityStore.IdentityError):
self.errorText = "Can't store this Mac's identity in the Keychain, so the "
+ "pairing would not survive a relaunch. Unlock the login "
+ "keychain and try again."
case .failure:
self.errorText = "Pairing failed — the host didn't answer. Is it running, "
+ "and is this device on the same network (no VPN, no guest-Wi-Fi "
+ "isolation)?"
}
}
}
}
/// The presenting screen went away discard whatever is still in flight. Called from every
/// dismissal path (an explicit Cancel, a swipe, B on a controller), which is why it is safe to
/// call when nothing is running.
func abandon() {
token.cancelled = true
}
}
@@ -5,19 +5,15 @@
// host rate-limits ceremonies to one per 2 s). Success returns the host's now-VERIFIED
// fingerprint: the caller pins it, no manual comparison needed, and the host stores this
// client's identity in return.
//
// This is the TOUCH/desktop presentation (and tvOS's, where the focus engine drives the same
// fields). A controller can't reach a `Form`'s text fields on iOS/macOS, so the console UI
// presents `GamepadPairView` instead same ceremony, via the shared `PairCeremony`.
import Foundation
import PunktfunkKit
import SwiftUI
/// Dismissing the sheet must abandon an in-flight ceremony: the blocking pair() call
/// can't be interrupted, so its completion checks this flag and self-discards a late
/// success must NOT pin and auto-connect to a host the user cancelled out of. Only
/// touched on the main actor.
private final class CeremonyToken: @unchecked Sendable {
var cancelled = false
}
struct PairSheet: View {
@Environment(\.dismiss) private var dismiss
let host: StoredHost
@@ -30,9 +26,10 @@ struct PairSheet: View {
#else
@State private var clientName = UIDevice.current.name
#endif
@State private var busy = false
@State private var errorText: String?
@State private var token = CeremonyToken()
@StateObject private var ceremony = PairCeremony()
private var busy: Bool { ceremony.busy }
private var errorText: String? { ceremony.errorText }
#if os(tvOS)
private enum EditField: String, Identifiable {
case pin, clientName
@@ -64,7 +61,7 @@ struct PairSheet: View {
}
HStack(spacing: 32) {
Button("Cancel", role: .cancel) {
token.cancelled = true
ceremony.abandon()
dismiss()
}
if busy {
@@ -78,7 +75,7 @@ struct PairSheet: View {
.frame(maxWidth: 1000)
.padding(60)
.navigationTitle("Pair with \(host.displayName)")
.onDisappear { token.cancelled = true }
.onDisappear { ceremony.abandon() }
.fullScreenCover(item: $editing) { field in
switch field {
case .pin:
@@ -142,7 +139,7 @@ struct PairSheet: View {
#endif
HStack {
Button("Cancel", role: .cancel) {
token.cancelled = true
ceremony.abandon()
dismiss()
}
#if !os(tvOS)
@@ -180,7 +177,7 @@ struct PairSheet: View {
.presentationDragIndicator(busy ? .hidden : .visible)
#endif
.interactiveDismissDisabled(busy)
.onDisappear { token.cancelled = true } // any other dismissal path
.onDisappear { ceremony.abandon() } // any other dismissal path
#endif
}
@@ -195,47 +192,11 @@ struct PairSheet: View {
}
private func runCeremony() {
busy = true
errorText = nil
let pin = pin.trimmingCharacters(in: .whitespaces)
let name = clientName.trimmingCharacters(in: .whitespaces)
let address = host.address
let port = host.port
let token = token
Task.detached(priority: .userInitiated) {
// Identity load + the ceremony both block keep them off the main actor.
// loadForPairing is the strict variant: the host durably trusts this
// identity, so it must have made it into the Keychain.
let result = Result {
let identity = try ClientIdentityStore.shared.loadForPairing()
return try PunktfunkKit.pair(
host: address, port: port, identity: identity,
pin: pin, name: name.isEmpty ? "Mac" : name)
}
await MainActor.run {
guard !token.cancelled else { return } // sheet dismissed mid-ceremony
busy = false
switch result {
case .success(let fingerprint):
onPaired(fingerprint)
dismiss()
case .failure(PunktfunkClientError.wrongPIN):
errorText = "Wrong PIN — check the host's web console (port 47992) "
+ "and try again."
case .failure(PunktfunkClientError.rejected(let rejection)):
// The host answered and said why (not armed / rate-limited / armed for
// another device) show that instead of the guessing-game fallback.
errorText = rejection.userMessage
case .failure(is ClientIdentityStore.IdentityError):
errorText = "Can't store this Mac's identity in the Keychain, so the "
+ "pairing would not survive a relaunch. Unlock the login "
+ "keychain and try again."
case .failure:
errorText = "Pairing failed — the host didn't answer. Is it running, "
+ "and is this device on the same network (no VPN, no guest-Wi-Fi "
+ "isolation)?"
}
}
ceremony.run(
host: host.address, port: host.port, pin: pin, clientName: clientName
) { fingerprint in
onPaired(fingerprint)
dismiss()
}
}
}
@@ -1,6 +1,12 @@
// Trust-on-first-use prompt: shown over the live-but-blurred stream when connecting to an
// unpinned host. The user compares the fingerprint with the one the host logged at startup,
// or drops this and runs the PIN pairing ceremony instead.
//
// Controller-drivable on iOS/macOS (A trust, B cancel, X pair instead). It had no controller
// wiring at all, which made it a dead end for a pad-only user at the worst possible moment: the
// card appears mid-connect with capture disabled (ContentView blurs the stream and stops
// forwarding), so the pad in their hands genuinely did nothing and the only way past was to reach
// for the screen. tvOS needs none of this the focus engine drives the buttons natively.
import Foundation
import PunktfunkKit
@@ -13,6 +19,12 @@ struct TrustCardView: View {
let onTrust: () -> Void
let onPairInstead: () -> Void
#if os(iOS) || os(macOS)
/// Observed so the legend appears the moment a pad wakes up mid-prompt and so it stays
/// absent for the mouse/touch users this card is otherwise for.
@ObservedObject private var gamepads = GamepadManager.shared
#endif
var body: some View {
VStack(spacing: 14) {
Image(systemName: "lock.shield")
@@ -60,12 +72,35 @@ struct TrustCardView: View {
.buttonStyle(.borderless)
#endif
.font(.geist(16, relativeTo: .callout))
#if os(iOS) || os(macOS)
// Only with a pad attached: controller glyphs in front of a trackpad user would be
// naming buttons they don't have.
if gamepads.active != nil {
GamepadHintBar(hints: [
.init(
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Trust",
action: onTrust),
.init(
glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Pair with PIN",
action: onPairInstead),
.init(
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Cancel",
action: onCancel),
])
.padding(.top, 2)
}
#endif
}
.padding(28)
.frame(maxWidth: 440)
// Floating trust card over the blurred stream Liquid Glass on 26+, .regularMaterial
// fallback below. The inner fingerprint box stays .quaternary (content, not glass).
.glassBackground(RoundedRectangle(cornerRadius: 18))
#if os(iOS) || os(macOS)
.background {
TrustControllerInput(onTrust: onTrust, onCancel: onCancel, onPairInstead: onPairInstead)
}
#endif
}
/// 64 hex chars four groups per line, two lines easy to eyeball against the log.
@@ -80,6 +115,35 @@ struct TrustCardView: View {
}
}
#if os(iOS) || os(macOS)
/// Controller binding for the trust prompt: A trusts, B cancels, X runs the PIN ceremony instead.
/// The same zero-size-backing-view shape as `ConnectOverlay`'s `ConnectControllerInput` mounted
/// for exactly as long as the card is up, and `GamepadMenuInput`'s snapshot-on-start swallows
/// whatever button was still held when it appeared (the A press that started the connect is
/// usually still down).
///
/// Nothing else is polling the pad here: capture is off for the duration of the prompt, and the
/// home screens are unmounted behind the session view.
private struct TrustControllerInput: View {
let onTrust: () -> Void
let onCancel: () -> Void
let onPairInstead: () -> Void
@State private var input = GamepadMenuInput(manager: .shared)
var body: some View {
Color.clear
.frame(width: 0, height: 0)
.onAppear {
input.onConfirm = onTrust
input.onBack = onCancel
input.onTertiary = onPairInstead
input.start()
}
.onDisappear { input.stop() }
}
}
#endif
private extension Array {
func chunks(of size: Int) -> [[Element]] {
stride(from: 0, to: count, by: size).map { Array(self[$0..<Swift.min($0 + size, count)]) }
@@ -90,6 +90,10 @@ public final class SessionAudio {
/// Token for the media-services-reset observer the audio server restarting takes the
/// session's configuration and every engine with it. Guarded by `stateLock`.
private var mediaResetObserver: NSObjectProtocol?
/// Token for the interruption observer a phone call or a non-mixable app stops the engines,
/// and ending the interruption restarts nothing by itself (see
/// `installInterruptionObserver`). Guarded by `stateLock`.
private var interruptionObserver: NSObjectProtocol?
#endif
// MARK: - Device changes (see `installDeviceChangeRecovery`)
@@ -219,9 +223,17 @@ public final class SessionAudio {
// make a headset's MIC usable, but it buys that by dragging the whole route onto
// HFP/SCO and collapsing game audio to narrowband. High-quality A2DP output plus
// the built-in mic is the better trade for a game-streaming client.
// `.mixWithOthers`, both branches: without it this session is EXCLUSIVE merely
// activating it paused the user's Music at connect, and Music's RESUME took the
// session right back, which read as "stream audio stops when I resume Music"
// (field report; the interruption observer below is the other half of that fix).
// A game stream mixing over someone's playlist is the behavior a console has,
// and what this client's peers do. The trade is real but right: a mixable
// session is nobody's Now Playing app, so the lock screen shows the music, not
// the stream which is exactly how it should read.
try session.setCategory(
.playAndRecord, mode: .default,
options: [.allowBluetoothA2DP])
options: [.allowBluetoothA2DP, .mixWithOthers])
// Uplink latency: ask for 5 ms IO quanta at the wire rate (the default ~10-23 ms
// quantum is most of the mic path's burst latency). Best-effort the hardware
// has the final word (a Bluetooth route will ignore both), and whatever quantum
@@ -229,10 +241,10 @@ public final class SessionAudio {
try? session.setPreferredIOBufferDuration(0.005)
try? session.setPreferredSampleRate(48_000)
} else {
try session.setCategory(.playback, mode: .default)
try session.setCategory(.playback, mode: .default, options: [.mixWithOthers])
}
#else // tvOS no app-accessible mic
try session.setCategory(.playback, mode: .default)
try session.setCategory(.playback, mode: .default, options: [.mixWithOthers])
#endif
try session.setActive(true)
#if os(iOS)
@@ -396,6 +408,8 @@ public final class SessionAudio {
routeObserver = nil
let mediaReset = mediaResetObserver
mediaResetObserver = nil
let interruption = interruptionObserver
interruptionObserver = nil
#endif
stateLock.unlock()
// Every watcher goes before the engines do: a device change landing during teardown must
@@ -406,10 +420,13 @@ public final class SessionAudio {
#if !os(macOS)
if let route { NotificationCenter.default.removeObserver(route) }
if let mediaReset { NotificationCenter.default.removeObserver(mediaReset) }
if let interruption { NotificationCenter.default.removeObserver(interruption) }
#endif
tearDownEngines()
#if !os(macOS)
// Release the session so audio we interrupted (Music, podcasts) gets its resume cue. Like
// Release the session. (A mixable session interrupts nobody, so the resume cue below is
// now a courtesy for the edge where an OLD non-mixable install interrupted something
// harmless either way, and deactivating promptly is still what orders a reconnect.) Like
// activation, setActive is synchronous/blocking run it on the shared serial session queue
// (off the main thread). Enqueued HERE engines already stopped, and BEFORE the drain wait
// below so across a reconnect it lands ahead of the next session's activate on the shared
@@ -493,6 +510,7 @@ public final class SessionAudio {
#if !os(macOS)
installRouteObserver()
installMediaResetObserver(micEnabled: micEnabled)
installInterruptionObserver(micEnabled: micEnabled)
#endif
}
@@ -654,6 +672,38 @@ public final class SessionAudio {
stateLock.unlock()
if let stale { NotificationCenter.default.removeObserver(stale) }
}
/// Interruptions still happen to a mixable session a phone call, Siri, an app that claims
/// a NON-mixable session of its own. iOS stops the engines, and when the interruption ends it
/// restarts NOTHING by itself; before this observer the stream just stayed silent (under the
/// old exclusive category, Music itself was such an interrupter, which is how "resume Music,
/// lose the stream" was ever possible). Reactivate and revive on `.ended` unconditionally,
/// not only when iOS hints `.shouldResume`: a live stream is the one case where the user's
/// intent to keep hearing it is not in doubt, and `reviveStoppedEngines` already declines
/// when playback never went down.
private func installInterruptionObserver(micEnabled: Bool) {
let observer = NotificationCenter.default.addObserver(
forName: AVAudioSession.interruptionNotification,
object: AVAudioSession.sharedInstance(), queue: nil
) { [weak self] note in
guard let raw = note.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt,
AVAudioSession.InterruptionType(rawValue: raw) == .ended else { return }
SessionAudio.sessionQueue.async {
guard let self, !self.flag.isStopped else { return }
// The full activation, not a bare `setActive`: an interruption can drop the
// category configuration too, and on iOS the earpiece steer is per-route.
self.activateAudioSession(micEnabled: micEnabled)
DispatchQueue.main.async {
self.reviveStoppedEngines("an audio interruption ended")
}
}
}
stateLock.lock()
let stale = interruptionObserver
interruptionObserver = observer
stateLock.unlock()
if let stale { NotificationCenter.default.removeObserver(stale) }
}
#endif
/// Silence the mic uplink (no room audio leaves the device) or restore it. THE one muting
@@ -47,12 +47,28 @@ public struct GameEntry: Codable, Hashable, Identifiable, Sendable {
/// optional String: the host owns the vocabulary, and an unknown future value must never fail
/// the whole library decode. Anything that isn't `"launcher"` is a game (design D4).
public var role: String?
/// The token for this entry's brand mark (`"steam"`, `"heroic"`) never art, never a URL.
/// `nil` on every older host and on every ordinary title. See `launcherIconImage`.
public var icon: String?
public var isCustom: Bool { store == "custom" }
/// Whether this entry opens a launcher rather than a game.
public var isLauncher: Bool { role == "launcher" }
/// The brand-icon token, re-validated rather than taken on trust.
///
/// The host checks the shape on the way in, so this can only fire for a host older than that
/// check or one that isn't ours. The value reaches `Image(named:)`, and "the peer promised" is
/// not the standard a name lookup deserves.
public var iconToken: String? {
guard let t = icon, !t.isEmpty, t.count <= 32,
let first = t.first, first.isASCII, first.isLowercase,
t.allSatisfy({ $0.isASCII && ($0.isLowercase || $0.isNumber || $0 == "-") })
else { return nil }
return t
}
/// Display name for the store badge the same table the Rust clients use
/// (`pf-console-ui::library::store_label`). Before this existed the badge said "Steam" for
/// every non-custom entry, which a Lutris or GOG title made a lie.
@@ -1002,22 +1002,34 @@ public final class PunktfunkConnection {
/// Pull the next EFFECTIVE rumble command from the core's shared rumble policy engine the
/// uniform replacement for per-platform rumble policy. The engine owns every decision
/// (v2 lease expiry, legacy-host staleness at a uniform 1 s, connection-close drain zeros),
/// so apply commands verbatim: `(0, 0)` = stop now, non-zero = run at this level.
/// so apply commands verbatim: all-zero = stop now, non-zero = run at this level.
/// `backstopMs` is a safety-net duration for duration-parameterized platform APIs the
/// CoreHaptics renderer ignores it (its finite segment ceiling is the equivalent net).
/// Drain from the (single) feedback thread, alongside `nextHidOutput`.
///
/// A command carries FOUR motor levels: the two handles plus the two Xbox impulse-trigger
/// motors (`leftTrigger`/`rightTrigger`, same 0...0xFFFF scale), which arrive on the 0xCA
/// plane's v3 tail. This calls the core's `_cmd2` entry point `_cmd` is the frozen
/// two-handle form kept for out-of-tree embedders, and there is no reason for this client to
/// stay on it: a pad that reports no `GCHapticsLocality.leftTrigger`/`.rightTrigger` simply
/// has no engine for those levels and they go nowhere, which is the normal case.
public func nextRumbleCommand(timeoutMs: UInt32 = 0) throws
-> (pad: UInt16, low: UInt16, high: UInt16, backstopMs: UInt32)?
-> (
pad: UInt16, low: UInt16, high: UInt16, leftTrigger: UInt16, rightTrigger: UInt16,
backstopMs: UInt32
)?
{
feedbackLock.lock()
defer { feedbackLock.unlock() }
guard let h = liveHandle() else { throw PunktfunkClientError.closed }
var pad: UInt16 = 0, low: UInt16 = 0, high: UInt16 = 0, backstop: UInt32 = 0
let rc = punktfunk_connection_next_rumble_cmd(h, &pad, &low, &high, &backstop, timeoutMs)
var lt: UInt16 = 0, rt: UInt16 = 0
let rc = punktfunk_connection_next_rumble_cmd2(
h, &pad, &low, &high, &lt, &rt, &backstop, timeoutMs)
switch rc {
case statusOK:
return (pad, low, high, backstop)
return (pad, low, high, lt, rt, backstop)
case statusNoFrame:
return nil
case statusClosed:
@@ -172,7 +172,8 @@ public final class GamepadFeedback {
while rumbleBurst < 64, !flag.isStopped,
let c = try connection.nextRumbleCommand(timeoutMs: 0) {
self?.routeRumble(
pad: UInt8(truncatingIfNeeded: c.pad), low: c.low, high: c.high)
pad: UInt8(truncatingIfNeeded: c.pad), low: c.low, high: c.high,
leftTrigger: c.leftTrigger, rightTrigger: c.rightTrigger)
rumbleBurst += 1
}
// Drain a BOUNDED burst of hidout events so sustained 0xCD traffic (a game writing
@@ -225,12 +226,21 @@ public final class GamepadFeedback {
/// Route one engine command to its pad's renderer (drain thread). A command for a pad with no
/// live renderer one that just left the forwarded set is dropped.
private func routeRumble(pad: UInt8, low: UInt16, high: UInt16) {
private func routeRumble(
pad: UInt8, low: UInt16, high: UInt16, leftTrigger: UInt16, rightTrigger: UInt16
) {
let renderer = withRouting { rumbleByPad[pad] }
renderer?.apply(low: low, high: high)
renderer?.apply(low: low, high: high, leftTrigger: leftTrigger, rightTrigger: rightTrigger)
// The opt-in device mirror follows controller 1 unconditionally the pads it exists for
// have no motors (their renderer above no-ops), and mirroring deliberately isn't gated on
// that: capability probing can't see a motor-less MFi pad, and the user opted in.
//
// HANDLES ONLY, deliberately. A phone body is one actuator with no trigger analogue, so
// the trigger levels would have to be folded to arrive at all and folding continuous
// impulse-trigger content (a racing title's engine RPM / tyre slip) onto the one motor
// this mirror has would buzz the phone flat-out for the whole race at a level the game
// never requested. Dropping them matches the core engine's policy for every pad without
// trigger motors.
if pad == 0 { deviceRumble?.apply(low: low, high: high) }
}
@@ -0,0 +1,109 @@
// Button glyphs for the gamepad UI's legends, for a controller that ISN'T currently attached.
//
// While a pad is connected the truth is GameController's own `sfSymbolsName` on the live element
// nothing here competes with that. The problem this file solves is the other half of the time: the
// instant `GamepadManager.active` goes nil (the pad slept, its battery died, it was unplugged, or
// `gamepadUIMode == "always"` put the console UI up with no pad at all) there is no element left to
// ask, and every legend fell back to the generic letter glyphs which read as an Xbox pad. A
// DualSense user watched their / legends turn into A/B the moment the controller dozed off.
//
// So: `GamepadManager` remembers the KIND of the last controller that was actually attached
// (`DefaultsKey.lastGamepadKind`, never cleared on disconnect) and the legends resolve through this
// table instead. Deliberately NOT a user-facing setting a "glyph style" picker is one more row in
// a settings screen to answer a question the app can answer itself, and the remembered pad is right
// essentially always: people own the controller they last plugged in.
//
// Positional, not nominal. `GCExtendedGamepad`'s buttonA/B/X/Y are POSITIONS (A = bottom, B =
// right, X = left, Y = top), so each family maps its own labels onto those positions which is why
// the Nintendo column looks transposed: a Switch pad's bottom button is B and its right one is A.
import Foundation
import GameController
/// A face/shoulder button by POSITION, which is what `GCExtendedGamepad` exposes and what a legend
/// actually means ("press the bottom button"). The label drawn for it is the family's business.
public enum GamepadButtonRole: Sendable {
/// Bottom face button Xbox A, PlayStation , Nintendo B.
case a
/// Right face button Xbox B, PlayStation , Nintendo A.
case b
/// Left face button Xbox X, PlayStation , Nintendo Y.
case x
/// Top face button Xbox Y, PlayStation , Nintendo X.
case y
case leftShoulder
case rightShoulder
/// The role a `GCExtendedGamepad` key path names, so a caller that already spells its buttons
/// as key paths (every legend in the gamepad UI does it reads `sfSymbolsName` off the live
/// element through one) can reach this table without restating itself. nil for any other
/// button: the legends only ever name these six, and a role invented for, say, the menu button
/// would have no honest glyph on half the families.
///
/// Compared with `==` rather than matched with `switch`: key paths are reference-typed and
/// their pattern-matching goes through the generic `Equatable` `~=`, which is easy to send to
/// an unintended overload. This spelling has exactly one meaning.
public init?(keyPath: KeyPath<GCExtendedGamepad, GCControllerButtonInput>) {
if keyPath == \GCExtendedGamepad.buttonA { self = .a }
else if keyPath == \GCExtendedGamepad.buttonB { self = .b }
else if keyPath == \GCExtendedGamepad.buttonX { self = .x }
else if keyPath == \GCExtendedGamepad.buttonY { self = .y }
else if keyPath == \GCExtendedGamepad.leftShoulder { self = .leftShoulder }
else if keyPath == \GCExtendedGamepad.rightShoulder { self = .rightShoulder }
else { return nil }
}
}
public enum GamepadGlyphs {
/// The SF Symbol a `role` wears on a `kind` of pad. Every name here is asserted to resolve on
/// the running OS by `GamepadGlyphTests` a symbol name that doesn't exist renders as NOTHING
/// (SwiftUI draws an empty image rather than failing), so a typo would silently blank a legend
/// on real hardware and never show up in a build.
public static func symbol(_ role: GamepadButtonRole, for kind: PunktfunkConnection.GamepadType)
-> String {
switch role {
case .leftShoulder: return "l1.rectangle.roundedbottom"
case .rightShoulder: return "r1.rectangle.roundedbottom"
case .a, .b, .x, .y: return faceSymbol(role, for: kind)
}
}
private static func faceSymbol(
_ role: GamepadButtonRole, for kind: PunktfunkConnection.GamepadType
) -> String {
switch kind {
// PlayStation shapes. is the BOTTOM button, so it belongs to role `.a` the mapping
// people mean when they say "the PlayStation glyphs".
case .dualSense, .dualSenseEdge, .dualShock4:
switch role {
case .a: return "xmark.circle"
case .b: return "circle.circle"
case .x: return "square.circle"
case .y: return "triangle.circle"
default: return "circle.circle"
}
// Nintendo's labels sit transposed on the same positions (bottom = B, right = A,
// left = Y, top = X) printing Xbox letters on a Switch pad would name the wrong
// physical button, which is worse than a generic glyph.
case .switchPro:
switch role {
case .a: return "b.circle"
case .b: return "a.circle"
case .x: return "y.circle"
case .y: return "x.circle"
default: return "a.circle"
}
// Xbox, the Steam pads (Deck included its ABXY is the Xbox layout), and `.auto`, which
// is what a client with no remembered pad has. Xbox letters double as the neutral default
// because they ARE the positional names in `GCExtendedGamepad`.
case .auto, .xbox360, .xboxOne, .steamController, .steamDeck, .steamController2:
switch role {
case .a: return "a.circle"
case .b: return "b.circle"
case .x: return "x.circle"
case .y: return "y.circle"
default: return "a.circle"
}
}
}
}
@@ -87,6 +87,17 @@ public final class GamepadManager: ObservableObject {
/// `lowest_free_index`). Recomputed by `assignPadIndices` whenever `forwarded` changes.
private var padIndexByController: [ObjectIdentifier: UInt8] = [:]
/// The kind of the last controller that was actually attached persisted under
/// `DefaultsKey.lastGamepadKind` and deliberately NEVER cleared on disconnect. The gamepad
/// UI's legends read it (through `GamepadGlyphs`) whenever `active` is nil, so a DualSense
/// user's / hints don't turn into A/B the moment the pad sleeps, and so the legends are
/// right at all under `gamepadUIMode == "always"`, which puts the console UI up with no pad
/// attached by design. `.auto` = nothing has ever been seen on this device ( neutral glyphs).
///
/// @Published so the legends re-render when a pad of a different family arrives; the screens
/// already observe this object for `active`.
@Published public private(set) var lastKnownKind: PunktfunkConnection.GamepadType
/// The user's pinned controller fingerprint ("" = automatic). Persisted; updating it
/// reselects immediately, so a Settings Picker can bind straight to this.
@Published public var preferredID: String {
@@ -97,12 +108,19 @@ public final class GamepadManager: ObservableObject {
}
private static let preferredKey = DefaultsKey.gamepadID
private static let lastKindKey = DefaultsKey.lastGamepadKind
/// Connect order (identity-keyed) drives both twin de-dup suffixes and auto-pick.
private var connectOrder: [ObjectIdentifier] = []
private var observers: [NSObjectProtocol] = []
private init() {
preferredID = UserDefaults.standard.string(forKey: Self.preferredKey) ?? ""
// Stored as an Int (what UserDefaults round-trips losslessly) and validated back into a
// real case: a value written by a NEWER client a pad family this build has no case for
// must fall back to the neutral glyphs, not trap on an invalid raw value.
lastKnownKind = (UserDefaults.standard.object(forKey: Self.lastKindKey) as? Int)
.flatMap { UInt32(exactly: $0) }
.flatMap(PunktfunkConnection.GamepadType.init(rawValue:)) ?? .auto
observers.append(NotificationCenter.default.addObserver(
forName: .GCControllerDidConnect, object: nil, queue: .main
) { [weak self] n in
@@ -212,6 +230,13 @@ public final class GamepadManager: ObservableObject {
// (list is in connect order). A stale pin falls back to automatic.
let pinned = candidates.last { $0.id == preferredID }
active = pinned ?? candidates.last
// Remember the family for the legends (see `lastKnownKind`). Only ever WRITTEN, never
// cleared: `active` going nil is precisely the moment the memory has to survive, and a
// pad whose `kind` is genuinely unknown never becomes active in the first place.
if let active, active.kind != lastKnownKind {
lastKnownKind = active.kind
UserDefaults.standard.set(Int(active.kind.rawValue), forKey: Self.lastKindKey)
}
// Forwarded set (pf-client-core's `forwarded_ids`): a pin forwards ONLY the pinned pad
// (explicit single-player); Automatic forwards every extended controller in connect order
// (oldestnewest), so a game's player numbers are stable across hot-plug churn.
@@ -0,0 +1,74 @@
// Where the arrow keys go in the library's plain poster grid (LibraryView's touch layout on
// iOS/iPadOS/macOS) the model behind "select games with keyboard arrows, enter to launch".
//
// The grid is up to TWO sections (launcher entries above titles), each rendered as its own
// `LazyVGrid`. A single flat index across both would step by the wrong amount at the boundary
// whenever the first section's last row is partial up from the second section's first row would
// land in the middle of the first section rather than on the row above. So moves happen WITHIN a
// section, with an explicit hand-off at its edges that preserves the column.
//
// Lives in PunktfunkKit rather than beside the view because this is arithmetic with edge cases
// partial rows, section hand-offs, empty sections and PunktfunkKit is the target the tests can
// reach (the app is an executable target). Pure values in, pure value out: no SwiftUI.
import Foundation
public struct LibraryGridNav {
/// Game ids per RENDERED section, in display order. Callers drop empty sections before
/// constructing this, so `sections` never contains one.
public let sections: [[String]]
/// How many columns the grid actually laid out the caller derives it from the measured
/// width using `.adaptive`'s own fitting rule, so a vertical move is exactly one visual row.
public let columns: Int
public init(sections: [[String]], columns: Int) {
self.sections = sections
// A zero or negative count would divide by zero below; one column is the degenerate grid.
self.columns = max(1, columns)
}
/// The id `direction` leads to from `current`, or nil when there is nowhere to go (so the
/// caller leaves the cursor where it is). A nil `current` nothing selected yet lands on
/// the very first tile, so the first arrow press always produces a visible cursor rather than
/// appearing to do nothing.
public func move(from current: String?, _ direction: GamepadMenuInput.Direction) -> String? {
guard !sections.isEmpty else { return nil }
guard let (s, i) = locate(current) else { return sections[0].first }
switch direction {
case .left:
if i > 0 { return sections[s][i - 1] }
return s > 0 ? sections[s - 1].last : nil
case .right:
if i + 1 < sections[s].count { return sections[s][i + 1] }
return s + 1 < sections.count ? sections[s + 1].first : nil
case .up:
if i >= columns { return sections[s][i - columns] }
// Off the top of this section: the section above, same column, its LAST row
// clamped, because that row may be partial.
guard s > 0 else { return nil }
let above = sections[s - 1]
let lastRowStart = ((above.count - 1) / columns) * columns
return above[min(lastRowStart + (i % columns), above.count - 1)]
case .down:
if i + columns < sections[s].count { return sections[s][i + columns] }
// Off the bottom: the section below, same column, its first row.
if s + 1 < sections.count {
let below = sections[s + 1]
return below[min(i % columns, below.count - 1)]
}
// Nothing below. A press from a full row above the last (partial) one still settles
// on the final tile rather than refusing the row IS down from here, just short.
let lastRowStart = ((sections[s].count - 1) / columns) * columns
return i < lastRowStart ? sections[s].last : nil
}
}
/// (section, index within it) for an id, or nil when it isn't in the grid any more.
private func locate(_ id: String?) -> (Int, Int)? {
guard let id else { return nil }
for (s, section) in sections.enumerated() {
if let i = section.firstIndex(of: id) { return (s, i) }
}
return nil
}
}
@@ -36,7 +36,9 @@ enum RumbleTuning {
/// classic Xbox ERM rotor ignores it. On split-handle pads the wire's two motors render at
/// distinct frequencies mirroring the real hardware they emulate low/left the heavy
/// low-frequency rotor, high/right the light buzzer; a single combined actuator keeps the
/// proven mid value.
/// proven mid value. The impulse-trigger motors are small and light the same character as
/// the high/right buzzer so they reuse `sharpnessHigh` rather than introduce a number
/// nobody has measured on real trigger hardware.
static let sharpnessLow: Float = 0.3
static let sharpnessHigh: Float = 0.7
static let sharpnessCombined: Float = 0.5
@@ -140,9 +142,21 @@ final class RumbleRenderer: @unchecked Sendable {
private var controller: GCController?
private var low: Motor?
private var high: Motor?
/// Wire-truth target (raw wire units) the engine command's level, applied verbatim; the
/// core policy engine owns when it ends (explicit zero commands), so no deadline lives here.
private var target: (low: UInt16, high: UInt16) = (0, 0)
/// The two Xbox impulse-trigger motors, when the pad offers
/// `GCHapticsLocality.leftTrigger`/`.rightTrigger`. **Nil is the normal case** every pad but
/// an Xbox One/Series/Elite has no such actuator, and the tree has already observed Xbox pads
/// on Apple exposing no haptics engine at all so their absence is never logged and never
/// counts as a setup failure. Independent of the handle split: a pad may offer trigger
/// localities with or without split handles, and losing one does not implicate the other.
private var leftTrigger: Motor?
private var rightTrigger: Motor?
/// Wire-truth target (raw wire units) the engine command's four levels, applied verbatim;
/// the core policy engine owns when it ends (explicit zero commands), so no deadline lives
/// here. The trigger levels are only ever non-zero against a Windows HID Xbox host pad; every
/// other backend on every OS lacks the channel entirely (XInput's `XINPUT_VIBRATION` and
/// evdev's `FF_RUMBLE` each carry exactly two magnitudes).
private var target: (low: UInt16, high: UInt16, leftTrigger: UInt16, rightTrigger: UInt16) =
(0, 0, 0, 0)
/// Runs while anything is (or should be) audible: staleness watchdog, segment re-arm,
/// throttled-level catch-up, engine rebuild after a reset, HID keepalive. Nil while silent,
/// so an idle controller costs no timer wakeups and no radio traffic.
@@ -216,22 +230,28 @@ final class RumbleRenderer: @unchecked Sendable {
}
}
/// Set the wire-truth target. Called with every 0xCA state the host sends level changes AND
/// renewals (v2) / 500 ms refreshes (legacy); both stamp liveness and, for v2, refresh the
/// self-termination deadline. `ttlMs` is the envelope lease in ms, or [`RumbleTuning.noTTL`]
/// against a legacy host (no lease the staleness watchdog is the backstop). Renewals at an
/// unchanged level extend the deadline before the idempotence guard, so a held rumble never
/// lapses mid-effect.
func apply(low lowAmp: UInt16, high highAmp: UInt16) {
/// Set the wire-truth target: one policy-engine command's four motor levels, applied verbatim.
/// Called with every 0xCA state the host sends level changes AND renewals and the core
/// engine owns when a level ends (it emits explicit zero commands), so nothing here decides.
///
/// `leftTrigger`/`rightTrigger` are the Xbox impulse-trigger motors. They default to zero so
/// handle-only callers (the debug test panel, the tuning tests) read unchanged, which is also
/// the wire's own rule: on a level-triggered plane an absent level is off, never "keep what
/// you had".
func apply(
low lowAmp: UInt16, high highAmp: UInt16, leftTrigger ltAmp: UInt16 = 0,
rightTrigger rtAmp: UInt16 = 0
) {
queue.async {
let active = lowAmp != 0 || highAmp != 0
let next = (lowAmp, highAmp, ltAmp, rtAmp)
let active = next != (0, 0, 0, 0)
if active != self.wasActive {
self.wasActive = active
log.debug(
"rumble: \(active ? "active" : "stop", privacy: .public) low=\(lowAmp, privacy: .public) high=\(highAmp, privacy: .public)")
"rumble: \(active ? "active" : "stop", privacy: .public) low=\(lowAmp, privacy: .public) high=\(highAmp, privacy: .public) lt=\(ltAmp, privacy: .public) rt=\(rtAmp, privacy: .public)")
}
guard (lowAmp, highAmp) != self.target else { return }
self.target = (lowAmp, highAmp)
guard next != self.target else { return }
self.target = next
self.render()
}
}
@@ -241,7 +261,7 @@ final class RumbleRenderer: @unchecked Sendable {
queue.sync {
self.ticker?.cancel()
self.ticker = nil
self.target = (0, 0)
self.target = (0, 0, 0, 0)
self.wasActive = false
self.teardown()
self.closeHID()
@@ -256,7 +276,7 @@ final class RumbleRenderer: @unchecked Sendable {
defer { updateTicker() }
if renderHID() { return }
guard !broken else { return }
let audible = target.low != 0 || target.high != 0
let audible = target != (0, 0, 0, 0)
if audible, low == nil, high == nil, DispatchTime.now() >= retryAfter {
setup()
}
@@ -274,6 +294,18 @@ final class RumbleRenderer: @unchecked Sendable {
let mixed = RumbleTuning.combined(low: target.low, high: target.high)
ok = reconcile(&low, to: RumbleTuning.amplitude(mixed))
}
// Impulse triggers: rendered ONLY where the hardware has the actuators, never folded into
// the handles. `reconcile` on a nil slot is a no-op returning true, so a pad without them
// silently drops the levels which is the correct degrade and the common case.
//
// Their outcome is deliberately kept OUT of `ok`: a trigger engine erroring must not tear
// down the handle engines (which are what the pad's rumble mostly is) nor flip
// `preferCombined`, which is a statement about the handle split and nothing else. Nothing
// is orphaned by that a failed reconcile leaves the slot's Motor in place, so the next
// tick simply retries it, and an engine that is genuinely dead fires its
// stopped/reset handler, which tears down all four slots for a lazy rebuild.
_ = reconcile(&leftTrigger, to: RumbleTuning.amplitude(target.leftTrigger))
_ = reconcile(&rightTrigger, to: RumbleTuning.amplitude(target.rightTrigger))
if !ok {
let wasSplit = high != nil
teardown()
@@ -410,9 +442,11 @@ final class RumbleRenderer: @unchecked Sendable {
/// The ticker runs only while something needs tending any nonzero target (watchdog,
/// throttle catch-up, HID keepalive, post-reset engine rebuild) or segments still alive.
private func updateTicker() {
let needed = target != (0, 0)
let needed = target != (0, 0, 0, 0)
|| low?.current != nil || low?.retiring != nil
|| high?.current != nil || high?.retiring != nil
|| leftTrigger?.current != nil || leftTrigger?.retiring != nil
|| rightTrigger?.current != nil || rightTrigger?.retiring != nil
if needed, ticker == nil {
let t = DispatchSource.makeTimerSource(queue: queue)
t.schedule(
@@ -477,6 +511,26 @@ final class RumbleRenderer: @unchecked Sendable {
preferCombined = true
log.info("rumble: split-handle engines failing — will retry with one combined engine")
}
// Return before the trigger engines: the retry path re-enters setup() on the same
// `low == nil, high == nil` condition, so building them here would leak a fresh pair
// on every attempt (teardown() only runs on the failure paths above, and this is not
// one of them).
return
}
// Impulse-trigger motors, built last and best-effort. Independent of the handle split
// the localities are separate and a pad can offer either, both or neither and NOT part
// of the failure test above: nil here is the ordinary state of every pad that is not an
// Xbox One/Series/Elite, so it must not read as "engine setup failed", back off the handle
// engines, or produce a log line on a path that runs per controller attach.
//
// Whether a given pad + OS pair actually reports these localities is UNVERIFIED on glass.
// The degrade needs no code: `createEngine(withLocality:)` returns nil, the slots stay nil,
// and `reconcile` no-ops on them.
if localities.contains(.leftTrigger) {
leftTrigger = makeMotor(haptics, .leftTrigger, sharpness: RumbleTuning.sharpnessHigh)
}
if localities.contains(.rightTrigger) {
rightTrigger = makeMotor(haptics, .rightTrigger, sharpness: RumbleTuning.sharpnessHigh)
}
}
@@ -563,7 +617,7 @@ final class RumbleRenderer: @unchecked Sendable {
}
private func teardown() {
for m in [low, high].compactMap({ $0 }) {
for m in [low, high, leftTrigger, rightTrigger].compactMap({ $0 }) {
// Disarm the handlers before stopping so stop() can't re-enter teardown via them.
// (Both properties are non-optional closures on this SDK, so assign no-ops, not nil.)
m.engine.stoppedHandler = { _ in }
@@ -577,6 +631,8 @@ final class RumbleRenderer: @unchecked Sendable {
}
low = nil
high = nil
leftTrigger = nil
rightTrigger = nil
}
private func seconds(since t: DispatchTime) -> TimeInterval {
@@ -624,6 +680,16 @@ final class RumbleRenderer: @unchecked Sendable {
/// Write the target to the DualSense over HID if that's the active backend; false not a
/// HID pad, so the caller renders via CoreHaptics. Deduped on the pad's 0...255 resolution,
/// with a periodic keepalive re-write while nonzero (the ticker calls back in here).
///
/// **The impulse-trigger levels are deliberately dropped here, and there is no mapping to
/// invent.** A DualSense has *adaptive* triggers force resistance on a trigger you press,
/// driven by the separate 0xCD `HidOutput.Trigger` plane and no trigger *motors*. The two
/// features are unrelated hardware that only share a word: an Xbox Series pad has trigger
/// motors and no adaptive triggers, a DualSense has the reverse. Routing wire trigger rumble
/// into either the DS5 rumble bytes (which are the two handles) or the adaptive-trigger
/// parameter block would fabricate feedback the game never asked for. This path returning
/// `true` also means a macOS DualSense never reaches the CoreHaptics trigger localities above,
/// which is correct for the same reason.
private func renderHID() -> Bool {
#if os(macOS)
guard let hid = dualSenseHID else { return false }
@@ -0,0 +1,26 @@
// The launcher tiles' brand marks: template vector imagesets in Resources/LauncherIcons.xcassets
// (generated from the repo's assets/launcher-icons masters by scripts/gen-launcher-icons.sh
// per-mark provenance and licensing in that directory's README), resolved from a library entry's
// `icon` token. Template rendering means they tint with `foregroundStyle` like an SF Symbol.
//
// The sibling of OsIcon.swift, which does the equivalent job for the host cards' OS marks. SF
// Symbols ships no third-party brand glyphs, so a curated registry is the only route.
import SwiftUI
/// The icon tokens this client ships art for. A token outside this set draws nothing and the tile
/// falls back to naming its launcher which is what every launcher tile looked like before icons
/// existed, so an unknown mark degrades to the old design rather than to a hole.
///
/// Checked against rather than interpolated: `Image(named:)` is a name lookup, and the set is the
/// only thing that decides which names it can ever see.
private let launcherIconTokensShipped: Set<String> = [
"steam", "lutris", "heroic", "playnite", "epic", "gog", "xbox",
]
/// The brand mark for a library entry's `icon` token, or nil no view at all when the entry
/// carries no token or names one this client ships no art for.
public func launcherIconImage(for token: String?) -> Image? {
guard let token, launcherIconTokensShipped.contains(token) else { return nil }
return Image("launcher-\(token)", bundle: .module)
}
@@ -0,0 +1,10 @@
{
"images" : [
{ "filename" : "epic.pdf", "idiom" : "universal" }
],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1,10 @@
{
"images" : [
{ "filename" : "gog.pdf", "idiom" : "universal" }
],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}

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