From 19f637ea6e14039172c07130689790cc10340e2a Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 5 Aug 2026 19:52:19 +0200 Subject: [PATCH 01/18] fix(ci): builder-image pushes authenticate, and :latest stops being a tag anyone can move MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second half of security-review-2026-08-05 H-6. The infra half (unom/infra, runners/ci-core/) split the LAN registry in two: :5010 serves GET/HEAD only and refuses everything else with 405, :5011 demands basic auth on every request including the /v2/ ping. Both fronts sit on one store, and a registry keys by repository name rather than by the host:port the client used, so an image pushed to :5011 is the identical image every consumer pulls from :5010. So: builds tag the write port, a docker login precedes the push, and the release-tag manifest PUTs authenticate. Consumers are untouched — every `container:` in every other workflow still pulls anonymously from :5010, and ci/rust-ci-arm64cross.Dockerfile's `FROM 192.168.1.58:5010/...` still resolves. Not doing the digest pinning the review asked for, deliberately, and the header says why at length. Once pushes are authenticated, the people who can overwrite a tag are exactly the people who can push to main and edit a pinned digest in this file — a pin defends against nobody it did not already trust, and costs a two-commit dance on every ci/ change (~3x a month) during which consumers run a builder image predating the change they are testing. What does close the residual gap is making :latest a checked function of the tree. reconcile-latest.sh asserts on every run that :latest and :ck-$KEY are the same digest, re-points it when they are not, and warns loudly. An out-of-band overwrite is caught on the next push to main with no churn, and it fixes a pre-existing bug on the side: reverting ci/ used to leave :latest on the newer build forever, because the older key is a cache hit and nothing re-pointed it. Repair rather than fail, because a legitimate revert must not red-line main. Verified against the live registry from a runner host with the real docker client: unauthenticated push denied, push to :5010 refused 405, authenticated push to :5011 accepted, that same image pulled back anonymously from :5010. reconcile-latest.sh exercised over all three cases (diverged -> repaired, already equal -> no-op, missing key -> exit 1). All seven builder images are consistent with their content keys today, so the new step is a silent no-op on its first real run. --- .gitea/scripts/reconcile-latest.sh | 68 ++++++++++++++ .gitea/workflows/docker.yml | 142 +++++++++++++++++++++-------- 2 files changed, 173 insertions(+), 37 deletions(-) create mode 100755 .gitea/scripts/reconcile-latest.sh diff --git a/.gitea/scripts/reconcile-latest.sh b/.gitea/scripts/reconcile-latest.sh new file mode 100755 index 00000000..5a699277 --- /dev/null +++ b/.gitea/scripts/reconcile-latest.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Assert that a builder image's :latest is the SAME manifest as its content key, and +# re-point it when it isn't. +# +# This is what we do instead of pinning consumers by @sha256: digest +# (security-review-2026-08-05, H-6 — see the reasoning at the top of docker.yml). The +# content key is a hash of the ci/ tree, so "which image should :latest be?" has an +# answer derivable from the commit alone. Checking it on every run turns :latest from a +# tag someone remembered to move into a function of the tree. +# +# Two different things make them diverge and neither is distinguishable from here: +# +# - Someone overwrote :latest out of band. Post-fix that needs the push credential, +# but it is exactly the H-6 attack and it must not pass silently. +# - ci/ was reverted. The older key is already a cache hit, so nothing rebuilds and +# nothing re-points :latest — it stays on the newer build forever while every +# consumer pulls a builder that does not match the tree it is building. That bug +# predates this script. +# +# Both are repaired identically, so: repair, and shout. Failing the build instead would +# turn a legitimate revert into a red main with no way forward. +# +# Reads go to the anonymous port, the single write to the authenticated one. +set -euo pipefail + +IMAGE="${1:?usage: reconcile-latest.sh }" +KEY="${2:?usage: reconcile-latest.sh }" +: "${CI_REGISTRY:?CI_REGISTRY not set}" +: "${CI_REGISTRY_PUSH:?CI_REGISTRY_PUSH not set}" +: "${CI_REGISTRY_PASSWORD:?CI_REGISTRY_PASSWORD not set}" + +ACCEPT='Accept: application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json' + +# Digest of a tag, or empty if the tag does not exist. Never fails the script itself — +# "missing" is a state this has to reason about, not an error to abort on. +digest_of() { + curl -sfI -H "$ACCEPT" "http://$CI_REGISTRY/v2/$IMAGE/manifests/$1" 2>/dev/null \ + | tr -d '\r' | sed -n 's/^[Dd]ocker-[Cc]ontent-[Dd]igest: //p' || true +} + +key_digest=$(digest_of "$KEY") +latest_digest=$(digest_of latest) + +if [ -z "$key_digest" ]; then + echo "::error::$IMAGE:$KEY has no manifest — the build or push above did not land" + exit 1 +fi + +if [ "$key_digest" = "$latest_digest" ]; then + echo "$IMAGE:latest == :$KEY ($key_digest)" + exit 0 +fi + +echo "::warning::$IMAGE:latest did not match its content key :$KEY — re-pointing it. If ci/ was not just reverted, someone overwrote this tag out of band: check the registry access log on home-ci-core." +echo " was: ${latest_digest:-}" +echo " wanted: $key_digest (:$KEY)" + +tmp=$(mktemp) +trap 'rm -f "$tmp"' EXIT +media_type=$(curl -sfI -H "$ACCEPT" "http://$CI_REGISTRY/v2/$IMAGE/manifests/$KEY" \ + | tr -d '\r' | sed -n 's/^[Cc]ontent-[Tt]ype: //p') +curl -sf -H "$ACCEPT" -o "$tmp" "http://$CI_REGISTRY/v2/$IMAGE/manifests/$KEY" +curl -sf -u "ci:$CI_REGISTRY_PASSWORD" -X PUT -H "Content-Type: $media_type" \ + --data-binary @"$tmp" "http://$CI_REGISTRY_PUSH/v2/$IMAGE/manifests/latest" + +now=$(digest_of latest) +[ "$now" = "$key_digest" ] || { echo "::error::re-point failed: :latest is $now"; exit 1; } +echo "$IMAGE:latest re-pointed to $key_digest" diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml index ec3422e8..8cb826d6 100644 --- a/.gitea/workflows/docker.yml +++ b/.gitea/workflows/docker.yml @@ -3,13 +3,18 @@ # Two very different image families now: # # BUILDER images (punktfunk-rust-ci{,-noble,-arm64cross}, punktfunk-fedora{,44}-rpm) -# live on the LAN registry (home-ci-core, 192.168.1.58:5010 — unom/infra -# runners/ci-core/) and are CONTENT-KEYED: the tag is a hash of what they are built -# from (the ci/ tree, + rust-toolchain.toml for the cross image), and a build only -# happens when that key has no manifest yet. A push that doesn't touch ci/ costs one -# curl per image (~seconds), pushes nothing over the WAN, and mints no per-SHA tag -# debris on the runners — the failure mode that filled the fleet's disks. `:latest` -# is re-pushed alongside every new key and is what the consuming workflows pin. +# live on the LAN registry (home-ci-core — unom/infra runners/ci-core/) and are +# CONTENT-KEYED: the tag is a hash of what they are built from (the ci/ tree, + +# rust-toolchain.toml for the cross image), and a build only happens when that key +# has no manifest yet. A push that doesn't touch ci/ costs one curl per image +# (~seconds), pushes nothing over the WAN, and mints no per-SHA tag debris on the +# runners — the failure mode that filled the fleet's disks. `:latest` is re-pushed +# alongside every new key and is what the consuming workflows pin. +# +# READS come from :5010 and need no credential. WRITES go to :5011 and need +# CI_REGISTRY_PASSWORD. Same store behind both — a registry keys by repository name, +# not by the host:port the client used — so an image pushed to :5011 is the same +# image every consumer pulls from :5010. # # APP images (punktfunk-web, punktfunk-docs) are deployables: they keep going to the # Gitea registry (git.unom.io) with :latest + :sha-<8> (+ :vX.Y.Z on tags), because @@ -17,26 +22,38 @@ # # Host and clients are intentionally NOT containerized (see CLAUDE.md "What's left"). # -# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (app images only — -# the LAN registry is unauthenticated inside the LAN). +# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (app images). +# CI_REGISTRY_PASSWORD: repo Actions secret, the LAN registry's push credential for user +# `ci`. Generated on ci-core into /srv/ci/stack/registry-secret; rotate in both places. # -# ⚠ OPEN FINDING — security-review-2026-08-05 H-6. That parenthetical is the whole problem. -# Every secret-bearing job in this repo runs INSIDE an image pulled from this registry by a -# MUTABLE tag (`:latest`), and the registry accepts pushes from any LAN peer. Attacker position #1 -# of the project's own threat model — an unauthenticated LAN peer — therefore does not need to -# break any signing logic: they push one tag, and the next android.yml run executes their code in -# the same job that does `echo "$RELEASE_KEYSTORE_BASE64" | base64 -d > release.jks`. Same shape -# for rpm.yml (RPM_GPG_PRIVATE_KEY), android-promote.yml (SERVICE_ACCOUNT_JSON), and every other -# consumer listed by `grep -l 192.168.1.58:5010 .gitea/workflows/`. +# --- security-review-2026-08-05 H-6, FIXED 2026-08-05 ------------------------------- +# The registry used to accept anonymous pushes from any LAN peer, and every +# secret-bearing job in this repo runs INSIDE an image pulled from it. Attacker +# position #1 of the project's own threat model did not need to break any signing +# logic: push one tag, and the next android.yml run executes their code in the same job +# that does `echo "$RELEASE_KEYSTORE_BASE64" | base64 -d > release.jks`. Same shape for +# rpm.yml (RPM_GPG_PRIVATE_KEY) and android-promote.yml (SERVICE_ACCOUNT_JSON). # -# The fix is two halves and only one of them lives in this repo: -# 1. INFRA (unom/infra, runners/ci-core/): put auth in front of the registry, or move the -# builder images to git.unom.io where pushes are already authenticated. -# 2. HERE: once pushes are authenticated, pin consumers by `@sha256:` digest rather than -# `:latest`, so a compromised push cannot retroactively change what a green run built. -# Pinning by tag — including the content-keyed `$KEY` tags below — is NOT sufficient while -# the registry is open, because a tag can simply be overwritten. -# Neither half is done. The content-keying below bounds rebuild churn; it is not a trust boundary. +# The infra half is done (unom/infra runners/ci-core/): :5010 serves GET/HEAD only and +# refuses everything else with 405, :5011 demands basic auth on every request. The half +# in this file is done below: pushes and release-tag manifest PUTs authenticate. +# +# ⚠ On the second half as the review originally worded it — "pin consumers by @sha256: +# digest". We deliberately do something else, because after authentication the digest +# pin no longer buys what it was meant to buy. The set of people who can overwrite a tag +# is now exactly the set who can push to main and edit a pinned digest in this very +# file: a pin defends against nobody it did not already trust, while costing a +# two-commit dance on every ci/ change (~3x a month) during which consumers silently run +# a builder image that predates the ci/ change they are testing. +# +# What actually closes the residual gap — a tag quietly overwritten out of band — is +# making :latest a CHECKED function of the tree instead of a tag someone remembered to +# move. The "Reconcile :latest" step below asserts on every run that :latest and +# :ck-$KEY are the same digest, repairs it when they are not, and says so loudly. That +# catches an out-of-band overwrite on the next push to main, needs no churn, and fixes +# a real pre-existing bug on the side: reverting ci/ used to leave :latest pointing at +# the newer build forever. Revisit inline digest pins if the push credential ever leaves +# the maintainer trust set. # # Bootstrap note: consuming workflows pull /punktfunk-rust-ci:latest, so the LAN # registry must hold a seeded :latest once (done 2026-07-29 from the last Gitea-registry @@ -60,7 +77,10 @@ on: env: REGISTRY: git.unom.io OWNER: unom + # Read port (anonymous, GET/HEAD only) and write port (basic auth). Two doors onto + # one store; see the header. CI_REGISTRY: 192.168.1.58:5010 + CI_REGISTRY_PUSH: 192.168.1.58:5011 jobs: builders: @@ -116,21 +136,40 @@ jobs: echo "hit=false" >> "$GITHUB_OUTPUT" fi + # Tagged for the WRITE port: :5010 refuses a push outright, so a tag that names it + # can only fail. Consumers still pull the identical image from :5010. - name: Build if: steps.exists.outputs.hit == 'false' # --pull is cheap now: base images come through the ci-core pull-through mirror. run: | docker build --pull ${{ matrix.buildargs }} \ -f "${{ matrix.dockerfile }}" \ - -t "$CI_REGISTRY/${{ matrix.image }}:$KEY" \ - -t "$CI_REGISTRY/${{ matrix.image }}:latest" \ + -t "$CI_REGISTRY_PUSH/${{ matrix.image }}:$KEY" \ + -t "$CI_REGISTRY_PUSH/${{ matrix.image }}:latest" \ ci + - name: Log in to the LAN registry + run: | + echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY_PUSH" -u ci --password-stdin + env: + CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }} + - name: Push if: steps.exists.outputs.hit == 'false' run: | - docker push "$CI_REGISTRY/${{ matrix.image }}:$KEY" - docker push "$CI_REGISTRY/${{ matrix.image }}:latest" + docker push "$CI_REGISTRY_PUSH/${{ matrix.image }}:$KEY" + docker push "$CI_REGISTRY_PUSH/${{ matrix.image }}:latest" + + # :latest must be whatever ci/ says it is, on every run — not only on the runs that + # happened to build. Two things break that: an out-of-band overwrite (the H-6 + # attack, now only reachable by someone holding the push credential), and a plain + # revert of ci/, which leaves :latest on the newer build because the older key is + # already a cache hit and nothing re-points it. Both look identical from here and + # both are repaired the same way, so repair and shout rather than fail the build. + - name: Reconcile :latest with the content key + run: .gitea/scripts/reconcile-latest.sh "${{ matrix.image }}" "$KEY" + env: + CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }} # A release pins reproducible builder images without any rebuild: copy the key's # manifest to a vX.Y.Z tag via the registry API (no image bytes move). @@ -142,8 +181,19 @@ jobs: | tr -d '\r' | sed -n 's/^[Cc]ontent-[Tt]ype: //p') curl -sf -H "$ACCEPT" -o /tmp/manifest.json \ "http://$CI_REGISTRY/v2/${{ matrix.image }}/manifests/$KEY" - curl -sf -X PUT -H "Content-Type: $MT" --data-binary @/tmp/manifest.json \ - "http://$CI_REGISTRY/v2/${{ matrix.image }}/manifests/$GITHUB_REF_NAME" + curl -sf -u "ci:$CI_REGISTRY_PASSWORD" -X PUT -H "Content-Type: $MT" \ + --data-binary @/tmp/manifest.json \ + "http://$CI_REGISTRY_PUSH/v2/${{ matrix.image }}/manifests/$GITHUB_REF_NAME" + env: + CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }} + + # Today the job container is ephemeral (the ubuntu-24.04 label is a docker:// + # image), so the credential docker login wrote would die with it anyway. Don't + # make that a load-bearing assumption about a runner label somebody may change to + # a host runner later. + - name: Log out of the LAN registry + if: always() + run: docker logout "$CI_REGISTRY_PUSH" || true # The aarch64 CROSS builder — a SEPARATE job because it is `FROM punktfunk-rust-ci:latest` # (the LAN copy) and so must not race the matrix entry that publishes that base. Consumed @@ -182,15 +232,26 @@ jobs: run: | docker build --pull \ -f ci/rust-ci-arm64cross.Dockerfile \ - -t "$CI_REGISTRY/$IMAGE:$KEY" \ - -t "$CI_REGISTRY/$IMAGE:latest" \ + -t "$CI_REGISTRY_PUSH/$IMAGE:$KEY" \ + -t "$CI_REGISTRY_PUSH/$IMAGE:latest" \ . + - name: Log in to the LAN registry + run: | + echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY_PUSH" -u ci --password-stdin + env: + CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }} + - name: Push if: steps.exists.outputs.hit == 'false' run: | - docker push "$CI_REGISTRY/$IMAGE:$KEY" - docker push "$CI_REGISTRY/$IMAGE:latest" + docker push "$CI_REGISTRY_PUSH/$IMAGE:$KEY" + docker push "$CI_REGISTRY_PUSH/$IMAGE:latest" + + - name: Reconcile :latest with the content key + run: .gitea/scripts/reconcile-latest.sh "$IMAGE" "$KEY" + env: + CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }} - name: Tag for release if: startsWith(github.ref, 'refs/tags/v') @@ -200,8 +261,15 @@ jobs: | tr -d '\r' | sed -n 's/^[Cc]ontent-[Tt]ype: //p') curl -sf -H "$ACCEPT" -o /tmp/manifest.json \ "http://$CI_REGISTRY/v2/$IMAGE/manifests/$KEY" - curl -sf -X PUT -H "Content-Type: $MT" --data-binary @/tmp/manifest.json \ - "http://$CI_REGISTRY/v2/$IMAGE/manifests/$GITHUB_REF_NAME" + curl -sf -u "ci:$CI_REGISTRY_PASSWORD" -X PUT -H "Content-Type: $MT" \ + --data-binary @/tmp/manifest.json \ + "http://$CI_REGISTRY_PUSH/v2/$IMAGE/manifests/$GITHUB_REF_NAME" + env: + CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }} + + - name: Log out of the LAN registry + if: always() + run: docker logout "$CI_REGISTRY_PUSH" || true # Deployable app images — unchanged flow, Gitea registry, per-SHA + release tags. apps: From a11c672bea4d1b318061d5f51e67aa79e0c1b4a3 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 5 Aug 2026 22:25:30 +0200 Subject: [PATCH 02/18] fix(android/hud): stop charging the compositor's wait to the stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Android HUD headlined `capture→displayed` with SurfaceFlinger's latch and scanout inside it — pipeline depth no client can pace under. The usual Android streaming overlays stop measuring at decode-complete, so users comparing overlays read our honesty as latency: on a 60 Hz panel that floor alone clears 30 ms, more than everything those overlays display put together. Exclude it, the way the Apple clients have since the presentation rebuild (8a40e467): shave the measured floor off the shown display and end-to-end at every tier, and name what came off in Detailed as `os present +N excluded (display pipeline minimum)`. The equation still tiles the headline, because the `display` term is shaved by the same amount. The floor is the `latch` p50 we already measure (release→OnFrameRendered), not a modelled 2/refresh: it moves with the panel rate, tunnelled playback and the vendor's low-latency mode, and it exists on every render path (the release stamp is parked on all three), so it does not depend on the timeline presenter being active. Unmeasured reads 0.0 and nothing is shaved — we exclude only what we actually measured. With the floor out, the `display` term is already just `pace`, so the `(pace + latch)` split now renders only on a window where no latch sample paired, and the hardcoded 2-refresh Apple-equivalence twin is gone with it. Raw numbers are untouched in the 1 Hz `pf.present` logcat line, so HUD-off A/Bs and cross-session comparisons still read unshaved values. --- .../kotlin/io/unom/punktfunk/StatsOverlay.kt | 81 +++++++++++++++---- .../unom/punktfunk/screenshots/ShotScenes.kt | 8 +- docs-site/content/docs/stats.md | 64 ++++++++++----- 3 files changed, 115 insertions(+), 38 deletions(-) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StatsOverlay.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StatsOverlay.kt index d5195d9a..926606ce 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StatsOverlay.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StatsOverlay.kt @@ -26,13 +26,25 @@ import kotlin.math.roundToInt * presentsWindow, presenterActive, feedP50Ms, codecP50Ms, skippedOverflowWindow]`. Every read * is length-guarded, so an older native lib simply omits the lines it can't feed. * + * The shown `display` and `end-to-end` numbers EXCLUDE the OS present floor (see [osFloorMs]) at + * every tier, and the detailed tier names what was excluded on its own line. The principle is the + * Apple client's: metrics report what Punktfunk controls, so the compositor's own latch and scanout + * — which no client can pace under — is reported rather than charged. It also stops the HUD reading + * worse than it is: the usual Android streaming overlays stop measuring at decode-complete, so a + * headline that carried the compositor's wait was compared against numbers that never contained it. + * + * The RAW figures are not lost — the native 1 Hz `pf.present` logcat line keeps `paceMs`, `latchMs` + * and `e2eMs` unshaved, so a HUD-off A/B and any cross-session comparison still work off the + * untouched numbers. + * * [verbosity] selects how many lines render (each tier a superset of the last — see * [StatsVerbosity]): * - [StatsVerbosity.COMPACT] — one line, `fps · end-to-end ms · Mb/s` (+ a loss flag). * - [StatsVerbosity.NORMAL] — the res/fps/Mb·s line, the end-to-end p50/p95 headline, and the * reliability counters (18–21) when nonzero. - * - [StatsVerbosity.DETAILED] — also the decoder label, the video-feed descriptor (10–13), and the - * stage equation (14/15, split into `host + network` when the Phase-2 terms at 16/17 are nonzero). + * - [StatsVerbosity.DETAILED] — also the decoder label, the video-feed descriptor (10–13), the + * stage equation (14/15, split into `host + network` when the Phase-2 terms at 16/17 are nonzero), + * and the excluded-floor line when one was measured. * [StatsVerbosity.OFF] renders nothing. Older native layouts simply omit the lines they lack (the * counter line falls back to the cumulative `lostTotal` at index 9 on a pre-window lib). */ @@ -95,9 +107,15 @@ internal fun StatsOverlay( // equation gains its `display` term; otherwise (older lib / no callbacks) the endpoint // honestly stays capture→decoded — the equation always tiles the headline interval. val dispValid = s.size >= 26 && s[22] != 0.0 + // The OS present floor this window (see [osFloorMs]) is excluded from every shown + // display / end-to-end number, at every tier — it is pipeline depth no client can pace + // under, so charging it to Punktfunk made our HUD read worse than clients that simply + // never measure it. 0.0 when unmeasured, which leaves the numbers exactly as raw as + // they were. + val floorMs = osFloorMs(s) val tag = if (skew) "" else " (same-host clock)" val (p50, p95, endpoint) = if (dispValid) { - Triple(s[24], s[25], "capture→displayed") + Triple(shave(s[24], floorMs), shave(s[25], floorMs), "capture→displayed") } else { Triple(s[2], s[3], "capture→decoded") } @@ -120,6 +138,11 @@ internal fun StatsOverlay( // dropping/serializing, an fps deficit is upstream. val split = s.size >= 30 && s[29] != 0.0 && (s[26] > 0 || s[27] > 0) val displayTerm = when { + // Floor excluded: what remains of the `display` term is the half Punktfunk + // owns (the presenter's pace wait), and the excluded line below carries the + // latch — printing the split too would report the same milliseconds twice. + dispValid && floorMs > 0 -> + " + display ${"%.1f".format(shave(s[23], floorMs))}" dispValid && split -> " + display ${"%.1f".format(s[23])} " + "(pace ${"%.1f".format(s[26])} + latch ${"%.1f".format(s[27])})" @@ -143,16 +166,14 @@ internal fun StatsOverlay( "= $hostTerms + $decodeTerm$displayTerm$presents", Color.White, ) - // Metric fairness: the Apple client's HUD shaves ~2 refresh periods of OS - // pipeline floor off its shown display/end-to-end; Android shows raw. This twin - // applies the same shave so iPhone↔Android HUD numbers compare directly. - if (dispValid && hz > 0) { - val shave = 2000.0 / hz + // What the numbers above leave out, named — the Apple client's + // `os present +N excluded` line, same wording so the two HUDs read alike. + // (This replaces the old "≈ Apple-HUD equiv" twin: both clients now shave, and + // Android's shave is measured rather than assumed at 2 refresh periods.) + if (floorMs > 0) { statLine( - "≈ Apple-HUD equiv: end-to-end " + - "${"%.1f".format((s[24] - shave).coerceAtLeast(0.0))} · display " + - "${"%.1f".format((s[23] - shave).coerceAtLeast(0.0))} (−2 refresh)", - Color(0xFFA8D8B8), + "os present +${"%.1f".format(floorMs)} excluded (display pipeline minimum)", + Color(0xFF9AA6B8), ) } } @@ -167,6 +188,37 @@ private fun statLine(text: String, color: Color) { Text(text, color = color, fontFamily = FontFamily.Monospace, fontSize = 12.sp) } +/** + * The OS present floor to exclude from the shown `display` / `end-to-end` numbers, ms — the + * measured `latch` p50 at index 27, i.e. release→`OnFrameRendered`: SurfaceFlinger's own latch and + * scanout. That is compositor pipeline depth no client can pace under, so it is reported as + * excluded rather than charged to Punktfunk — the Apple client's policy since its presentation + * rebuild, where the same floor is measured from the display link's vend lead. + * + * Measured, not assumed: the previous Android treatment used a fixed `2000/hz` twin, but the latch + * varies with panel rate, tunnelled playback and the vendor's low-latency mode (~21 ms p50 observed + * where the ~2-interval model predicts less), and this term self-adapts to all three. It is also + * available on every render path — the presenter's and both legacy release-immediately ones — since + * the release stamp it starts from is parked on every render, so it does not depend on + * `presenterActive` (29). + * + * `0.0` means unmeasured — no display stage this window (an older native lib, API < 33, or a + * platform that refused the callback), or no latch sample paired — and every caller then leaves its + * number raw, which is the honest fallback: we exclude only what we actually measured. + */ +private fun osFloorMs(s: DoubleArray): Double { + val dispValid = s.size >= 26 && s[22] != 0.0 + if (!dispValid || s.size < 28) return 0.0 + return s[27].coerceAtLeast(0.0) +} + +/** + * Subtract the excluded [floorMs] from a shown latency [ms], clamped at zero — the percentiles are + * drawn from different sample sets (a p50 latch against a p50/p95 end-to-end), so the difference can + * legitimately go slightly negative on a well-paced window without anything being wrong. + */ +private fun shave(ms: Double, floorMs: Double): Double = (ms - floorMs).coerceAtLeast(0.0) + /** * The single [StatsVerbosity.COMPACT] line: `238 fps · 1.3 ms · 921 Mb/s`. The end-to-end p50 term * is dropped when no in-range latency sample landed (`latValid` false), and a loss flag @@ -174,8 +226,9 @@ private fun statLine(text: String, color: Color) { * one reliability signal worth surfacing even at the tersest tier. */ private fun compactLine(s: DoubleArray, latValid: Boolean): String { - // Prefer the capture→displayed end-to-end (s[24]) when a render timestamp landed this window. - val e2eP50 = if (s.size >= 26 && s[22] != 0.0) s[24] else s[2] + // Prefer the capture→displayed end-to-end (s[24]) when a render timestamp landed this window, + // less the excluded OS present floor — the same number the richer tiers headline. + val e2eP50 = if (s.size >= 26 && s[22] != 0.0) shave(s[24], osFloorMs(s)) else s[2] val parts = buildList { add("${s[0].roundToInt()} fps") if (latValid) add("${"%.1f".format(e2eP50)} ms") diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt index b012a453..a7845699 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt @@ -355,9 +355,11 @@ internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) { // dispValid, displayP50, e2eDispP50, e2eDispP95]. // 10/9/16/1 = a 10-bit BT.2020 PQ (HDR) 4:2:0 feed so the DETAILED HUD renders its // video-feed line; the display stage is valid (dispValid 1) so the headline is the - // directly-measured capture→displayed pair (1.8/2.6) and the Phase-2 stage terms - // (host 0.6 + network 0.3 + decode 0.4 + display 0.5) tile it, rendering the full split - // equation; the decoder label shows the ranked low-latency decoder. Light per-window loss + // directly-measured capture→displayed pair, less the excluded OS present floor (the 0.3 + // latch p50) — 1.5/2.3 shown from 1.8/2.6 raw — and the Phase-2 stage terms + // (host 0.6 + network 0.3 + decode 0.4 + display 0.2) tile the shaved headline, with the + // `os present +0.3 excluded` line naming what came off; the decoder label shows the ranked + // low-latency decoder. Light per-window loss // (lost 2 · skipped 1 · FEC 5 of 238) so the reliability line (NORMAL/DETAILED) and the // compact loss flag both render. StatsOverlay( diff --git a/docs-site/content/docs/stats.md b/docs-site/content/docs/stats.md index 2a4b4952..147acc50 100644 --- a/docs-site/content/docs/stats.md +++ b/docs-site/content/docs/stats.md @@ -7,13 +7,20 @@ Every Punktfunk client has an in-stream stats overlay. All clients use **the sam vocabulary and the same four measurement points**, so a stage name on your phone means what the same name means on your desktop. -Two platforms differ in the *math*: on **iOS and tvOS** the headline is **floor-shaved**. -The fixed depth of Apple's present pipeline — roughly two refresh intervals, which no -client can pace under — is excluded from it, and the Detailed tier prints the excluded +Some platforms differ in the *math*: on **iOS, tvOS and Android** the headline is +**floor-shaved**. The depth of the OS present pipeline — the compositor's own wait, which +no client can pace under — is excluded from it, and the Detailed tier prints the excluded term on its own line as `os present +X.X excluded (display pipeline minimum)`. Add that -floor back before holding an iPhone, iPad or Apple TV's `capture→on-glass` next to a -macOS, Linux, Windows or Android one. (The macOS client shaves nothing: it presents -straight to the display, with no such pipeline depth to measure, so its numbers are raw.) +floor back before holding an iPhone, iPad, Apple TV or Android device's headline next to a +macOS, Linux or Windows one. (The macOS client shaves nothing: it presents straight to the +display, with no such pipeline depth to measure, so its numbers are raw.) + +The floor is **measured, not assumed**, and it is not small: it is commonly one to two +refresh intervals, which on a 60 Hz phone is more than 30 ms — enough on its own to dwarf +everything Moonlight's overlay displays. Charging it to the stream made Punktfunk look +slower than clients that simply never measure that far (see +[Comparing with Moonlight / Sunshine](#comparing-with-moonlight--sunshine)), so we report +it rather than bury it in the total. ## The four measurement points @@ -47,7 +54,7 @@ captured input, switch mouse mode, disconnect, mute the microphone — are in lost). **Normal** adds the stream line and the p50/p95 headline. **Detailed** adds the per-stage breakdown everywhere; on Linux/Windows it also adds the encoder's target bitrate, the decode path, an HDR tag and a chroma tag, on Android the decoder plus the full codec/bit-depth/colour line, and -on iOS/tvOS the excluded OS present floor. +on iOS, tvOS and Android the excluded OS present floor. You can also set the level a stream starts at in each client's [Settings](/docs/client-settings#overlay). The examples below are the **Detailed** view. @@ -68,14 +75,16 @@ present: mailbox lost 3 (2.4%) ``` -Android: +Android (headline and `display` both floor-shaved, like the Apple clients — the raw +end-to-end here is 30.9 ms, the 16.7 ms floor of a 120 Hz panel included): ``` 1920×1080@120 120 fps 24.3 Mb/s c2.qti.hevc.decoder · low-latency HEVC · 10-bit · HDR (BT.2020 PQ) · 4:2:0 end-to-end 14.2 ms p50 · 19.8 p95 · capture→displayed -= host 3.1 + network 6.7 + decode 2.1 + display 2.3 += host 3.1 + network 6.7 + decode 2.1 + display 2.3 · presents 119 +os present +16.7 excluded (display pipeline minimum) lost 3 (2.4%) · skipped 1 · FEC 12 ``` @@ -131,18 +140,22 @@ lost 3 (2.4%) the screen's refresh cycle, not the stream; a large `pace` is us. (`pace` is also the fair number to compare against an iPhone or iPad, whose figure already has its equivalent of `latch` removed.) - - `os present` *(iOS and tvOS)* — the fixed depth of the OS present pipeline, which is + - `os present` *(iOS, tvOS and Android)* — the depth of the OS present pipeline, which is excluded from both the headline and `display` and printed here so you can add it - back. + back. On Android it is the measured time SurfaceFlinger took to latch and scan out each + frame, so it moves with your panel's rate and with whatever low-latency mode the vendor + applied; on Apple it is measured from the display link's own lead. - `client queue` *(Apple only)* — how long a received frame waited before the decoder pulled it. It's the front part of `decode`, not time on top of it. Hidden below 2 ms; a value that persists is a standing receive backlog on the client. - - `display X (pace A + latch B)` and `presents N` *(Android only)* — when the timeline presenter - is running it splits `display` in two: `pace` is the wait it deliberately holds the frame for - its target refresh, `latch` is SurfaceFlinger picking it up and scanning it out. `presents` - counts the frames confirmed on glass this second — well below `fps` means the presenter is - dropping or serializing frames; an `fps` shortfall with `presents` keeping up is upstream of - the client. + - `presents N` *(Android only)* — the frames confirmed on glass this second. Well below `fps` + means the presenter is dropping or serializing frames; an `fps` shortfall with `presents` + keeping up is upstream of the client. + - `display X (pace A + latch B)` *(Android, only when the floor couldn't be measured)* — with + the floor excluded, Android's `display` term is already just `pace` (the wait the presenter + deliberately holds a frame for its target refresh) and `latch` is what the `os present` line + reports. On the rare window where no latch sample pairs up, nothing is excluded and `display` + reverts to the raw figure with both halves shown. Against an **older host** that doesn't report its share yet, the first two terms merge into a single `host+network` number (`host+net` on Linux/Windows) — same total, @@ -190,12 +203,13 @@ pretending: | Windows, Linux | `capture→on-glass` | present instant available (measured right after the Vulkan swapchain present); published raw | | macOS (Metal presenter) | `capture→on-glass` | present instant available (the system's on-glass time for the flip); published raw | | iOS/tvOS (Metal presenter) | `capture→on-glass` | present instant available, but the OS present floor is **excluded** from the number and printed separately as `os present +X.X excluded` | -| Android | `capture→displayed` | MediaCodec's per-frame render callback reports SurfaceFlinger's render timestamp; on the rare window where no callback is delivered (the platform may drop them under load) the HUD falls back to `capture→decoded` | +| Android | `capture→displayed` | MediaCodec's per-frame render callback reports SurfaceFlinger's render timestamp, and the OS present floor measured from it is **excluded** from the number and printed separately as `os present +X.X excluded`; on the rare window where no callback is delivered (the platform may drop them under load) the HUD falls back to `capture→decoded` | | macOS/iOS fallback presenter | `capture→received` | the system video layer hides decode and present timing entirely | A shorter chain means the number is **smaller because it measures less** — check the endpoint before comparing two devices, and add the excluded `os present` floor back to an -iOS or tvOS client's headline before holding it next to another platform's. +iOS, tvOS or Android client's headline before holding it next to a macOS, Linux or Windows +one. ## Comparing with Moonlight / Sunshine @@ -235,8 +249,8 @@ stands in for a one-way frame flight that Moonlight doesn't measure.) | `Frames dropped due to network jitter` | Decoded frames the *client's pacer* chose to drop ÷ decoded frames | `skipped` (line 4, Android only) | Approximately (both are client-side pacing decisions, despite Moonlight's name) | | `Average network latency` | The **control connection's round-trip time** (ENet RTT + variance) — not video frame latency | `network` (line 3) is the closest concept, but it's the *actual one-way frame path* (flight + reassembly), not an RTT | **No direct comparison.** Roughly, Punktfunk's `network` ≈ ½ × an idle RTT plus serialization time of the frame | | `Average decoding time` | Mean time from decoder enqueue to picture out | `decode` (p50) | Yes (mean vs median; both include decoder queueing) | -| `Average frame queue delay` | Mean time a decoded frame waits for its vsync slot | inside `display` | Sum the two Moonlight lines → | -| `Average rendering time (incl. V-sync latency)` | Mean duration of the present call | inside `display` | …and compare against Punktfunk's `display` | +| `Average frame queue delay` *(desktop only)* | Mean time a decoded frame waits for its vsync slot | inside `display` | Sum the two Moonlight lines → | +| `Average rendering time (incl. V-sync latency)` *(desktop only)* | Mean duration of the present call | inside `display` | …and compare against Punktfunk's `display` | | *(no equivalent)* | — | `end-to-end` — true capture→glass, clock-skew-corrected across machines | **Punktfunk only** | | *(no equivalent)* | — | `FEC` recovered shards (loss absorbed invisibly; Android only) | Punktfunk only | @@ -250,6 +264,14 @@ Other differences worth knowing when squinting at both overlays side by side: - **Host frame rate.** Moonlight's headline FPS estimates what the *host* produced (received + lost). Punktfunk shows what your client actually received, and reports loss separately. +- **On Android, Moonlight's numbers stop at the decoder.** The two lines above that cover + presentation are desktop-only: Moonlight's Android overlay measures nothing after the + decoder produces the picture, so no part of the wait for the screen appears anywhere in + it — and the popular Android forks measure the same slice. Its `Average decoding time` is + therefore comparable to Punktfunk's `decode`, and to nothing else; on Android there is no + Moonlight number that includes what your screen contributes. That asymmetry is why + Punktfunk excludes the `os present` floor on Android too, and why adding that floor back + is the right move when you want the whole truth rather than a like-for-like comparison. ## Recording a capture for a bug report From 5ebe840320514c2a1eafc3c1c9fa41b6790851dd Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 5 Aug 2026 22:31:23 +0200 Subject: [PATCH 03/18] fix(client/windows): settings persist when the app isn't installed on C: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported from the field (2026-08-05): a fresh Windows 11 box with a data partition, "New apps will save to: D:", and the client installed there. It launches, finds hosts and streams — but no setting and no profile survives a restart. Reinstalling to C: fixes it completely. The reporter's read was "it's in read-only mode", and that is almost exactly right. The one clue that localises it: the client creates its mTLS identity with a plain `fs::write` on first run and hard-exits if that fails. Their app started, so ordinary file creation in the config directory works. Only the config stores were being lost — and those are the three files that go through `write_atomic`, which writes a sibling temp and renames it over the target. The rename is what breaks. The client ships as a full-trust MSIX package, so its `%APPDATA%` writes are redirected into the package container. When the package lives on a secondary drive, Windows keeps that redirected state on the package's own volume: `C:\Users\\AppData\Local\Packages\\` stays a real directory on C:, but its children (LocalCache, RoamingState, …) are junctions to `D:\WpSystem\\…`. Both sides of our rename still spell `C:\Users\…`, so nothing looks unusual, but they can resolve across that junction boundary — and `std::fs::rename` is `MoveFileExW` with `MOVEFILE_REPLACE_EXISTING` and *not* `MOVEFILE_COPY_ALLOWED`, so a cross-volume move fails outright rather than degrading to a copy. Creating files still works, which is why everything else about the install looks healthy. So the fix is not to make the rename work — it is to stop treating it as the only way to persist. `write_atomic` now falls back to writing the target in place when the atomic route fails. That is the same operation the identity files already use, and those demonstrably round-trip on the affected installs, so the fallback lands on a path we know resolves. It trades crash-atomicity for exactly the writes that would otherwise be lost, and nowhere else: temp+rename stays the normal route everywhere it works. Writing into a redirected location cannot desync from reading it — Microsoft documents one private-location-first resolution order for both, so whichever layer a write lands in is the layer the next read finds. The fallback verifies anyway, by reading the bytes straight back: a write that reports success and disappears is precisely the bug being fixed, so this path does not get to claim success on an `Ok(())` alone. It costs nothing normally — it only runs on an install that has already shown it does something unusual. Two things this uncovered on the way: The temp file was a single shared `.json.tmp`, but these stores have five whole-file writers (WinUI shell, session, console UI, CLI, Decky). Two saving at once collide on it — on Windows the second write hits a sharing violation, and worse, one process can rename the other's half-written bytes over the target. The scratch path now carries the pid. And none of this was visible to anyone. Every save on this page is fire-and-forget by design (a failed settings write must never take a stream down), so ~15 call sites discard the error and the UI cheerfully shows the toggle you just moved. The reporter had no log file to send either, because "Open log folder" was handing out a phantom path — a separate bug, already fixed in f3c0ee47 but not in the 0.24.0 they were running. `store_health` records the last persistence failure centrally, and Settings shows an error bar naming the path when the store is refusing writes, so a client that cannot save says so instead of pretending. `update.rs` had hand-rolled the same temp+rename inline, so it neither cleaned up its temp on a failed rename nor picks up the fallback; it now goes through the one writer. The update floor silently never rising is how a declined update comes back forever. Deliberately NOT done: disabling MSIX AppData virtualization in the manifest (`desktop6:FileSystemWriteVirtualization`). It would stop the redirection at the source, but every existing packaged install's settings, profiles and pairings live inside the container today — turning it off points the client at an empty real `%APPDATA%` and silently resets all of them. That needs a migration, not a manifest flag. Also considered and not taken: resolving the destination directory with `GetFinalPathNameByHandleW` and creating the temp inside the resolved path, to keep atomicity. It does not reliably close this hole — when the target file exists only in the unvirtualized layer while its directory resolves to the private one, the rename still straddles the boundary — and it would rest on canonicalisation behaving through the redirection, which we have never verified on a packaged run. Verified on the RTX box (.173, Windows 11 26200), which is the platform that actually has these rename semantics: `cargo fmt --all --check`, the full `pf-client-core` lib suite (109 passed), and clippy `-D warnings --all-targets` on both `pf-client-core` and `punktfunk-client-windows` — all clean. Also green under linux/amd64 (116 passed). Three new tests: the pid-scoped scratch path, the fallback actually persisting and reading back when the atomic route is blocked, and a genuinely unwritable store surfacing its error instead of swallowing it. The mechanism above is established from documentation and third-party reports, not from a reproduction on a second-drive install — that box does not exist here. The fix does not depend on the diagnosis being exactly right: it repairs any install where the rename fails but a direct write succeeds. --- clients/windows/src/app/settings.rs | 27 +++- crates/pf-client-core/src/trust.rs | 233 ++++++++++++++++++++++++++-- crates/pf-client-core/src/update.rs | 11 +- 3 files changed, 256 insertions(+), 15 deletions(-) diff --git a/clients/windows/src/app/settings.rs b/clients/windows/src/app/settings.rs index ecfa1792..b6532d7d 100644 --- a/clients/windows/src/app/settings.rs +++ b/clients/windows/src/app/settings.rs @@ -1911,10 +1911,35 @@ pub(crate) fn settings_page( } else { border(vstack(Vec::::new())).into() }; + // Every save on this page is fire-and-forget by design — a failed settings write must + // never take a stream down — so a client whose config store rejects writes looks entirely + // normal: toggles move, profiles appear, and NOTHING survives a restart. That is exactly + // how it reached us from the field ("it's in read-only mode"), with no log file to send + // either. When the store is refusing writes, say so, name the path, and stop pretending. + // + // Same always-mounted-slot discipline as `sheet_slot`: one child in both states, and the + // SAME KIND in both (a Border wrapping the bar, versus an empty background-less Border — + // which per style.rs is not hit-testable, so it swallows no clicks). Neither a grid child + // nor a vstack child is ever added or removed, which is where this reconciler's phantom + // bookkeeping breaks. + let store_slot: Element = match pf_client_core::trust::store_health::last_error() { + Some(err) => border( + InfoBar::new("Your changes aren\u{2019}t being saved") + .message(format!( + "Punktfunk can\u{2019}t write to its settings folder, so nothing on this \ + page will survive a restart. {err}" + )) + .error() + .is_closable(false), + ) + .margin(edges(24.0, 12.0, 28.0, 0.0)) + .into(), + None => border(vstack(Vec::::new())).into(), + }; // The bar rides an Auto row above the nav's Star row, so the nav (and the sheet's scrim // over it) still fills the rest of the window. grid(vec![ - scope_bar.grid_row(0), + Element::from(vstack(vec![store_slot, scope_bar])).grid_row(0), Element::from(grid(vec![nav.into(), sheet_slot, confirm])).grid_row(1), ]) .rows([GridLength::Auto, GridLength::STAR]) diff --git a/crates/pf-client-core/src/trust.rs b/crates/pf-client-core/src/trust.rs index c05bc820..3b10a192 100644 --- a/crates/pf-client-core/src/trust.rs +++ b/crates/pf-client-core/src/trust.rs @@ -91,22 +91,131 @@ fn lock_identity_perms(dir: &std::path::Path, key: &std::path::Path) { let _ = std::fs::set_permissions(key, std::fs::Permissions::from_mode(0o600)); } +/// A sibling temp path unique to this process. The stores below have five whole-file writers +/// (WinUI shell, session, console UI, CLI, Decky) and a single shared `.json.tmp` lets two of +/// them interleave: on Windows the second `fs::write` hits a sharing violation, and worse, one +/// process can rename the OTHER's half-written bytes over the target. The pid keeps each +/// writer on its own scratch file; the rename below removes it, so a leftover only survives a +/// hard kill. +fn temp_sibling(path: &Path) -> PathBuf { + let mut name = path.file_name().unwrap_or_default().to_os_string(); + name.push(format!(".tmp-{}", std::process::id())); + path.with_file_name(name) +} + /// Write a config file the safe way: a sibling temp file, then a rename over the target. A /// plain `fs::write` truncates first, so a crash, a full disk or a power cut between truncate /// and the last byte leaves an empty/half file — and these stores are what a client needs to /// find its hosts at all. Rename is atomic within a directory on both Unix and Windows /// (`MoveFileEx` with replace), so a reader ever sees the old file or the new one, never a /// torn one. Same discipline as the host's `session_settings.rs`. +/// +/// **But the rename is not always available, and losing the write is far worse than a torn +/// one.** The Windows client ships as an MSIX package, so every path here is rewritten by the +/// container's AppData virtualization before it reaches the filesystem — and when the package +/// is installed to a secondary drive (Settings ▸ Storage ▸ "New apps will save to: D:"), +/// Windows stores that redirected AppData on the *package's* volume, under +/// `D:\WpSystem\\AppData\`. The literal path we name still says `C:\Users\…`, so a rename +/// can end up straddling two volumes, and `std::fs::rename` is `MoveFileExW` with +/// `MOVEFILE_REPLACE_EXISTING` and *not* `MOVEFILE_COPY_ALLOWED` — a cross-volume move fails +/// outright with `ERROR_NOT_SAME_DEVICE`. Creating and writing files works fine, which is why +/// such an install starts, streams and pairs happily while every setting and profile silently +/// evaporates (field report 2026-08-05: "it's in read-only mode"). +/// +/// So a failed rename falls back to writing the target in place. That is exactly what the +/// identity files already do a few lines up — and those demonstrably work on the affected +/// installs — so the fallback is a path we know resolves. It gives up crash-atomicity for that +/// one write and nothing else: the temp+rename stays the normal route everywhere it works. +/// +/// Writes and reads of one literal path cannot disagree under that redirection — Microsoft +/// documents a single private-location-first resolution order for both, so whichever layer a +/// write lands in is the layer the next read finds. The fallback still verifies by reading +/// back: a silent write is the exact bug being fixed here, and this path only runs on an +/// install that has already proven it does something unusual. pub(crate) fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> { - let tmp = path.with_extension("json.tmp"); - std::fs::write(&tmp, bytes)?; - match std::fs::rename(&tmp, path) { - Ok(()) => Ok(()), - Err(e) => { - // Don't leave the temp behind to confuse the next writer (or a backup tool). - let _ = std::fs::remove_file(&tmp); - Err(e) + let tmp = temp_sibling(path); + let atomic = std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path)); + let Err(e) = atomic else { + store_health::clear(); + return Ok(()); + }; + // Don't leave the temp behind to confuse the next writer (or a backup tool). + let _ = std::fs::remove_file(&tmp); + match std::fs::write(path, bytes) { + Ok(()) => { + tracing::warn!( + path = %path.display(), + error = %e, + "atomic replace unavailable in this install; wrote the config in place instead", + ); + // Read it straight back. This whole bug was a write that reported success and + // vanished, so the fallback does not get to claim success on the strength of an + // `Ok(())` alone — on the one layered filesystem we know we run on, that is the + // failure mode to be paranoid about. Only on the degraded path, so the normal + // route pays nothing. + match std::fs::read(path) { + Ok(back) if back == bytes => { + store_health::clear(); + Ok(()) + } + Ok(_) => { + let e = std::io::Error::other( + "the file read back different from what was just written", + ); + store_health::record(path, &e); + Err(e) + } + Err(reread) => { + store_health::record(path, &reread); + Err(reread) + } + } } + // Both routes are gone: the store really is unwritable. Report the direct write's + // error — it describes the actual permission/space problem, where the rename's may + // only say the two paths landed on different volumes. + Err(direct) => { + store_health::record(path, &direct); + Err(direct) + } + } +} + +/// Whether the config store is accepting writes, so a front-end can *say so* when it is not. +/// +/// Every persistence call site in this crate is deliberately fire-and-forget — a failed +/// settings write must never take a stream down — which historically meant a client whose +/// store was unwritable looked completely normal: toggles moved, profiles appeared, and +/// nothing survived a restart. The field report that produced this module had no log file to +/// send either, so there was no signal anywhere. Recording the last failure centrally lets the +/// UI surface it without unpicking ~15 `let _ = …save()` call sites. +pub mod store_health { + use std::path::Path; + use std::sync::Mutex; + + static LAST_ERROR: Mutex> = Mutex::new(None); + + pub(crate) fn record(path: &Path, err: &std::io::Error) { + let msg = format!("{}: {err}", path.display()); + tracing::error!(store = %path.display(), error = %err, "cannot persist client config"); + if let Ok(mut slot) = LAST_ERROR.lock() { + *slot = Some(msg); + } + } + + pub(crate) fn clear() { + if let Ok(mut slot) = LAST_ERROR.lock() { + *slot = None; + } + } + + /// The most recent failure to persist a config file, if the last attempt failed. + /// + /// Tracks the last *attempt*, not a per-file verdict: a store that cannot be written fails + /// every file, so this latches for as long as the problem lasts and goes quiet the moment + /// any write gets through. + pub fn last_error() -> Option { + LAST_ERROR.lock().ok().and_then(|s| s.clone()) } } @@ -1940,6 +2049,7 @@ mod tests { /// discipline all three client stores now share. #[test] fn write_atomic_replaces_and_cleans_up() { + let _guard = store_health_lock(); let dir = std::env::temp_dir().join(format!( "pf-client-core-test-{}", std::time::SystemTime::now() @@ -1953,7 +2063,112 @@ mod tests { assert_eq!(std::fs::read_to_string(&p).unwrap(), "{\"a\":1}"); write_atomic(&p, b"{\"a\":2}").unwrap(); assert_eq!(std::fs::read_to_string(&p).unwrap(), "{\"a\":2}"); - assert!(!p.with_extension("json.tmp").exists()); + assert!(!temp_sibling(&p).exists()); + // Nothing else in the directory either — the scratch file is gone, not renamed aside. + let left: Vec<_> = std::fs::read_dir(&dir) + .unwrap() + .filter_map(|e| e.ok().map(|e| e.file_name())) + .collect(); + assert_eq!(left, vec![std::ffi::OsString::from("store.json")]); + let _ = std::fs::remove_dir_all(&dir); + } + + /// `store_health` is process-global, so the two tests that read it must not run at the same + /// time — one's successful write clears the other's recorded failure. Nothing else in the + /// crate's tests reaches `write_atomic`, so this lock is the whole serialization needed. + fn store_health_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + LOCK.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// Two processes saving at once must not share one scratch file — the pid keeps them apart. + /// (Same-process, so this only proves the name varies with the pid, not the interleaving.) + #[test] + fn temp_sibling_is_per_process_and_a_sibling() { + let p = Path::new("/tmp/pf/client-windows-settings.json"); + let t = temp_sibling(p); + assert_eq!(t.parent(), p.parent()); + assert_eq!( + t.file_name().unwrap().to_str().unwrap(), + format!("client-windows-settings.json.tmp-{}", std::process::id()) + ); + // Must not collide with the store itself, nor look like one to `load()`. + assert_ne!(t, p.to_path_buf()); + } + + /// **The fix itself.** When the temp+rename route is unavailable, the bytes must still + /// reach the target — that is the difference between the field's "read-only mode" and a + /// working client. Simulated by parking a DIRECTORY on the (deterministic) temp sibling + /// path so the temp leg cannot be written; the field's install fails one step later, at + /// the rename, but both funnel into the same fallback, which is what this pins. + #[test] + fn the_atomic_route_failing_falls_back_to_an_in_place_write() { + let _guard = store_health_lock(); + let dir = std::env::temp_dir().join(format!( + "pf-client-core-inplace-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + std::fs::create_dir_all(&dir).unwrap(); + let p = dir.join("store.json"); + std::fs::write(&p, b"{\"old\":true}").unwrap(); + + // Block the scratch path, so the atomic route cannot complete. + std::fs::create_dir_all(temp_sibling(&p)).unwrap(); + assert!(temp_sibling(&p).is_dir()); + + // The write must still report success AND actually be readable back — a silent + // `Ok(())` that lost the bytes is the bug, not the fix. + write_atomic(&p, b"{\"new\":true}").unwrap(); + assert_eq!(std::fs::read_to_string(&p).unwrap(), "{\"new\":true}"); + // Degraded, but not broken: nothing to warn the user about. + assert_eq!(store_health::last_error(), None); + + let _ = std::fs::remove_dir_all(&dir); + } + + /// The other end: when the in-place fallback ALSO fails, the error must surface rather + /// than be swallowed, because at that point nothing the user does on the page will stick. + #[test] + fn a_failed_rename_still_persists_the_write() { + let _guard = store_health_lock(); + let dir = std::env::temp_dir().join(format!( + "pf-client-core-fallback-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + std::fs::create_dir_all(&dir).unwrap(); + + // Sanity: the healthy path reports a healthy store. + let ok = dir.join("store.json"); + write_atomic(&ok, b"{}").unwrap(); + assert_eq!(store_health::last_error(), None); + + // Now the unwritable case: a directory in the target's place defeats BOTH the rename + // and the in-place write, so the error must surface instead of being swallowed. + let blocked = dir.join("blocked.json"); + std::fs::create_dir_all(&blocked).unwrap(); + std::fs::write(blocked.join("occupant"), b"x").unwrap(); + assert!(write_atomic(&blocked, b"{\"a\":1}").is_err()); + let reported = store_health::last_error().expect("an unwritable store must be reported"); + assert!( + reported.contains("blocked.json"), + "the report names the store: {reported}" + ); + // No scratch file left behind by the failed attempt. + assert!(!temp_sibling(&blocked).exists()); + + // And a later success clears it, so the UI stops warning once the store recovers. + write_atomic(&ok, b"{\"a\":2}").unwrap(); + assert_eq!(store_health::last_error(), None); + assert_eq!(std::fs::read_to_string(&ok).unwrap(), "{\"a\":2}"); + let _ = std::fs::remove_dir_all(&dir); } } diff --git a/crates/pf-client-core/src/update.rs b/crates/pf-client-core/src/update.rs index b33d9cb3..6962b154 100644 --- a/crates/pf-client-core/src/update.rs +++ b/crates/pf-client-core/src/update.rs @@ -270,7 +270,11 @@ fn load_floor(path: &Path, channel: &str) -> u64 { .unwrap_or(0) } -/// Raise (never lower) the floor; atomic tmp+rename so a power cut can't half-write it. +/// Raise (never lower) the floor, through the crate's one config writer — this used to +/// hand-roll its own tmp+rename, which meant it neither cleaned up its temp on a failed +/// rename nor picked up [`crate::trust::write_atomic`]'s in-place fallback, so on an install +/// where the rename cannot work the floor silently never rose and a declined update came +/// back forever. fn store_floor(path: &Path, channel: &str, serial: u64) { let mut file: FloorFile = std::fs::read(path) .ok() @@ -287,10 +291,7 @@ fn store_floor(path: &Path, channel: &str, serial: u64) { if let Some(dir) = path.parent() { let _ = std::fs::create_dir_all(dir); } - let tmp = path.with_extension("json.tmp"); - if std::fs::write(&tmp, &bytes).is_ok() { - let _ = std::fs::rename(&tmp, path); - } + let _ = crate::trust::write_atomic(path, &bytes); } // ---------------------------------------------------------------- check From 22bc81238d308dad50c582f04a8695e9399c3abe Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 5 Aug 2026 23:20:28 +0200 Subject: [PATCH 04/18] fix(decky): Decky's plugin list says "Punktfunk", not "punktfunk" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The label Decky shows for an installed plugin is plugin.json "name", which we had set to the lowercase directory name — so the one place every user sees the plugin listed was the one place it was off-brand, while the panel header (titleView) already read "Punktfunk". The two were conflated because the name looked load-bearing: the zip's top-level dir becomes ~/homebrew/plugins/, and the scripts derived that dir FROM plugin.json "name". They are in fact independent — Decky extracts the zip as-is and locates an installed plugin by MATCHING plugin.json "name", never by folder name (that is how a plugin can live in DeckWebBrowser/ and list itself as "Web Browser"). So brand-case the label and pin the on-disk dir to the literal `punktfunk` in package.sh/deploy.sh/CI instead of deriving it. Pinning is the part that matters: had the dir followed the label, this rename would have installed a second `Punktfunk/` folder beside the existing `punktfunk/` and the plugin would have shown up twice. The self-update call passes the name Decky uninstalls before extracting, so it moves to "Punktfunk" with it. The upgrade INTO this build still passes "punktfunk" (the installed build's own value), which matches that build's plugin.json — so the old folder is removed and the new zip lands in the same lowercase dir either way. Decky's per-plugin settings dir is unused (all state lives in ~/.config/punktfunk), so nothing is stranded. --- .gitea/workflows/decky.yml | 5 ++++- clients/decky/plugin.json | 2 +- clients/decky/scripts/deploy.sh | 4 +++- clients/decky/scripts/package.sh | 12 ++++++++---- clients/decky/src/hooks.ts | 5 ++++- clients/decky/src/index.tsx | 8 +++++--- 6 files changed, 25 insertions(+), 11 deletions(-) diff --git a/.gitea/workflows/decky.yml b/.gitea/workflows/decky.yml index a5363174..e72bde05 100644 --- a/.gitea/workflows/decky.yml +++ b/.gitea/workflows/decky.yml @@ -46,7 +46,10 @@ env: REGISTRY: git.unom.io OWNER: unom PACKAGE: punktfunk-decky # generic-registry package name - PLUGIN: punktfunk # plugin.json "name" == zip top-level dir + # The plugin's ON-DISK dir == the zip's top-level dir. Deliberately NOT plugin.json "name" + # (that is the brand-cased label Decky lists, and it locates a plugin by matching it, not by + # the folder) — see clients/decky/scripts/package.sh. + PLUGIN: punktfunk jobs: build-publish: diff --git a/clients/decky/plugin.json b/clients/decky/plugin.json index 9f179723..dc6a5f77 100644 --- a/clients/decky/plugin.json +++ b/clients/decky/plugin.json @@ -1,5 +1,5 @@ { - "name": "punktfunk", + "name": "Punktfunk", "author": "enrico", "flags": ["debug"], "api_version": 1, diff --git a/clients/decky/scripts/deploy.sh b/clients/decky/scripts/deploy.sh index 2d3eb202..db004de3 100755 --- a/clients/decky/scripts/deploy.sh +++ b/clients/decky/scripts/deploy.sh @@ -12,7 +12,9 @@ set -euo pipefail HERE="$(cd "$(dirname "$0")/.." && pwd)" DECK="${DECK:?set DECK=deck@}" -NAME="$(python3 -c 'import json;print(json.load(open("'"$HERE"'/plugin.json"))["name"])')" +# The on-disk plugin DIR (what scripts/package.sh staged into out/), not plugin.json "name" — +# that field is the brand-cased label Decky shows in its plugin list. See package.sh's header. +NAME=punktfunk STAGE_LOCAL="$HERE/out/$NAME" [ -d "$STAGE_LOCAL" ] || { echo "$STAGE_LOCAL missing — run scripts/package.sh first" >&2; exit 1; } diff --git a/clients/decky/scripts/package.sh b/clients/decky/scripts/package.sh index 9c07cf12..6e517563 100755 --- a/clients/decky/scripts/package.sh +++ b/clients/decky/scripts/package.sh @@ -5,9 +5,13 @@ # package.json,decky.pyi,LICENSE,README.md} # out/punktfunk/ (the same tree, unzipped — rsync this with scripts/deploy.sh) # -# Decky extracts the zip with --strip-components=1, so the single top-level dir MUST equal -# plugin.json "name". Run after `pnpm build` (or use `pnpm run package`). Host-agnostic: needs -# only bash, python3 and zip. +# The single top-level dir is the plugin's ON-DISK folder name (Decky extracts the zip as-is, +# so the dir in the zip becomes ~/homebrew/plugins/). It is deliberately NOT read from +# plugin.json "name": that field is the user-visible label ("Punktfunk", brand-cased, shown in +# Decky's plugin list) and Decky locates an installed plugin by MATCHING it, never by the folder +# name. Keeping the folder lowercase means a rename of the label can't strand the old directory +# next to a new one (which would show up as two plugins). +# Run after `pnpm build` (or use `pnpm run package`). Host-agnostic: needs only bash, python3 and zip. set -euo pipefail HERE="$(cd "$(dirname "$0")/.." && pwd)" cd "$HERE" @@ -15,7 +19,7 @@ cd "$HERE" [ -f dist/index.js ] || { echo "dist/index.js missing — run 'pnpm build' first" >&2; exit 1; } [ -f LICENSE ] || { echo "LICENSE missing (required by the Decky store)" >&2; exit 1; } -NAME="$(python3 -c 'import json;print(json.load(open("plugin.json"))["name"])')" +NAME=punktfunk # the on-disk plugin dir (see the header) — NOT plugin.json "name" VER="$(python3 -c 'import json;print(json.load(open("package.json"))["version"])')" STAGE="$(mktemp -d)" diff --git a/clients/decky/src/hooks.ts b/clients/decky/src/hooks.ts index 0c1911dc..8f2fde12 100644 --- a/clients/decky/src/hooks.ts +++ b/clients/decky/src/hooks.ts @@ -387,7 +387,10 @@ export async function applyUpdate( // before any result could arrive — so never await it. Decky shows its own confirm prompt. void backend.callable("utilities/install_plugin")( info.artifact, - "punktfunk", + // The name Decky uninstalls before extracting the new zip — it locates the folder by + // matching plugin.json "name", so this must equal THIS build's plugin.json name (the + // brand-cased one), not the lowercase on-disk dir. + "Punktfunk", info.latest, info.hash, INSTALL_TYPE_UPDATE, diff --git a/clients/decky/src/index.tsx b/clients/decky/src/index.tsx index 24c5e180..e5ec1d6b 100644 --- a/clients/decky/src/index.tsx +++ b/clients/decky/src/index.tsx @@ -337,9 +337,11 @@ export default definePlugin(() => { // controller config. Fire-and-forget: cosmetic library upkeep must never block plugin load. void ensureGamepadUiShortcut(); return { - // `name` is the plugin's INTERNAL id — it must stay in sync with plugin.json (the loader - // keys plugins by it), so it stays lowercase; user-facing strings say "Punktfunk". - name: "punktfunk", + // `name` must stay in sync with plugin.json (the loader keys plugins by it) — and it is + // USER-VISIBLE: Decky labels the entry in its plugin list with it, so it carries the brand + // case. Decky finds an installed plugin by matching plugin.json "name" (never the folder + // name), so this is independent of the on-disk dir, which stays lowercase `punktfunk`. + name: "Punktfunk", // `staticClasses?.Title` is guarded so a future client that drops the export can't throw // at plugin-load time (an error boundary only catches render-time, not load-time, errors). titleView:
Punktfunk
, From db0637928b723b1f2d1f29af16647ecd60775d49 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 5 Aug 2026 23:33:25 +0200 Subject: [PATCH 05/18] fix(decky): the shortcut liveness guard answered "alive" for every appId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `shortcutStillExists()` extracted the store method before calling it: const get = appStore?.GetAppOverviewByAppID; return get(appId) != null; `GetAppOverviewByAppID` reads the store's own state (`this.m_mapApps`), so the unbound call throws on the lost `this` — and the function's own `catch { return true }` swallowed it. The guard therefore returned "still exists" for EVERY appId. Not a stale-data bug: it never once answered no. Everything downstream of it was consequently inert. A dangling appId — the documented hazard this guard exists to catch, since the id outlives the shortcut in Steam's CEF localStorage across a plugin reinstall — was never dropped, so `ensureGamepadUiShortcut` always took the reuse branch and `SetShortcut*`'d a dead id (silent no-ops). The visible library entry never came back, `recreateShortcuts` reported success having done nothing (its toast only checks for a non-null appId, and the dead one is non-null), and "Open Punktfunk" ran `RunGame` on the dead id — Steam answers that with "Game configuration unavailable". Call it as a method so `this` survives, and guard the global with `typeof` first: `appStore` is Steam-injected, and a bare reference to a missing one is a ReferenceError that optional chaining does not prevent — which would have landed in the same catch. Verified against the live Deck that hit this: evaluated both versions over its actual appIds, and where the old guard says alive/alive, the fixed one says alive for the live stream shortcut and dead for the dangling UI id — so the stale key now drops and the entry is recreated on the next mount. --- clients/decky/src/steam.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/clients/decky/src/steam.ts b/clients/decky/src/steam.ts index 26f1fcac..176808bc 100644 --- a/clients/decky/src/steam.ts +++ b/clients/decky/src/steam.ts @@ -70,9 +70,18 @@ declare const appStore: * entry from a false "missing". A confident null means the shortcut was deleted → recreate. */ function shortcutStillExists(appId: number): boolean { try { - const get = appStore?.GetAppOverviewByAppID; - if (!get) return true; // no way to verify — preserve the reuse path - return get(appId) != null; + // Call it as a METHOD on appStore — NEVER as an extracted function. Its implementation + // reads the store's own state (`this.m_mapApps`), so `const get = appStore.GetAppOverview…; + // get(id)` throws on the lost `this`, and the catch below turns that into a permanent + // "true". That is not a stale-data bug but a total one: the guard then answers "still + // exists" for EVERY appId, so a dangling id is never dropped, the reuse path repoints a + // dead shortcut (silent no-ops), and "recreate" reports success having done nothing. + // `typeof` first: `appStore` is a Steam-injected global, and a bare reference to a missing + // one is a ReferenceError that optional chaining does NOT prevent. + if (typeof appStore === "undefined" || !appStore?.GetAppOverviewByAppID) { + return true; // no way to verify — preserve the reuse path + } + return appStore.GetAppOverviewByAppID(appId) != null; } catch { return true; } From b53568c99fd6698550afbce935c92d8340d0b001 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 5 Aug 2026 23:37:18 +0200 Subject: [PATCH 06/18] fix(decky): a host saved under its own IP now shows the name it advertises MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel captioned most rows with an IP address. The saved records were the source: `hosts add` falls back to the address when the pairing path knew nothing better, so `name` is literally "192.168.1.21" — and `mergeHosts` took `s.name || s.addr` unconditionally. The fallback only ever fired for an EMPTY name, so a name that was already a copy of the address sailed through as if it were meaningful, and the row printed the address twice: once as its title, once as its subtitle. The friendly name was in hand the whole time. The row is built by joining the saved record to the live advert, and that advert carries the host's actual hostname — the join was already trusted for address, port, online and OS, and only the name was read from the saved side alone. So treat a name equal to the record's own address as the placeholder it is and yield to the advert. A real saved name still wins, even when stale: it may be one the user chose, and an advert must never silently overwrite it. The comparison is against the SAVED address, so a host that moved DHCP lease still recognises its old address as a placeholder rather than mistaking it for a chosen name. Checked against the Deck that reported this, over its actual store and browse: three online rows turn into home-worker-5, ENRICOS-DESKTOP and steamdeck, the four offline ones keep their address (nothing is advertising a better name for them yet), and a user-chosen name survives a conflicting advert. --- clients/decky/src/hooks.ts | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/clients/decky/src/hooks.ts b/clients/decky/src/hooks.ts index 8f2fde12..7c7c4c7d 100644 --- a/clients/decky/src/hooks.ts +++ b/clients/decky/src/hooks.ts @@ -122,6 +122,25 @@ function advertMatchesSaved(a: DiscoveredHost, s: SavedHost): boolean { ); } +/** + * The label a saved row shows. + * + * A saved record whose name IS its own address is a PLACEHOLDER, not a choice: `hosts add` + * falls back to the address when the pairing path had nothing better, so the row ends up + * captioned with the same string it already prints underneath. When the box is on the air it + * is advertising its actual hostname — prefer that, and the row reads "home-worker-5" instead + * of "192.168.1.21". + * + * A real saved name always wins over the advert, even a stale one: it may be a name the user + * chose, and a live advert must never quietly overwrite that. Compared against the SAVED + * address, so a host that moved DHCP lease still recognises its old address as a placeholder. + */ +function hostLabel(s: SavedHost, advert?: DiscoveredHost): string { + const placeholder = !s.name || s.name === s.addr || s.name === `${s.addr}:${s.port}`; + if (!placeholder) return s.name; + return advert?.name || s.name || s.addr; +} + /** * Join the saved store and the live browse into the rows the panel draws. * @@ -134,7 +153,7 @@ export function mergeHosts(saved: SavedHost[], discovered: DiscoveredHost[]): Ho // Prefer a live advert's address: the host may have moved since it was last saved. const advert = discovered.find((a) => advertMatchesSaved(a, s)); return { - name: s.name || s.addr, + name: hostLabel(s, advert), addr: advert?.addr ?? s.addr, port: advert?.port ?? s.port, fp: s.fp_hex, From e1adc5d6d767d777db00c8dd82a17b679ab44f70 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 5 Aug 2026 23:59:45 +0200 Subject: [PATCH 07/18] fix(flatpak): export GAMESCOPE_WAYLAND_DISPLAY so the Deck actually gets HDR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gamescope WSI layer decides whether to engage from one signal: isRunningUnderGamescope() reads $GAMESCOPE_WAYLAND_DISPLAY and nothing else. flatpak does not forward host env into the sandbox, so it arrived unset and the layer's CreateInstance early-returned before creating a GamescopeInstance — no gamescope surface, so the HDR10/ST.2084 formats were never appended and the surface stayed SDR. The layer still loads and still logs its generic bits in that state, so it reads as working. It is not: the three settings already here (layer search path, ENABLE_GAMESCOPE_WSI, the socket bind) all sit downstream of this gate and buy nothing without it. Measured on a Deck OLED (Galileo, SteamOS 3.8.16), client --browse, reading "swapchain config": unset -> no [Gamescope WSI] Surface state block, None set, hdr_enabled=0 -> server hdr output enabled: false, None set, hdr_enabled=1 -> hdr formats exposed to client: true, Some(A2B10G10R10_UNORM_PACK32, HDR10_ST2084_EXT) Matches the field report of "HDR->SDR" in the stats overlay on a correct HDR host. DXVK_HDR was ruled out by measurement. The remaining gate (gamescope's hdr_enabled convar = Steam's HDR display setting) is a user-side step, not a packaging one. --- packaging/flatpak/io.unom.Punktfunk.yml | 42 ++++++++++++++++++------- 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/packaging/flatpak/io.unom.Punktfunk.yml b/packaging/flatpak/io.unom.Punktfunk.yml index 3d5b75ed..4aafa891 100644 --- a/packaging/flatpak/io.unom.Punktfunk.yml +++ b/packaging/flatpak/io.unom.Punktfunk.yml @@ -93,20 +93,38 @@ finish-args: # /usr/lib/extensions/vulkan/gamescope once installed (a one-time, per-Deck step; keep it in the # Decky plugin's setup / docs): # flatpak install --user -y flathub org.freedesktop.Platform.VulkanLayer.gamescope//25.08 - # THREE things are needed, not two (verified live on a Deck OLED — the env vars alone left - # hdr10_format=None). (1) VK_ADD_IMPLICIT_LAYER_PATH puts the layer's implicit-layer JSON on the - # Vulkan loader's search path (the runtime point mounts the files but not onto the path). (2) - # ENABLE_GAMESCOPE_WSI flips the layer's own `enable_environment` gate. (3) The layer, once - # loaded, must open a *Wayland* connection to gamescope's private socket ($GAMESCOPE_WAYLAND_DISPLAY - # = gamescope-0) to negotiate the HDR10 colorspace via the gamescope_swapchain protocol — but the - # Deck runs games as X11 clients (DISPLAY=:1, no WAYLAND_DISPLAY exported), so --socket=wayland - # binds nothing and that socket never enters the sandbox. Without it the layer loads, maps, and - # silently can't reach the compositor → no HDR10 offered → PQ tone-mapped to SDR, badge dark. - # Binding xdg-run/gamescope-0 is the missing half (chiaki-ng does the same). With all three the - # surface offers HDR10 and the presenter's existing HDR10 swapchain path engages — no client code - # change. Harmless off-Deck: the layer no-ops when there's no gamescope socket to bind. + # FOUR things are needed. An earlier revision of this block claimed three and was WRONG: the + # fourth is the gate that makes the other three moot, so the Deck sat at hdr10_format=None with + # all of (1)-(3) in place, which is exactly the field report ("HDR->SDR" in the stats overlay). + # (1) VK_ADD_IMPLICIT_LAYER_PATH puts the layer's implicit-layer JSON on the Vulkan loader's + # search path (the runtime point mounts the files but not onto the path). (2) ENABLE_GAMESCOPE_WSI + # flips the layer's own `enable_environment` gate. (3) --filesystem=xdg-run/gamescope-0 binds + # gamescope's private Wayland socket: the layer must reach the compositor over it to negotiate + # HDR10, and the Deck runs games as X11 clients (DISPLAY=:1, no WAYLAND_DISPLAY exported) so + # --socket=wayland binds nothing (chiaki-ng does the same). (4) GAMESCOPE_WAYLAND_DISPLAY must be + # set INSIDE the sandbox. The layer's `isRunningUnderGamescope()` reads that env var and nothing + # else; flatpak does not forward host env, so it arrives unset and the layer's CreateInstance + # early-returns before it ever creates a GamescopeInstance. The layer still LOADS and still logs + # its generic bits ("Forcing on VK_EXT_swapchain_maintenance1", swapchain destroys), which is why + # this reads as working — but no gamescope surface is made, so no HDR10 format is ever appended + # and (1)-(3) buy nothing. Measured on a Deck OLED (Galileo, SteamOS 3.8.16) 2026-08-05, client + # `--browse`, reading `pf_presenter::vk::setup` "swapchain config": + # unset -> no "[Gamescope WSI] Surface state" block at all, hdr10_format=None + # set, hdr_enabled=0 -> "server hdr output enabled: false", hdr10_format=None + # set, hdr_enabled=1 -> "hdr formats exposed to client: true", + # hdr10_format=Some(A2B10G10R10_UNORM_PACK32, HDR10_ST2084_EXT) + # DXVK_HDR is NOT the gate for us and was ruled out by measurement: the layer forces it OFF for + # clients it has already decided to deny, it does not turn HDR on. + # Hardcoding `gamescope-0` matches the socket bound just below, and is safe off-Deck both ways: + # on a normal Wayland desktop --socket=wayland sets WAYLAND_DISPLAY=wayland-0 inside the sandbox + # and the layer bails on the mismatch; on X11-only there is no gamescope socket to connect to, so + # it prints one "Bypass layer will be unavailable" line and passes through. + # The REMAINING gate is not ours: gamescope's `hdr_enabled` convar (Steam's HDR display setting) + # drives the GAMESCOPE_HDR_OUTPUT_FEEDBACK X property the layer reads, and with it off no app on + # the Deck gets HDR. See docs — that one is a user/Decky-side step, not a packaging one. - --env=VK_ADD_IMPLICIT_LAYER_PATH=/usr/lib/extensions/vulkan/gamescope/share/vulkan/implicit_layer.d - --env=ENABLE_GAMESCOPE_WSI=1 + - --env=GAMESCOPE_WAYLAND_DISPLAY=gamescope-0 # the layer's ONLY "am I under gamescope?" signal - --filesystem=xdg-run/gamescope-0 # gamescope's private Wayland socket (HDR negotiation) build-options: From 5f71aeb02440050a474b92fc3092f7fdf8866263 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 6 Aug 2026 00:15:16 +0200 Subject: [PATCH 08/18] feat(flatpak): vendor the gamescope WSI layer so Deck HDR works on a plain install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HDR on a Deck needed a manual second step nobody took: flatpak install --user flathub org.freedesktop.Platform.VulkanLayer.gamescope//25.08 documented only in a comment in this file. Build the layer ourselves instead, so a plain `flatpak install` is all it takes. The layer is genuinely required, not legacy. Measured on SteamOS 3.8.16 (gamescope 3.16.23.4): the gamescope-0 socket advertises gamescope_swapchain_factory_v2 but NOT wp_color_manager_v1, with HDR both off and on — so Mesa's Wayland WSI has no colour-management protocol to negotiate HDR10 through, and this layer is the only thing that can append the ST.2084 surface formats. Removing the extension gives zero [Gamescope WSI] lines and hdr10_format=None. Vendored rather than declared via add-extensions autodownload: the extension is 94 MB of whole-gamescope for one 4 MB .so, its layer JSON hardcodes a /usr library_path that an app-scoped extension mounted under /app would not satisfy, and it would make flathub a hard install-time dependency of an app we self-host on flatpak.unom.io. enable_gamescope=false skips subdir('src') and every compositor dependency, so only protocol/ and layer/ build. buildsystem is simple rather than meson because glm and stb ship no meson.build of their own - the wraps' patch_directory supplies it, and without that copy configure dies with "Subproject exists but has no meson.build file". meson generates the layer JSON from prefix+libdir, so it self-writes library_path=/app/lib/... into /app/share/vulkan/implicit_layer.d, which XDG_DATA_DIRS already covers. VK_ADD_IMPLICIT_LAYER_PATH is therefore dropped - keeping it would also risk double-loading two same-named layers for anyone who still has the flathub extension installed. Pinned to the same gamescope rev as packaging/gamescope/PKGBUILD so the client's layer and the host's punktfunk-gamescope come from one tree. Verified on a Deck OLED: builds offline (--wrap-mode=nodownload) in org.gnome.Sdk//50, and the resulting .so drives the Deck's system gamescope to "hdr formats exposed to client: true" with hdr10_format=Some(A2B10G10R10_UNORM_PACK32, HDR10_ST2084_EXT). Still user-side, and not fixable in packaging: gamescope's hdr_enabled convar (Steam's HDR display setting) must be on. --- packaging/flatpak/io.unom.Punktfunk.yml | 97 ++++++++++++++++++++++--- 1 file changed, 86 insertions(+), 11 deletions(-) diff --git a/packaging/flatpak/io.unom.Punktfunk.yml b/packaging/flatpak/io.unom.Punktfunk.yml index 4aafa891..6f27c165 100644 --- a/packaging/flatpak/io.unom.Punktfunk.yml +++ b/packaging/flatpak/io.unom.Punktfunk.yml @@ -85,19 +85,17 @@ finish-args: # --- persistent client identity / pairing store (shared with punktfunk-probe) --- - --filesystem=~/.config/punktfunk:create # client-{cert,key}.pem, known-hosts, settings # --- HDR under gamescope (Steam Deck Game Mode) --- - # A flatpak's Vulkan loader can't see the host's gamescope WSI layer, so the SDL3 surface never - # offers the HDR10 (ST.2084) colorspace and the presenter silently tone-maps PQ->SDR — the - # Game-Mode HDR indicator stays dark (verified on a Deck OLED: the sandbox loader found NO - # frog/gamescope layer). The layer ships as the runtime extension - # `org.freedesktop.Platform.VulkanLayer.gamescope`, which org.gnome.Platform//50 auto-mounts at - # /usr/lib/extensions/vulkan/gamescope once installed (a one-time, per-Deck step; keep it in the - # Decky plugin's setup / docs): - # flatpak install --user -y flathub org.freedesktop.Platform.VulkanLayer.gamescope//25.08 + # A flatpak's Vulkan loader can't see the host's gamescope WSI layer, so without help the SDL3 + # surface never offers the HDR10 (ST.2084) colorspace and the presenter silently tone-maps + # PQ->SDR — the field-reported "HDR->SDR" badge. The layer is now VENDORED (see the + # gamescope-wsi-layer module below), so it is always present and there is no longer any + # manual `flatpak install ... VulkanLayer.gamescope` step for the user. # FOUR things are needed. An earlier revision of this block claimed three and was WRONG: the # fourth is the gate that makes the other three moot, so the Deck sat at hdr10_format=None with # all of (1)-(3) in place, which is exactly the field report ("HDR->SDR" in the stats overlay). - # (1) VK_ADD_IMPLICIT_LAYER_PATH puts the layer's implicit-layer JSON on the Vulkan loader's - # search path (the runtime point mounts the files but not onto the path). (2) ENABLE_GAMESCOPE_WSI + # (1) the layer's implicit-layer JSON must be on the Vulkan loader's search path — now + # automatic, the vendored module installs it to /app/share/vulkan/implicit_layer.d which + # XDG_DATA_DIRS already covers. (2) ENABLE_GAMESCOPE_WSI # flips the layer's own `enable_environment` gate. (3) --filesystem=xdg-run/gamescope-0 binds # gamescope's private Wayland socket: the layer must reach the compositor over it to negotiate # HDR10, and the Deck runs games as X11 clients (DISPLAY=:1, no WAYLAND_DISPLAY exported) so @@ -122,7 +120,6 @@ finish-args: # The REMAINING gate is not ours: gamescope's `hdr_enabled` convar (Steam's HDR display setting) # drives the GAMESCOPE_HDR_OUTPUT_FEEDBACK X property the layer reads, and with it off no app on # the Deck gets HDR. See docs — that one is a user/Decky-side step, not a packaging one. - - --env=VK_ADD_IMPLICIT_LAYER_PATH=/usr/lib/extensions/vulkan/gamescope/share/vulkan/implicit_layer.d - --env=ENABLE_GAMESCOPE_WSI=1 - --env=GAMESCOPE_WAYLAND_DISPLAY=gamescope-0 # the layer's ONLY "am I under gamescope?" signal - --filesystem=xdg-run/gamescope-0 # gamescope's private Wayland socket (HDR negotiation) @@ -198,6 +195,84 @@ modules: cleanup: - '*' + # --------------------------------------------------------------------------------------- + # gamescope WSI layer — VENDORED, so HDR works from a plain `flatpak install` with no + # second step. This is the ONLY route to HDR on a Deck: measured on SteamOS 3.8.16 + # (gamescope 3.16.23.4), the gamescope-0 socket advertises `gamescope_swapchain_factory_v2` + # but NOT `wp_color_manager_v1` (checked with HDR both off and on), so Mesa's Wayland WSI + # has no colour-management protocol to negotiate HDR10 through and only this layer can add + # the ST.2084 surface formats. Without it: zero `[Gamescope WSI]` lines, hdr10_format=None. + # + # It used to come from the flathub runtime extension + # `org.freedesktop.Platform.VulkanLayer.gamescope`, which every user had to install BY HAND + # (documented only in a comment here — so in practice nobody did, and the field report was + # "HDR->SDR" in the stats overlay). Vendoring instead of `add-extensions` autodownload, + # deliberately: that extension is 94 MB of whole-gamescope to deliver one 4 MB .so, its + # layer JSON hardcodes a /usr `library_path` that an app-scoped extension (mounted under + # /app) would not satisfy, and it would make flathub a hard install-time dependency of an + # app we self-host on flatpak.unom.io. + # + # Pinned to the SAME gamescope rev as packaging/gamescope/PKGBUILD (`_gsrev`) so the + # client's layer and the host's punktfunk-gamescope always come from one tree — bump both + # together. `enable_gamescope=false` skips subdir('src') and every compositor dependency + # (wlroots, SDL2, libliftoff, ...); only protocol/ and layer/ are built. + # + # `buildsystem: simple` rather than `meson` because two subprojects need their wrap + # `patch_directory` applied by hand: glm and stb ship NO meson.build of their own, and the + # one meson would normally inject lives in subprojects/packagefiles/. Cloning them as plain + # sources without that copy fails at configure with "Subproject exists but has no + # meson.build file". `--wrap-mode=nodownload` then proves the build is genuinely offline. + # + # The layer JSON is generated by meson from prefix+libdir, so it self-writes + # `library_path: /app/lib/libVkLayer_FROG_gamescope_wsi_x86_64.so` and lands in + # /app/share/vulkan/implicit_layer.d — already on the loader's search path via + # XDG_DATA_DIRS, which is why no VK_ADD_IMPLICIT_LAYER_PATH is needed (and why it was + # dropped from finish-args: pointing at the old /usr extension path too would risk + # double-loading two layers of the same name). + # + # Verified on a Deck OLED 2026-08-05: builds offline in org.gnome.Sdk//50, and the + # resulting .so drives the Deck's system gamescope to + # "hdr formats exposed to client: true" + hdr10_format=Some(...). + # --------------------------------------------------------------------------------------- + - name: gamescope-wsi-layer + buildsystem: simple + build-commands: + # Apply the wraps' patch_directory by hand (see above) — these supply the meson.build + # that glm and stb do not ship themselves. + - cp -r subprojects/packagefiles/glm/. subprojects/glm/ + - cp -r subprojects/packagefiles/stb/. subprojects/stb/ + - meson setup _build --prefix=/app --libdir=lib --wrap-mode=nodownload + -Denable_gamescope=false -Denable_gamescope_wsi_layer=true + -Denable_tests=false -Denable_openvr_support=false + - ninja -C _build + - ninja -C _build install + sources: + - type: git + url: https://github.com/ValveSoftware/gamescope.git + # KEEP IN SYNC with `_gsrev` in packaging/gamescope/PKGBUILD. + commit: 8c676c399c761e4540587f61004c957993d12fea + # Submodule + wrap pins as of that rev. `git ls-tree subprojects/` for the + # submodules; subprojects/*.wrap for the rest. + - type: git + url: https://github.com/Joshua-Ashton/vkroots.git + commit: 5106d8a0df95de66cc58dc1ea37e69c99afc9540 + dest: subprojects/vkroots + - type: git + url: https://github.com/g-truc/glm.git + commit: 0af55ccecd98d4e5a8d1fad7de25ba429d60e863 + dest: subprojects/glm + - type: git + url: https://github.com/nothings/stb.git + commit: 5736b15f7ea0ffb08dd38af21067c314d6a3aae9 + dest: subprojects/stb + cleanup: + # Only the .so and its implicit-layer JSON are runtime. vkroots installs its dev files + # from the subproject, and the layer-only install still drops gamescope's display .lua + # scripts + LUT .cube files, none of which a client uses. + - /include + - /lib/pkgconfig + - /share/gamescope + # --------------------------------------------------------------------------------------- # The client. cargo-sources.json is the GENERATED offline crate cache: # python3 flatpak-cargo-generator.py Cargo.lock -o packaging/flatpak/cargo-sources.json From 25b08916b6190724dac911424ea6849ba9acc960 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 6 Aug 2026 00:49:25 +0200 Subject: [PATCH 09/18] =?UTF-8?q?fix(flatpak):=20the=20WSI=20layer=20modul?= =?UTF-8?q?e=20builds=20again=20=E2=80=94=20vkroots=20was=20declared=20twi?= =?UTF-8?q?ce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flatpak has not built since 35ba64ca. Every push to main fails at "Build the flatpak", before a single build command runs: cp: cannot overwrite non-directory '.../build/gamescope-wsi-layer-1/subprojects/vkroots/.git' with directory '.../git/https_github.com_Joshua-Ashton_vkroots.git' Error: module gamescope-wsi-layer: Child process exited with code 1 vkroots was declared twice. flatpak-builder clones git sources WITH SUBMODULES by default, and `subprojects/vkroots` is a real gamescope submodule — `git ls-tree 8c676c39 subprojects/` shows it as mode 160000 at 5106d8a0, which is byte-for-byte the commit the explicit source pinned. So the submodule checkout already produced the right tree and left `subprojects/vkroots/.git` as a gitlink FILE; the second, redundant source then tried to copy the bare mirror onto that path as a DIRECTORY, and cp refused. Source extraction died there — `buildsystem: simple` and the hand-applied glm/stb patch_directory copies were never reached, so neither is at fault. Removing the redundant source is therefore a no-op on the resulting tree: the submodule supplies that exact rev. glm and stb are NOT submodules — `subprojects/ glm.wrap` and `stb.wrap` are plain blobs at that rev — so nothing else populates them and their explicit sources have to stay. That asymmetry is the whole trap, and it is now written down in the manifest next to the sources, along with the disable-submodules escape hatch for anyone who later needs to pin a subproject away from the gamescope rev. Why this reached main: flatpak.yml has no `pull_request:` trigger — only `push` on main with path filters, `tags: ['v*']`, and workflow_dispatch. PR #64's checks were green because the flatpak was never built on the PR; run 15775 was the first time this module had ever been built in CI. Adding a PR trigger (or a manifest lint) is the durable follow-up, deliberately not bundled here. This blocks the release, not just main. flatpak.yml runs on `tags: ['v*']`, and the failing step gates the bundle export, the generic-registry publish, the OSTree push to flatpak.unom.io and the release-asset attach — all of which stay skipped. A v0.25.0 tag cut today would ship with NO Linux/Steam Deck flatpak at all, on the release whose headline Linux change is Deck HDR working out of the box. NOT VALIDATED LOCALLY: this cannot be built on macOS. The reasoning is confirmed against the upstream tree (the ls-tree above) but the green run is still owed — dispatch flatpak.yml on this branch before merging. --- packaging/flatpak/io.unom.Punktfunk.yml | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/packaging/flatpak/io.unom.Punktfunk.yml b/packaging/flatpak/io.unom.Punktfunk.yml index 6f27c165..b1d6a017 100644 --- a/packaging/flatpak/io.unom.Punktfunk.yml +++ b/packaging/flatpak/io.unom.Punktfunk.yml @@ -251,12 +251,19 @@ modules: url: https://github.com/ValveSoftware/gamescope.git # KEEP IN SYNC with `_gsrev` in packaging/gamescope/PKGBUILD. commit: 8c676c399c761e4540587f61004c957993d12fea - # Submodule + wrap pins as of that rev. `git ls-tree subprojects/` for the - # submodules; subprojects/*.wrap for the rest. - - type: git - url: https://github.com/Joshua-Ashton/vkroots.git - commit: 5106d8a0df95de66cc58dc1ea37e69c99afc9540 - dest: subprojects/vkroots + # Wrap pins as of that rev (`subprojects/*.wrap`). These are meson WRAPS, not gamescope + # submodules, so nothing else populates them and they need explicit sources. + # + # vkroots is deliberately NOT listed here. It is a real gamescope SUBMODULE, and + # flatpak-builder clones git sources with submodules by default — so it is already + # checked out at exactly the rev above (`git ls-tree subprojects/vkroots`), which + # leaves `subprojects/vkroots/.git` as a gitlink FILE. Declaring it again with + # `dest: subprojects/vkroots` made the extractor copy the bare mirror onto that path and + # die before any build command ran: + # cp: cannot overwrite non-directory '.../subprojects/vkroots/.git' with directory + # Re-adding it re-breaks the whole flatpak. If the submodule ever needs to be pinned away + # from the gamescope rev, set `disable-submodules: true` on the source above and then + # declare ALL THREE subprojects explicitly — not one of them alone. - type: git url: https://github.com/g-truc/glm.git commit: 0af55ccecd98d4e5a8d1fad7de25ba429d60e863 From 5a7f7f0fc51bc4a188ce27bf5b5987c24a6bcec8 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 6 Aug 2026 12:39:30 +0200 Subject: [PATCH 10/18] feat(clients/gamepad-ui): section tabs, background palettes, and a backdrop that moves everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The console settings were one 30-row scroll, which on a Deck meant thumbing past Video and Audio to reach the pad settings. They are now split across sections — Stream · Video · Audio · Controller · Interface · Profiles, plus Input on the desktop console, which alone carries the touch/mouse rows. L1/R1 walks them, each section remembers where its cursor was, and the names are the same word on every client so a setting is where you looked for it last. Shoulders are not the only route, because a D-pad remote hasn't got any: on Android, Up from the first row moves onto the strip (left/right walks sections there, A drops back in), and on tvOS the pills are focusable, so the focus engine handles it — a Siri Remote has no extended gamepad profile and never reaches the input poll at all. The desktop console needs neither; PageUp and PageDown already map to the same events. New "Background" row, six palettes: Violet (the brand default), Tide, Forest, Ember, Rose, Graphite. A palette is a hue rotation plus a saturation scale over the ONE colour field each client already draws, so every palette inherits its structure and Violet is the identity transform — existing installs see exactly what they see today. The maths is ported three times (Rust/Swift/Kotlin) under one shared `ui_palette` key, with the same assertions pinned in each language. It is presentation only, so it is a device preference and never part of a profile. The form screens no longer have a backdrop of their own. Settings, add-host and pair used to sit on a still gradient; they now wear the same living field at a calm mix — pools dimmed onto the palette's own corner colour, vignette halved so rows that run to the edges don't get crushed. On the desktop console that collapsed the old aurora-over-static crossfade into one shader pass with a chased uniform. Motion speed is identical in both modes on purpose: changing it would make the field jump mid-transition. Nothing in the gamepad UI is backed by a static image now, and Reduce Motion (Apple) / "remove animations" (Android) still freeze it. Also: the settings screen had no raster coverage at all — the eyeball dump is `#[ignore]`d — so a new test draws every tab, and the Android screenshot set gains a console-settings scene. Both earned their keep immediately: the renders showed the extra hint pushing "Done" off a 360 dp phone (the legend scrolls now, and the Section cell only appears where shoulders exist) and the form backdrop crushing its own edges. --- .../src/main/kotlin/io/unom/punktfunk/App.kt | 16 + .../kotlin/io/unom/punktfunk/GamepadChrome.kt | 173 +++++++-- .../kotlin/io/unom/punktfunk/GamepadNav.kt | 12 +- .../io/unom/punktfunk/GamepadPalette.kt | 84 +++++ .../unom/punktfunk/GamepadSettingsScreen.kt | 280 +++++++++----- .../main/kotlin/io/unom/punktfunk/Settings.kt | 13 + .../io/unom/punktfunk/GamepadPaletteTest.kt | 132 +++++++ .../punktfunk/screenshots/ScreenshotTest.kt | 3 + .../unom/punktfunk/screenshots/ShotScenes.kt | 11 + .../PunktfunkClient/Home/GamepadChrome.swift | 137 ++++--- .../Home/GamepadMenuList.swift | 6 + .../Settings/GamepadSettingsView.swift | 290 +++++++++++---- .../PunktfunkShared/DefaultsKeys.swift | 8 + .../PunktfunkShared/GamepadPalette.swift | 79 ++++ .../GamepadPaletteTests.swift | 81 ++++ crates/pf-client-core/src/trust.rs | 14 + crates/pf-console-ui/src/library.rs | 179 ++++++++- crates/pf-console-ui/src/screens.rs | 5 +- crates/pf-console-ui/src/screens/settings.rs | 348 ++++++++++++++---- crates/pf-console-ui/src/shell.rs | 88 ++++- crates/pf-console-ui/src/shell/overlays.rs | 2 +- crates/pf-console-ui/src/shell/render.rs | 17 +- crates/pf-console-ui/src/shell/tests.rs | 61 +++ crates/pf-console-ui/src/theme.rs | 59 +-- crates/pf-console-ui/src/widgets.rs | 121 +++++- 25 files changed, 1805 insertions(+), 414 deletions(-) create mode 100644 clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadPalette.kt create mode 100644 clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadPaletteTest.kt create mode 100644 clients/apple/Sources/PunktfunkShared/GamepadPalette.swift create mode 100644 clients/apple/Tests/PunktfunkKitTests/GamepadPaletteTests.swift diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt index 43a66b76..4c759e7b 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt @@ -31,6 +31,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -98,6 +99,13 @@ fun App(forceGamepadUi: Boolean = false) { } } + // The console backdrop's colour family, published once from the live settings rather than + // threaded through every screen that draws a backdrop. Because it is read from the SAME + // `settings` state the gamepad settings screen writes, stepping the Background row recolours + // the field behind that very row. + CompositionLocalProvider( + LocalGamepadPalette provides GamepadPalette.named(settings.uiPalette), + ) { AnimatedContent( targetState = session, transitionSpec = { @@ -201,8 +209,16 @@ fun App(forceGamepadUi: Boolean = false) { } } } + } } +/** + * The console backdrop's colour family for everything under [App] — provided from the live + * settings so a change on the gamepad settings screen recolours every backdrop at once. Defaults + * to the brand violet, which is also what a preview or a test composition gets. + */ +val LocalGamepadPalette = compositionLocalOf { GamepadPalette.named("violet") } + /** Which console screen the gamepad shell is showing. */ private enum class GamepadScreen { Home, Settings, Library } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadChrome.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadChrome.kt index e7e4cefb..d33c084e 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadChrome.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadChrome.kt @@ -14,6 +14,8 @@ import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues @@ -23,6 +25,9 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -31,7 +36,9 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -86,32 +93,53 @@ private val auroraBlobs = listOf( AuroraBlob(Color(0xFF3862DB), 0.72f, 0.14f, 0.10f, 0.08f, 1, 3, 1.2f, 0.48f, 0.40f), // cool blue ) +/** The deep base the field sits on — and, scaled, the [calm] lift that flattens it. */ +private val auroraBase = Color(0xFF131126) + /** - * The living console backdrop: soft violet-family blobs drifting over black on slow, seamless loops, - * finished with a centre-pooling vignette and top/bottom legibility scrims. A Compose approximation - * of the Apple client's MeshGradient aurora — same brand family, same "ambience, never content" role. + * The living console backdrop: soft brand-family blobs drifting over a deep base on slow, seamless + * loops, finished with a centre-pooling vignette and top/bottom legibility scrims. A Compose + * approximation of the Apple client's MeshGradient aurora — same colour family, same "ambience, + * never content" role, and the same [GamepadPalette] setting recolours both. + * + * [calm] is what the FORM screens wear: the pools dim onto the base 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. */ @Composable -fun GamepadAuroraBackground(modifier: Modifier = Modifier) { +fun GamepadAuroraBackground(modifier: Modifier = Modifier, calm: Boolean = false) { + val palette = LocalGamepadPalette.current + val animated = animationsEnabled() 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 angle by transition.animateFloat( + 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 + // Tinting is per-frame-cheap but not free, and the palette changes about once a year. + val blobs = remember(palette.id) { auroraBlobs.map { it to palette.tint(it.color) } } + val base = remember(palette.id) { palette.tint(auroraBase) } Canvas(modifier) { - drawRect(Color.Black) + drawRect(if (calm) base else Color.Black) val span = max(size.width, size.height) - for (b in auroraBlobs) { + for ((b, tinted) in blobs) { 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 base + // 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(b.color.copy(alpha = b.alpha), Color.Transparent), + colors = listOf(tinted.copy(alpha = alpha), Color.Transparent), center = Offset(cx, cy), radius = r, ), @@ -120,10 +148,15 @@ fun GamepadAuroraBackground(modifier: Modifier = Modifier) { blendMode = BlendMode.Plus, ) } - // Cinematic vignette: pool light centre, sink the corners. + // Cinematic vignette: pool light centre, sink the corners. 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 to black just eats them. (Matches the Apple client and the desktop console.) drawRect( Brush.radialGradient( - colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.44f)), + colors = listOf( + Color.Transparent, + Color.Black.copy(alpha = if (calm) 0.22f else 0.44f), + ), center = Offset(size.width / 2, size.height / 2), radius = span * 0.92f, ), @@ -141,33 +174,96 @@ fun GamepadAuroraBackground(modifier: Modifier = Modifier) { } /** - * The calm backdrop for the console FORM screens (settings, add-host) — deliberately still and quiet - * (unlike the launcher's drifting aurora), a deep indigo base with two soft brand glows so the glass - * rows have some colour + luminance to sit on. Mirrors the Apple client's GamepadFormBackground. + * `false` when the user has turned animations off system-wide (Developer options' animator duration + * scale, or the accessibility "Remove animations" switch, which sets the same global). Read once + * per composition — it needs a settings trip to the system, and it changes about never. + */ +@Composable +private fun animationsEnabled(): Boolean { + val context = LocalContext.current + return remember { + runCatching { + android.provider.Settings.Global.getFloat( + context.contentResolver, + android.provider.Settings.Global.ANIMATOR_DURATION_SCALE, + 1f, + ) != 0f + }.getOrDefault(true) + } +} + +/** + * The backdrop for the console FORM screens (settings, add-host). It used to be a STILL deep-indigo + * base with two soft glows; it is now the launcher's own living field at `calm`, which keeps that + * colour and luminance under the glass rows, honours the palette setting on every screen rather + * than only the launcher, and leaves nothing in the console UI backed by a static image. Mirrors + * the Apple client's GamepadFormBackground, which made the same substitution. */ @Composable fun GamepadFormBackground(modifier: Modifier = Modifier) { - Canvas(modifier) { - val span = max(size.width, size.height) - drawRect(Color(0xFF131126)) - drawCircle( - brush = Brush.radialGradient( - colors = listOf(Color(0xE6635AAE), Color.Transparent), - center = Offset(size.width * 0.24f, size.height * 0.12f), - radius = span * 0.7f, - ), - center = Offset(size.width * 0.24f, size.height * 0.12f), - radius = span * 0.7f, - ) - drawCircle( - brush = Brush.radialGradient( - colors = listOf(Color(0xBF343E96), Color.Transparent), - center = Offset(size.width * 0.82f, size.height * 0.9f), - radius = span * 0.7f, - ), - center = Offset(size.width * 0.82f, size.height * 0.9f), - radius = span * 0.7f, - ) + GamepadAuroraBackground(modifier, calm = true) +} + +/** + * The horizontal section switcher above a console list. Purely presentational — the SCREEN owns + * which tab is selected and what the shoulders do. Scrollable so a narrow phone in landscape never + * has to squeeze the pills, and the selected one is always brought into view whether it was reached + * by shoulder button or tap. + */ +@Composable +fun ConsoleTabStrip( + titles: List, + selected: Int, + onSelect: (Int) -> Unit, + modifier: Modifier = Modifier, + /** + * The strip itself holds the cursor (the caller moved focus UP out of its list). Draws a ring + * on the selected pill so it's clear left/right now walks sections rather than values — the + * route a D-pad remote, which has no shoulder buttons, needs. + */ + focused: Boolean = false, +) { + val listState = rememberLazyListState() + LaunchedEffect(selected) { + runCatching { listState.animateScrollToItem(selected.coerceAtLeast(0)) } + } + LazyRow( + state = listState, + modifier = modifier, + contentPadding = PaddingValues(horizontal = ConsoleEdgeInset), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + itemsIndexed(titles) { i, title -> + val active = i == selected + val background by animateColorAsState( + if (active) Color(0xD96656F2) else Color(0x14FFFFFF), + tween(180), + label = "tabBg", + ) + val ink by animateColorAsState( + Color.White.copy(alpha = if (active) 1f else 0.55f), + tween(180), + label = "tabInk", + ) + val ring by animateColorAsState( + Color.White.copy(alpha = if (active && focused) 0.85f else 0f), + tween(180), + label = "tabRing", + ) + Text( + title, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = ink, + maxLines = 1, + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(background) + .border(1.5.dp, ring, RoundedCornerShape(50)) + .clickable { onSelect(i) } + .padding(horizontal = 14.dp, vertical = 7.dp), + ) + } } } @@ -176,7 +272,7 @@ fun GamepadFormBackground(modifier: Modifier = Modifier) { * sits in the SAME spot across Home / Settings / Add-Host and appears pinned while the content behind * it cross-fades between screens. */ -val ConsoleLegendInset = PaddingValues(start = 24.dp, bottom = 24.dp) +val ConsoleLegendInset = PaddingValues(start = 24.dp, end = 24.dp, bottom = 24.dp) /** The shared horizontal inset for a console screen's heading (matches the legend's left edge). */ val ConsoleEdgeInset = 24.dp @@ -471,7 +567,12 @@ fun GamepadHintBar(hints: List, modifier: Modifier = Modifier, haze Row( modifier = frosted .border(1.dp, Color.White.copy(alpha = 0.14f), shape) - .padding(horizontal = 16.dp, vertical = 10.dp), + .padding(horizontal = 16.dp, vertical = 10.dp) + // The pill still hugs its content when it fits; when it doesn't (a narrow phone, or a + // screen whose legend grew a cell) it scrolls rather than running off the edge and + // silently eating the last hint — which is exactly what the settings screen's new + // Section cell did on a 360 dp phone. + .horizontalScroll(rememberScrollState()), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(11.dp), ) { diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadNav.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadNav.kt index dd98af48..fe64b01f 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadNav.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadNav.kt @@ -152,8 +152,9 @@ fun GamepadNavEffect( * keyboard). Same hysteresis + hold-to-repeat as [GamepadNavEffect] but on both axes — the dominant * stick axis (or the pressed D-pad/HAT) commits a [NavDir], and it re-arms only after the stick * returns near centre (so a flick is one step). [onActivate] is A / center, [onTertiary] is X, - * [onSecondary] is Y. B is left to MainActivity's BACK remap → the screen's BackHandler (so B "peels - * one layer": close the keyboard, then the screen). + * [onSecondary] is Y, and [onShoulder] is L1 (-1) / R1 (+1) — a step SIDEWAYS out of the list, which + * the settings screen uses for its section tabs. B is left to MainActivity's BACK remap → the + * screen's BackHandler (so B "peels one layer": close the keyboard, then the screen). */ @Composable fun GamepadNavEffect2D( @@ -162,6 +163,7 @@ fun GamepadNavEffect2D( onActivate: () -> Unit, onTertiary: () -> Unit = {}, onSecondary: () -> Unit = {}, + onShoulder: (Int) -> Unit = {}, ) { val activity = LocalContext.current as? MainActivity ?: return val state = remember { NavInputState() } @@ -169,6 +171,7 @@ fun GamepadNavEffect2D( val currentOnActivate by rememberUpdatedState(onActivate) val currentOnTertiary by rememberUpdatedState(onTertiary) val currentOnSecondary by rememberUpdatedState(onSecondary) + val currentOnShoulder by rememberUpdatedState(onShoulder) DisposableEffect(active) { // Stable probe refs so onDispose only releases the slot if WE still own it — during a @@ -196,7 +199,10 @@ fun GamepadNavEffect2D( KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> { if (edge) currentOnActivate(); true } KeyEvent.KEYCODE_BUTTON_X -> { if (edge) currentOnTertiary(); true } KeyEvent.KEYCODE_BUTTON_Y -> { if (edge) currentOnSecondary(); true } - else -> false // B / shoulders → MainActivity (B remaps to BACK → BackHandler) + // 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 } + else -> false // B → MainActivity (remapped to BACK → BackHandler) } } if (active) { diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadPalette.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadPalette.kt new file mode 100644 index 00000000..3db75d01 --- /dev/null +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadPalette.kt @@ -0,0 +1,84 @@ +package io.unom.punktfunk + +import androidx.compose.ui.graphics.Color +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt + +// The console (gamepad) UI's background colour families. +// +// A palette is NOT a second hand-tuned colour field: it is a hue rotation + saturation scale +// applied to the ONE field GamepadAuroraBackground already draws, so every palette inherits its +// structure (dark base, bright drifting pools) and the brand default is exactly the shipped look — +// `violet` is the identity transform. +// +// The table and the `tint` maths are mirrored in `pf-console-ui`'s `library.rs` (Rust) and the +// Apple client's `GamepadPalette.swift` under the same ids, so the shared `ui_palette` setting +// names the same colour family on every client. Keep the three copies in step: a palette added +// here without the others is a value the other clients will silently render as Violet. + +/** + * One background colour family. [hueDegrees] rotates about the grey axis (positive runs + * red → green → blue) and [saturation] scales saturation about luminance. + */ +class GamepadPalette( + /** The stored `ui_palette` value ([Settings.uiPalette]). */ + val id: String, + /** What the settings row shows. */ + val name: String, + val hueDegrees: Double, + val saturation: Double, +) { + /** True for the identity transform, so the default path skips the per-colour work. */ + val isIdentity: Boolean get() = hueDegrees == 0.0 && saturation == 1.0 + + /** Apply this palette to one packed sRGB colour, keeping its alpha. */ + fun tint(c: Color): Color { + if (isIdentity) return c + val (r, g, b) = tint(Triple(c.red.toDouble(), c.green.toDouble(), c.blue.toDouble())) + return Color(r.toFloat(), g.toFloat(), b.toFloat(), c.alpha) + } + + /** + * Rotate `c` about the grey axis by [hueDegrees] (Rodrigues — the same rotation, in the same + * orientation, that the desktop console's shader uses for its ±8° warm/cool sway) and scale + * its saturation about luminance. Clamped, because a large rotation can push a channel out of + * gamut. + */ + fun tint(c: Triple): Triple { + val (r, g, b) = c + val a = Math.toRadians(hueDegrees) + val cs = cos(a) + val sn = sin(a) + val invSqrt3 = 1.0 / sqrt(3.0) + val grey = (r + g + b) / 3.0 * (1.0 - cs) + // The `sn` term is cross(k, c) with k = (1,1,1)/√3. + val rr = r * cs + (b - g) * invSqrt3 * sn + grey + val rg = g * cs + (r - b) * invSqrt3 * sn + grey + val rb = b * cs + (g - r) * invSqrt3 * sn + grey + val luma = 0.2126 * rr + 0.7152 * rg + 0.0722 * rb + fun mix(v: Double) = (luma + (v - luma) * saturation).coerceIn(0.0, 1.0) + return Triple(mix(rr), mix(rg), mix(rb)) + } + + companion object { + /** + * The six shipped palettes, in cycling order: the brand violet, then cool → warm, then + * the neutral. + */ + val ALL = listOf( + GamepadPalette("violet", "Violet", 0.0, 1.0), + GamepadPalette("tide", "Tide", -70.0, 1.0), + GamepadPalette("forest", "Forest", -130.0, 0.9), + GamepadPalette("ember", "Ember", 105.0, 1.0), + GamepadPalette("rose", "Rose", 60.0, 0.95), + GamepadPalette("graphite", "Graphite", 0.0, 0.12), + ) + + /** + * The palette stored under [id], falling back to the brand default — an unknown name is a + * palette a newer client shipped, not a reason to draw nothing. + */ + fun named(id: String): GamepadPalette = ALL.firstOrNull { it.id == id } ?: ALL[0] + } +} diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt index b085163c..48911910 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt @@ -39,6 +39,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -63,10 +64,35 @@ import io.unom.punktfunk.kit.security.KnownHostStore // The gamepad-driven settings screen — the Android mirror of the Apple client's GamepadSettingsView: // the couch-relevant subset of the touch settings restyled as a console page and fully navigable with // a controller: up/down moves the focus bar, left/right steps the focused value, A cycles/toggles it, -// B closes. Both write the same SharedPreferences, so values round-trip with the touch settings. +// L1/R1 change SECTION, B closes. Both write the same SharedPreferences, so values round-trip with +// the touch settings. +// +// The rows are split across SECTION TABS ([GpTab]) — a shoulder press on a pad, a tap on a phone. +// They used to be one long scroll with inline `Group · Subgroup` headers, which on a TV meant +// walking past Display and Audio to reach the controller settings. The tab names match the desktop +// console's and the Apple client's, so a setting is found under the same word wherever you look. + +/** + * The settings screen's sections. Order IS the strip order and the L1/R1 cycle order; the names + * match `pf-console-ui`'s `TABS` and the Apple client's `GpSettingsTab`. + */ +enum class GpTab(val title: String) { + STREAM("Stream"), + VIDEO("Video"), + AUDIO("Audio"), + CONTROLLER("Controller"), + INTERFACE("Interface"), + PROFILES("Profiles"), +} internal class GpRow( val id: String, + val tab: GpTab, + /** + * A sub-heading above this row, for the few tabs that hold more than one group. Most rows have + * none: the tab pill already names the section, and repeating it would be a second label + * saying the same word. + */ val header: String?, val label: String, val value: String, @@ -133,10 +159,34 @@ 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 rows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update) + + val allRows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::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) } + // 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() } + val rows = allRows.filter { it.tab == tab } var focus by remember { mutableIntStateOf(0) } - if (focus > rows.lastIndex) focus = rows.lastIndex + if (focus > rows.lastIndex) focus = rows.lastIndex.coerceAtLeast(0) + + // 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 + tab = next + // Clamp: a tab's length follows the hardware and the catalog, so a remembered index can + // outlive the row it pointed at. + focus = (tabFocus[next] ?: 0) + .coerceIn(0, (allRows.count { it.tab == next } - 1).coerceAtLeast(0)) + } + fun stepTab(delta: Int) { + val all = GpTab.entries + selectTab(all[((all.indexOf(tab) + delta) % all.size + all.size) % all.size]) + } // 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) } @@ -151,20 +201,28 @@ fun GamepadSettingsScreen( active = navActive && pinProfile == null, onDirection = { dir -> when (dir) { - NavDir.UP -> if (focus > 0) focus-- - NavDir.DOWN -> if (focus < rows.lastIndex) focus++ - // 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 -> { adjustDir = -1; liveRow(rows, focus)?.adjust(-1) } - NavDir.RIGHT -> { adjustDir = 1; liveRow(rows, focus)?.adjust(1) } + NavDir.UP -> if (focus > 0) focus-- else tabFocused = true + NavDir.DOWN -> if (tabFocused) tabFocused = false else if (focus < rows.lastIndex) focus++ + // 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) } } }, - onActivate = { adjustDir = 1; liveRow(rows, focus)?.activate() }, + // 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() } + }, + // 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) { + LaunchedEffect(focus, tab) { runCatching { val itemIndex = focus + 1 val info = listState.layoutInfo @@ -183,9 +241,21 @@ fun GamepadSettingsScreen( // 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().systemBarsPadding(), + modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 104.dp), verticalArrangement = Arrangement.spacedBy(6.dp), ) { @@ -196,12 +266,19 @@ fun GamepadSettingsScreen( ConsoleHeader("Default settings", horizontalInset = false) } itemsIndexed(rows, key = { _, r -> r.id }) { index, row -> - SettingRowView(row, focused = index == focus, adjustDir = adjustDir, onClick = { - // Same inertness as the pad path above — tapping a dimmed row focuses it (so - // its detail explains itself) but never flips it. - if (focus != index) focus = index - else if (row.enabled) { adjustDir = 1; row.activate() } - }) + SettingRowView( + row, + focused = index == focus && !tabFocused, + adjustDir = adjustDir, + 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() } + }, + ) + } } } } @@ -218,8 +295,23 @@ fun GamepadSettingsScreen( // a profile row doesn't adjust, it opens the pin picker, and the "No profiles yet" // placeholder does nothing at all — advertising ↔/A on those would be a lie. val focused = rows.getOrNull(focus) + // The shoulders always change section, so that cell leads on every row. Tappable too, + // like the others — a user without a working pad can still reach every tab. + // Advertise the shoulders only where they EXIST: a TV remote has none (its route is Up + // into the strip) and a touch user taps a pill, so on those the cell would be both a + // lie and the reason a 360 dp legend runs out of room. Defaults to the pad case off an + // 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) }) + .takeIf { padIsGamepad }, + ) GamepadHintBar( - when { + 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), ) @@ -353,7 +445,8 @@ 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 (absent on TVs); [av1Capable] gates the - * AV1 codec entry (see `codecOptionsFor`). */ + * AV1 codec entry (see `codecOptionsFor`). Every row declares its [GpTab]; the screen shows one + * tab at a time. */ internal fun buildSettingsRows( s: Settings, hasBodyVibrator: Boolean, @@ -361,12 +454,12 @@ internal fun buildSettingsRows( update: (Settings) -> Unit, ): List { fun choice( - id: String, header: String?, label: String, detail: String, + id: String, tab: GpTab, header: String?, label: String, detail: String, options: List>, current: T, enabled: Boolean = true, write: (T) -> Unit, ): GpRow { val idx = options.indexOfFirst { it.first == current } return GpRow( - id, header, label, + id, tab, header, label, value = options.getOrNull(idx)?.second ?: "—", detail = detail, enabled = enabled, @@ -385,10 +478,10 @@ internal fun buildSettingsRows( ) } fun toggle( - id: String, header: String?, label: String, detail: String, + id: String, tab: GpTab, header: String?, label: String, detail: String, value: Boolean, enabled: Boolean = true, write: (Boolean) -> Unit, ): GpRow = GpRow( - id, header, label, + id, tab, header, label, value = if (value) "On" else "Off", detail = detail, enabled = enabled, @@ -397,36 +490,13 @@ internal fun buildSettingsRows( toggled = value, ) - // Grouped and ordered by the cross-client category map (General / Display / Audio / - // Controllers), with the same sub-section names the touch settings and the desktop clients use, - // so a setting sits in the same place whichever surface you found it on. The ROWS stay the - // couch-relevant subset: a pad can't drive a touch-input picker, and adding one for the sake of - // symmetry would be parity in name only. + // Grouped by the cross-client tab map (Stream / Video / Audio / Controller / Interface / + // Profiles), so a setting sits under the same word whichever client you found it on. The ROWS + // stay the couch-relevant subset: a pad can't drive a touch-input picker, and adding one for + // the sake of symmetry would be parity in name only. return listOf( choice( - "hud", "General · Statistics", "Statistics overlay", - "How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " + - "A 3-finger tap cycles the tiers live.", - STATS_VERBOSITY_OPTIONS, s.statsVerbosity, - ) { update(s.copy(statsVerbosity = it)) }, - toggle( - "autoWake", "General · Session", "Auto-wake on connect", - "Wake a saved host with Wake-on-LAN when it isn't seen on the network, then connect.", - s.autoWakeEnabled, - ) { update(s.copy(autoWakeEnabled = it)) }, - toggle( - "library", "General · Library", "Game library", - "Browse a paired host's games with Y (experimental).", - s.libraryEnabled, - ) { update(s.copy(libraryEnabled = it)) }, - toggle( - "gamepadUI", "General · Interface", "Controller-optimized UI", - "Turn off to use the touch interface even with a controller connected.", - s.gamepadUiEnabled, - ) { update(s.copy(gamepadUiEnabled = it)) }, - - choice( - "resolution", "Display · Resolution", "Resolution", + "resolution", GpTab.STREAM, null, "Resolution", "The host creates a virtual display at exactly this size — no scaling. " + "Custom sizes are typed in the touch settings.", // A custom size (typed in the touch settings) leads the list so it stays visible and @@ -440,55 +510,56 @@ internal fun buildSettingsRows( s.width to s.height, ) { (w, h) -> update(s.copy(width = w, height = h)) }, choice( - "refresh", null, "Refresh rate", "Frame rate the host renders and streams at.", + "refresh", GpTab.STREAM, null, "Refresh rate", + "Frame rate the host renders and streams at.", REFRESH_OPTIONS, s.hz, ) { update(s.copy(hz = it)) }, - choice( - "bitrate", "Display · Quality", "Bitrate", + "bitrate", GpTab.STREAM, null, "Bitrate", "Automatic uses the host's default. A host's options (Up on its tile) can measure the " + "link and set an informed value.", BITRATE_OPTIONS, s.bitrateKbps, ) { update(s.copy(bitrateKbps = it)) }, choice( - "codec", null, "Video codec", - "A preference — the host falls back if it can't encode this one.", - codecOptionsFor(s.codec, av1Capable), s.codec, - ) { update(s.copy(codec = it)) }, - toggle( - "hdr", null, "10-bit HDR", - "HDR10 — engages when the host sends HDR content and this display supports it.", - s.hdrEnabled, - ) { update(s.copy(hdrEnabled = it)) }, - - toggle( - "lowLatency", "Display · Decoding", "Low-latency mode", - "The fast pipeline (async decode + system tuning). On by default — turn off to fall back if the stream stutters or glitches.", - s.lowLatencyMode, - ) { update(s.copy(lowLatencyMode = it)) }, - - choice( - "compositor", "Display · Host output", "Compositor", + "compositor", GpTab.STREAM, "Host output", "Compositor", "Which compositor drives the virtual output — honored only if available on the host.", COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.compositor, ) { update(s.copy(compositor = it)) }, choice( - "audio", "Audio", "Audio channels", "The speaker layout requested from the host.", + "codec", GpTab.VIDEO, null, "Video codec", + "A preference — the host falls back if it can't encode this one.", + codecOptionsFor(s.codec, av1Capable), s.codec, + ) { update(s.copy(codec = it)) }, + toggle( + "hdr", GpTab.VIDEO, null, "10-bit HDR", + "HDR10 — engages when the host sends HDR content and this display supports it.", + s.hdrEnabled, + ) { update(s.copy(hdrEnabled = it)) }, + toggle( + "lowLatency", GpTab.VIDEO, "Decoding", "Low-latency mode", + "The fast pipeline (async decode + system tuning). On by default — turn off to fall back if the stream stutters or glitches.", + s.lowLatencyMode, + ) { update(s.copy(lowLatencyMode = it)) }, + + choice( + "audio", GpTab.AUDIO, null, "Audio channels", + "The speaker layout requested from the host.", AUDIO_CHANNEL_OPTIONS, s.audioChannels, ) { update(s.copy(audioChannels = it)) }, toggle( - "mic", null, "Microphone", "Send this device's microphone to the host's virtual mic.", + "mic", GpTab.AUDIO, null, "Microphone", + "Send this device's microphone to the host's virtual mic.", s.micEnabled, ) { update(s.copy(micEnabled = it)) }, toggle( - "echoCancel", null, "Echo cancellation", + "echoCancel", GpTab.AUDIO, null, "Echo cancellation", "Filter the stream's own audio out of the mic pickup. Applies while the microphone is on.", s.echoCancel, ) { update(s.copy(echoCancel = it)) }, toggle( - "padForward", "Controllers", "Forward controllers", + "padForward", GpTab.CONTROLLER, null, "Forward controllers", "Send this device's controllers to the host. Turn it off when your controller " + "already reaches the host another way — USB passthrough such as VirtualHere — " + "so games don't see two of them.", @@ -499,18 +570,18 @@ internal fun buildSettingsRows( // had the capability (`GpRow.enabled`) and used it only for the profiles placeholder, so // the pad rows kept stepping settings that had nothing to act on. choice( - "padType", null, "Controller type", + "padType", GpTab.CONTROLLER, null, "Controller type", "The virtual pad the host creates — Automatic matches this controller.", GAMEPAD_OPTIONS, s.gamepad, enabled = s.gamepadForwarding, ) { update(s.copy(gamepad = it)) }, choice( - "systemButtons", null, "Guide button", + "systemButtons", GpTab.CONTROLLER, null, "Guide button", "Where the guide (Xbox/PS) and share presses go while streaming — Automatic " + "sends them to the host whenever this device delivers them.", SYSTEM_BUTTON_OPTIONS, s.systemButtons, enabled = s.gamepadForwarding, ) { update(s.copy(systemButtons = it)) }, choice( - "guideGesture", null, "Hold Select for guide", + "guideGesture", GpTab.CONTROLLER, null, "Hold Select for guide", "Hold Select alone to press the host's guide button — keep holding for a " + "Gaming-Mode host's quick-access menu. A Select tap still goes through.", GUIDE_GESTURE_OPTIONS, s.guideGesture, enabled = s.gamepadForwarding, @@ -518,7 +589,7 @@ internal fun buildSettingsRows( ) + listOfNotNull( if (hasBodyVibrator) { toggle( - "phoneRumble", null, "Rumble on this phone", + "phoneRumble", GpTab.CONTROLLER, null, "Rumble on this phone", "Also play controller 1's rumble on this phone's own vibration motor — " + "for clip-on pads without rumble motors.", s.rumbleOnPhone, @@ -530,7 +601,7 @@ internal fun buildSettingsRows( // NOT gated on the vibrator (the bug A2 fixed in the touch settings): an SC2 capture has // nothing to do with this device's motor, and a TV box is where it matters most. toggle( - "sc2", null, "Steam Controller 2 passthrough", + "sc2", GpTab.CONTROLLER, "Passthrough", "Steam Controller 2 passthrough", "Capture a Steam Controller 2 (wired, Puck dongle, or paired Bluetooth) and stream " + "it as-is — Steam on the host drives it like the physical pad.", s.sc2Capture, enabled = s.gamepadForwarding, @@ -540,20 +611,53 @@ internal fun buildSettingsRows( // back to — could turn on SC2 passthrough but not the Sony one. Same no-vibrator-gate // reasoning: this capture renders feedback on the CONTROLLER's motors, not this device's. toggle( - "dsCapture", null, "DualSense / DualShock passthrough (USB)", + "dsCapture", GpTab.CONTROLLER, null, "DualSense / DualShock passthrough (USB)", "Drive a USB-connected Sony pad directly — rumble on any phone, plus adaptive " + "triggers, lightbar and gyro.", s.dsCapture, enabled = s.gamepadForwarding, ) { update(s.copy(dsCapture = it)) }, + + // 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 + // found in the section. + choice( + "palette", GpTab.INTERFACE, null, "Background", + "The colour family this backdrop drifts through — it changes as you step, so pick by " + + "looking. Appearance only.", + GamepadPalette.ALL.map { it.id to it.name }, + GamepadPalette.named(s.uiPalette).id, + ) { update(s.copy(uiPalette = it)) }, + 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.", + STATS_VERBOSITY_OPTIONS, s.statsVerbosity, + ) { update(s.copy(statsVerbosity = it)) }, + toggle( + "autoWake", GpTab.INTERFACE, null, "Auto-wake on connect", + "Wake a saved host with Wake-on-LAN when it isn't seen on the network, then connect.", + s.autoWakeEnabled, + ) { update(s.copy(autoWakeEnabled = it)) }, + toggle( + "library", GpTab.INTERFACE, null, "Game library", + "Browse a paired host's games with Y (experimental).", + s.libraryEnabled, + ) { update(s.copy(libraryEnabled = it)) }, + toggle( + "gamepadUI", GpTab.INTERFACE, null, "Controller-optimized UI", + "Turn off to use the touch interface even with a controller connected.", + s.gamepadUiEnabled, + ) { update(s.copy(gamepadUiEnabled = it)) }, ) } + /** * The trailing Profiles section — the Android mirror of the desktop console's (design §5.2a, §5.4): * one row per catalog profile, valued with how many saved hosts pin it, activating into the * pin-to-hosts picker. Read-only beyond pinning: profiles are created and edited in the standard * interface, so an empty catalog shows one dimmed placeholder explaining where they come from - * instead of a dead-looking empty header. On a TV that phrasing changes: "touch interface" points + * instead of a dead-looking empty tab. On a TV that phrasing changes: "touch interface" points * nowhere useful on a touchless device, so the strings name the actual route — the * 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). @@ -574,7 +678,8 @@ private fun buildProfileRows( return listOf( GpRow( id = "noProfiles", - header = "Profiles", + tab = GpTab.PROFILES, + header = null, label = "No profiles yet", value = "", detail = "Profiles bundle stream settings for different uses — pinned ones become " + @@ -586,12 +691,13 @@ private fun buildProfileRows( ), ) } - return profiles.mapIndexed { i, p -> + return profiles.map { p -> // Counted straight off the host records, so it agrees with what the carousel renders. val pins = savedHosts.count { p.id in it.pinnedProfileIds } GpRow( id = "profile:${p.id}", - header = if (i == 0) "Profiles" else null, + tab = GpTab.PROFILES, + header = null, label = p.name, value = when (pins) { 0 -> "Not pinned" diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt index 6ebca6f3..83bf4f61 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt @@ -105,6 +105,16 @@ data class Settings( * client's `libraryEnabled`. */ val libraryEnabled: Boolean = true, + /** + * Which colour family the console (gamepad) UI's living backdrop drifts through — the + * cross-client `ui_palette` key: `"violet"` (the brand default), `"tide"`, `"forest"`, + * `"ember"`, `"rose"`, `"graphite"`. See [GamepadPalette], whose table and maths mirror the + * desktop console's and the Apple client's under the same names. Presentation only: nothing + * about a stream depends on it, so it is a device preference and never part of a profile. + * An unknown value reads as the default rather than failing — a newer client may have shipped + * a palette this build doesn't know. + */ + val uiPalette: String = "violet", /** * "Low-latency mode" — the master switch over the latency pipeline: the async decode loop * (native; burst-feed + present-newest-per-vsync, the Apple client's discipline), decoder ranking @@ -284,6 +294,7 @@ class SettingsStore(context: Context) { ?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER, gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true), libraryEnabled = prefs.getBoolean(K_LIBRARY, true), + uiPalette = prefs.getString(K_UI_PALETTE, "violet") ?: "violet", lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true), presentPriority = prefs.getString(K_PRESENT_PRIORITY, "latency") ?: "latency", smoothBuffer = prefs.getInt(K_SMOOTH_BUFFER, 0), @@ -323,6 +334,7 @@ class SettingsStore(context: Context) { .putString(K_TOUCH_MODE, s.touchMode.name) .putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled) .putBoolean(K_LIBRARY, s.libraryEnabled) + .putString(K_UI_PALETTE, s.uiPalette) .putBoolean(K_LOW_LATENCY, s.lowLatencyMode) .putString(K_PRESENT_PRIORITY, s.presentPriority) .putInt(K_SMOOTH_BUFFER, s.smoothBuffer) @@ -361,6 +373,7 @@ class SettingsStore(context: Context) { const val K_TOUCH_MODE = "touch_mode" const val K_GAMEPAD_UI = "gamepad_ui_enabled" const val K_LIBRARY = "library_enabled" + const val K_UI_PALETTE = "ui_palette" /** * Bumped AGAIN to restart every install at the new default (ON). History: the original diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadPaletteTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadPaletteTest.kt new file mode 100644 index 00000000..40b40b98 --- /dev/null +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadPaletteTest.kt @@ -0,0 +1,132 @@ +package io.unom.punktfunk + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +// The console UI's background palettes. These assertions are the CONTRACT the Rust +// (`pf-console-ui::library::tint`) and Swift (`GamepadPalette.tint`) ports have to reproduce — the +// same ids, the same rotation orientation, the same in-gamut results — so one `ui_palette` value +// names the same colour family on every client. +class GamepadPaletteTest { + /** The brightest pool of the field — the colour a palette is judged by. */ + private val violetPool = Triple(0.49, 0.39, 0.95) + + /** + * The brand default must be the IDENTITY transform. Every existing install already sees the + * shipped violet backdrop, and a palette table that quietly restyled it would be a regression + * dressed as a feature. + */ + @Test + fun violetIsTheUntouchedShippedField() { + val violet = GamepadPalette.named("violet") + assertEquals("violet", GamepadPalette.ALL.first().id) + assertTrue(violet.isIdentity) + assertEquals(violetPool, violet.tint(violetPool)) + // An unknown name is a newer client's palette, not an error. + assertEquals("violet", GamepadPalette.named("chartreuse").id) + assertEquals("violet", GamepadPalette.named("").id) + } + + /** The ids and their order are the cross-client contract (strip order, and the L1/R1 cycle). */ + @Test + fun tableMatchesTheOtherClients() { + assertEquals( + listOf("violet", "tide", "forest", "ember", "rose", "graphite"), + GamepadPalette.ALL.map { it.id }, + ) + assertEquals( + listOf("Violet", "Tide", "Forest", "Ember", "Rose", "Graphite"), + GamepadPalette.ALL.map { it.name }, + ) + } + + /** + * A rotation moves the hue while roughly holding luminance, and the saturation scale collapses + * toward grey — the same four checks the Rust and Swift tests make. + */ + @Test + fun tintRotatesHueAndScalesSaturation() { + assertTrue(violetPool.third > violetPool.first && violetPool.third > violetPool.second) + + // +105° (Ember) turns the blue-dominant pool red-dominant… + val ember = GamepadPalette.named("ember").tint(violetPool) + assertTrue("$ember should be warm", ember.first > ember.third) + // …−130° (Forest) turns it green-dominant… + val forest = GamepadPalette.named("forest").tint(violetPool) + assertTrue("$forest", forest.second > forest.first && forest.second > forest.third) + // …and −70° (Tide) lands on a cyan whose green and blue both beat red. + val tide = GamepadPalette.named("tide").tint(violetPool) + assertTrue("$tide", tide.second > tide.first && tide.third > tide.first) + + // Graphite's saturation scale leaves the channels nearly equal… + val grey = GamepadPalette.named("graphite").tint(violetPool) + val channels = listOf(grey.first, grey.second, grey.third) + assertTrue("$grey", channels.max() - channels.min() < 0.08) + // …at about the source's luminance (it desaturates, it doesn't dim). + val luma = 0.2126 * violetPool.first + 0.7152 * violetPool.second + 0.0722 * violetPool.third + assertEquals(luma, grey.second, 0.05) + } + + /** + * Every palette stays in gamut on every colour the field is built from — an out-of-range + * channel would clamp differently on each platform's rasteriser. + */ + @Test + fun everyPaletteStaysInGamut() { + val field = listOf( + Triple(0.075, 0.060, 0.160), Triple(0.34, 0.27, 0.72), Triple(0.30, 0.26, 0.74), + 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.22, 0.18, 0.54), + Triple(0.24, 0.20, 0.58), + ) + for (palette in GamepadPalette.ALL) { + for (c in field) { + val t = palette.tint(c) + for (v in listOf(t.first, t.second, t.third)) { + assertTrue("${palette.id} $c → $t", v in 0.0..1.0) + } + } + } + } + + /** + * Every settings row lands in exactly one tab — a row missing from the tab map is a setting + * that became unreachable on a TV, which is precisely what this screen exists to prevent. + */ + @Test + fun everySettingsRowHasATab() { + val rows = buildSettingsRows(Settings(), hasBodyVibrator = true, av1Capable = true) {} + assertTrue(rows.isNotEmpty()) + assertEquals(rows.size, rows.map { it.id }.toSet().size) + // Profiles is built separately (from the catalog), so no settings row claims it. + assertTrue(rows.none { it.tab == GpTab.PROFILES }) + for (t in listOf(GpTab.STREAM, GpTab.VIDEO, GpTab.AUDIO, GpTab.CONTROLLER, GpTab.INTERFACE)) { + assertTrue("$t is empty", rows.any { it.tab == t }) + } + } + + /** The Background row steps the shared `ui_palette` key and wraps on A, like every choice row. */ + @Test + fun backgroundRowStepsTheSharedKey() { + var s = Settings() + fun rows() = buildSettingsRows(s, hasBodyVibrator = false, av1Capable = false) { s = it } + fun palette() = rows().first { it.id == "palette" } + + assertEquals("violet", s.uiPalette) + assertEquals("Violet", palette().value) + assertTrue("already the first = thud", !palette().adjust(-1)) + assertTrue(palette().adjust(1)) + assertEquals(GamepadPalette.ALL[1].id, s.uiPalette) + + // A from the last entry wraps home. + s = s.copy(uiPalette = GamepadPalette.ALL.last().id) + palette().activate() + assertEquals("violet", s.uiPalette) + + // A store written by a newer client shows the palette that is actually drawing. + s = s.copy(uiPalette = "chartreuse") + assertEquals("Violet", palette().value) + } +} diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt index 3fe894ea..70dd0e57 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt @@ -106,6 +106,9 @@ class ScreenshotTest { @Test fun connectingConsole() = shootRoot("connecting-console") { ConnectConsoleScene() } + @Test + fun consoleSettings() = shootRoot("console-settings") { ConsoleSettingsScene() } + @Test fun trust() = shootScreen("trust") { HostsScene() diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt index a7845699..43562f37 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt @@ -31,6 +31,7 @@ import io.unom.punktfunk.BrandDark import io.unom.punktfunk.ConnectModal import io.unom.punktfunk.ConnectPhase import io.unom.punktfunk.ConnectTakeover +import io.unom.punktfunk.GamepadSettingsScreen import io.unom.punktfunk.Settings import io.unom.punktfunk.TouchMode import io.unom.punktfunk.SettingsCategory @@ -406,3 +407,13 @@ internal fun WakeTimedOutScene() = @Composable internal fun ConnectConsoleScene() = ConnectTakeover(ConnectPhase.Connecting("Living Room PC"), onCancel = {}, onRetry = {}) + +/** + * The real console settings screen — the section tab strip, the glass rows, the focused row's + * unfolded detail, and the living (calmed) backdrop behind them. The touch [SettingsScene] can't + * stand in for it: this is a different screen with different navigation, and the strip is the part + * a layout regression would eat first. + */ +@Composable +internal fun ConsoleSettingsScene() = + GamepadSettingsScreen(initial = SHOT_SETTINGS, onChange = {}, onBack = {}) diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift index be9c6940..80c5390e 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift @@ -122,12 +122,21 @@ struct GamepadHintBar: View { } } -/// The console backdrop: a living aurora in the brand's violet family, 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 elliptical vignette (pools light in the centre, sinks -/// the corners) and a top/bottom legibility scrim. Older OSes fall back to the original drifting -/// radial-blob field, unchanged, so nothing regresses. +/// 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 +/// elliptical vignette (pools light in the centre, sinks the corners) and a top/bottom legibility +/// scrim. Older OSes fall back to the original drifting radial-blob field, unchanged, so nothing +/// regresses. +/// +/// `calm` is what the FORM screens (settings, add-host) wear: the same living field with its pools +/// dimmed onto its own corner colour, so those screens keep real colour under their Liquid Glass +/// rows without the launcher's contrast. They used to sit on a still gradient; nothing in the +/// gamepad UI is backed by a static image now. Motion is identical in both modes on purpose — only +/// the contrast differs, so a screen change can't make the field jump. +/// +/// `GamepadPalette` recolours the whole thing (the shared `ui_palette` setting) by transforming the +/// COLOURS, not by stacking a filter — see GamepadPalette.swift for why. /// /// Deliberately pure SwiftUI, no `.metal`: these sources build under both SwiftPM (`swift run`/ /// tests) and the Xcode project's synchronized folders, and a compiled metallib is only reliably @@ -136,35 +145,52 @@ struct GamepadHintBar: View { /// can't inflate the caller's layout past the safe area (see the layout note in GamepadHomeView's /// header). Honors Reduce Motion by freezing the field at a fixed phase. struct GamepadScreenBackground: View { + /// Quiet the field for a form screen (see the type comment). + var calm = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet" var body: some View { + let palette = GamepadPalette.named(paletteID) Group { if reduceMotion { - composite(at: 0) + composite(at: 0, palette: palette) } else { // 30 Hz is plenty for a field that drifts centimetres per minute, and halves the // redraw cost of a battery-fed couch device vs. the display's native rate. TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in - composite(at: context.date.timeIntervalSinceReferenceDate) + composite(at: context.date.timeIntervalSinceReferenceDate, palette: palette) } } } .ignoresSafeArea() } - /// The colour field under a very slow warm/cool hue sway, an elliptical vignette, and the - /// title/hints legibility scrim. - private func composite(at t: TimeInterval) -> some View { + /// The colour field under a very slow warm/cool hue sway, the calm flattening, an elliptical + /// vignette, and the title/hints legibility scrim — in that order, matching the console + /// shader's `composite` so the two platforms' backdrops stay the same picture. + private func composite(at t: TimeInterval, palette: GamepadPalette) -> some View { ZStack { Color.black - colorField(at: t) + 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 + corner·0.4: over black, `.opacity` IS the multiply… + .opacity(calm ? 0.6 : 1) + if calm { + // …and a plusLighter wash of the palette's own corner colour IS the add. Chosen so + // a corner lands exactly where it was and the bright pools come down to meet it. + Self.color(palette.tint(Self.cornerRGB)) + .opacity(0.4) + .blendMode(.plusLighter) + } // Cinematic vignette: darker toward the edges so the cards sit in the pooled light. // Soft (extends past the frame) so the corners deepen rather than crush to black. + // 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 to black just eats them. EllipticalGradient( - colors: [.clear, .black.opacity(0.42)], + colors: [.clear, .black.opacity(calm ? 0.21 : 0.42)], center: .center, startRadiusFraction: 0.25, endRadiusFraction: 1.15) // Legibility grounding for the pinned title (top) and hint pill (bottom). This one // darkens the aurora itself (it's the backdrop's bottom layer — nothing behind it to @@ -180,33 +206,45 @@ struct GamepadScreenBackground: View { } } - @ViewBuilder private func colorField(at t: TimeInterval) -> some View { + @ViewBuilder private func colorField(at t: TimeInterval, palette: GamepadPalette) -> some View { if #available(iOS 18, macOS 15, tvOS 18, *) { MeshGradient( width: 4, height: 4, points: Self.meshPoints(at: t), - colors: Self.meshColors, + colors: Self.meshColors(palette), smoothsColors: true) } else { - LegacyBlobField(t: t) + LegacyBlobField(t: t, palette: palette) } } // MARK: - MeshGradient aurora (iOS 18 / macOS 15+) + static func color(_ c: SIMD3) -> Color { + Color(red: c.x, green: c.y, blue: c.z) + } + + /// The corner colour — the four pinned corners AND the calm lift's base. + static let cornerRGB = SIMD3(0.075, 0.060, 0.160) + /// Sixteen mesh colours (row-major, 4×4): dark-violet corners sink the frame, the edges carry /// mid-tone violets, and the four interior points hold the bright brand family — a violet and a /// blue-violet up top, a magenta-violet and a violet below — so warm pools on the left, cool on - /// the right, and the silk shifts temperature as those interior points drift. - private static let meshColors: [Color] = { - let corner = Color(red: 0.075, green: 0.060, blue: 0.160) - return [ - corner, Color(red: 0.34, green: 0.27, blue: 0.72), Color(red: 0.30, green: 0.26, blue: 0.74), corner, - Color(red: 0.42, green: 0.20, blue: 0.54), Color(red: 0.49, green: 0.39, blue: 0.95), Color(red: 0.28, green: 0.31, blue: 0.84), Color(red: 0.16, green: 0.26, blue: 0.64), - Color(red: 0.45, green: 0.23, blue: 0.60), Color(red: 0.53, green: 0.31, blue: 0.75), Color(red: 0.35, green: 0.35, blue: 0.91), Color(red: 0.19, green: 0.28, blue: 0.70), - corner, Color(red: 0.22, green: 0.18, blue: 0.54), Color(red: 0.24, green: 0.20, blue: 0.58), corner, - ] - }() + /// the right, and the silk shifts temperature as those interior points drift. A palette rotates + /// the whole grid; `violet` is the identity, so this array IS what the default draws. + private static let baseMeshRGB: [SIMD3] = [ + cornerRGB, SIMD3(0.34, 0.27, 0.72), SIMD3(0.30, 0.26, 0.74), cornerRGB, + SIMD3(0.42, 0.20, 0.54), SIMD3(0.49, 0.39, 0.95), SIMD3(0.28, 0.31, 0.84), SIMD3(0.16, 0.26, 0.64), + SIMD3(0.45, 0.23, 0.60), SIMD3(0.53, 0.31, 0.75), SIMD3(0.35, 0.35, 0.91), SIMD3(0.19, 0.28, 0.70), + cornerRGB, SIMD3(0.22, 0.18, 0.54), SIMD3(0.24, 0.20, 0.58), cornerRGB, + ] + + /// `baseMeshRGB` under a palette. Recomputed per frame rather than cached — sixteen `tint` + /// calls at 30 Hz costs nothing next to rasterising the mesh, and the obvious cache would be + /// mutable global state on a type SwiftUI is free to evaluate off the main actor. + private static func meshColors(_ palette: GamepadPalette) -> [Color] { + baseMeshRGB.map { color(palette.tint($0)) } + } /// The 4×4 control points at time `t`: every boundary point is PINNED to the frame (so the mesh /// always fills edge-to-edge — a drifting edge point would shrink the mesh and expose the black @@ -233,15 +271,18 @@ struct GamepadScreenBackground: View { } /// Pre-18/15 fallback for `GamepadScreenBackground`: the original drifting radial-blob field — four -/// soft colour blobs on slow Lissajous paths, additively blended. Kept verbatim so older OSes see -/// exactly the aurora they shipped with (the mesh path is the upgrade for OS 18/15+). +/// soft colour blobs on slow Lissajous paths, additively blended. Geometry and motion are verbatim +/// so older OSes see exactly the aurora they shipped with (the mesh path is the upgrade for OS +/// 18/15+); only the blob COLOURS now pass through the palette, so an older device honours the +/// setting too instead of being stuck on violet. private struct LegacyBlobField: View { let t: TimeInterval + let palette: GamepadPalette /// One drifting color blob: a base position + drift ellipse (unit coordinates), angular speeds /// (rad/s — periods of 30–90 s), and a radius that slowly breathes. private struct Blob { - let color: Color + let rgb: SIMD3 let center: CGPoint let drift: CGSize let speed: (x: Double, y: Double) @@ -252,19 +293,19 @@ private struct LegacyBlobField: View { } private static let blobs: [Blob] = [ - Blob(color: Color(red: 0.53, green: 0.47, blue: 0.96), // brand violet + Blob(rgb: SIMD3(0.53, 0.47, 0.96), // brand violet center: CGPoint(x: 0.30, y: 0.24), drift: CGSize(width: 0.16, height: 0.10), speed: (0.111, 0.083), phase: (0.0, 1.9), radius: 0.52, breathe: (0.07, 0.061), opacity: 0.52), - Blob(color: Color(red: 0.24, green: 0.20, blue: 0.72), // deep indigo + Blob(rgb: SIMD3(0.24, 0.20, 0.72), // deep indigo center: CGPoint(x: 0.78, y: 0.66), drift: CGSize(width: 0.13, height: 0.14), speed: (0.071, 0.096), phase: (2.4, 0.7), radius: 0.58, breathe: (0.08, 0.049), opacity: 0.55), - Blob(color: Color(red: 0.62, green: 0.30, blue: 0.80), // plum + Blob(rgb: SIMD3(0.62, 0.30, 0.80), // plum center: CGPoint(x: 0.16, y: 0.82), drift: CGSize(width: 0.12, height: 0.09), speed: (0.089, 0.067), phase: (4.1, 3.2), radius: 0.44, breathe: (0.09, 0.078), opacity: 0.42), - Blob(color: Color(red: 0.22, green: 0.38, blue: 0.86), // cool blue + Blob(rgb: SIMD3(0.22, 0.38, 0.86), // cool blue center: CGPoint(x: 0.70, y: 0.12), drift: CGSize(width: 0.10, height: 0.08), speed: (0.059, 0.104), phase: (1.2, 5.0), radius: 0.40, breathe: (0.06, 0.055), opacity: 0.38), @@ -287,9 +328,10 @@ private struct LegacyBlobField: View { let y = blob.center.y + blob.drift.height * CGFloat(cos(t * blob.speed.y + blob.phase.y)) let r = side * blob.radius * (1 + blob.breathe.amount * CGFloat(sin(t * blob.breathe.speed + blob.phase.x))) + let color = GamepadScreenBackground.color(palette.tint(blob.rgb)) return Circle() .fill(RadialGradient( - colors: [blob.color, blob.color.opacity(0)], + colors: [color, color.opacity(0)], center: .center, startRadius: 0, endRadius: r / 2)) .frame(width: r, height: r) .position(x: x * size.width, y: y * size.height) @@ -330,27 +372,16 @@ struct GamepadTrayScrim: View { } } -/// The calm backdrop for the gamepad UI's form screens (settings, add-host) — NOT the launcher's -/// drifting aurora (this stays still and quiet), but deliberately NOT near-black either: Liquid -/// Glass refracts whatever sits behind it, so over black the rows turn invisible. A deep indigo -/// base plus two soft, static violet/indigo glows give the glass real colour and luminance to lens, -/// so the rows read as glass while the screen stays restful. +/// 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 +/// living field at `calm`, which keeps that luminance under the glass, keeps the palette setting +/// honoured on every screen rather than only the launcher, and leaves nothing in the gamepad UI +/// backed by a static image. Kept as its own type because that is what the form screens ask for by +/// name; the console (`pf-console-ui`) made the same substitution behind its `Bg::Form`. struct GamepadFormBackground: View { var body: some View { - ZStack { - Color(red: 0.075, green: 0.062, blue: 0.150) - // Violet lift top-leading, cooler indigo bottom-trailing — resolution-independent - // (fraction radii) so the glow scale tracks the window on any screen. - EllipticalGradient( - colors: [Color(red: 0.40, green: 0.31, blue: 0.68).opacity(0.9), .clear], - center: UnitPoint(x: 0.26, y: 0.14), - startRadiusFraction: 0, endRadiusFraction: 0.78) - EllipticalGradient( - colors: [Color(red: 0.20, green: 0.24, blue: 0.58).opacity(0.75), .clear], - center: UnitPoint(x: 0.82, y: 0.9), - startRadiusFraction: 0, endRadiusFraction: 0.78) - } - .ignoresSafeArea() + GamepadScreenBackground(calm: true) } } diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadMenuList.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadMenuList.swift index bf916fc4..e156eb40 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadMenuList.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadMenuList.swift @@ -35,6 +35,10 @@ struct GamepadMenuList: View where Item.ID: Hasha let onActivate: (Item) -> Void /// B → back/dismiss; nil disables it. var onBack: (() -> Void)? + /// L1 (`-1`) / R1 (`+1`) — a step SIDEWAYS out of the list: the settings screen's section + /// tabs. Wired on tvOS too, where the focus engine owns up/down but leaves the shoulders + /// to the poll. nil ⇒ the shoulders do nothing. + var onShoulder: ((Int) -> Void)? /// Whether this list currently owns controller input — same handoff contract as /// GamepadCarousel's `isActive` (a covered screen must stop polling the shared pad). var isActive: Bool = true @@ -159,6 +163,7 @@ struct GamepadMenuList: View where Item.ID: Hasha case .up, .down: break } } + input.onShoulder = { forward in onShoulder?(forward ? 1 : -1) } #else input.onMove = { direction in switch direction { @@ -170,6 +175,7 @@ struct GamepadMenuList: View where Item.ID: Hasha } input.onConfirm = { activate() } input.onBack = onBack + input.onShoulder = { forward in onShoulder?(forward ? 1 : -1) } #endif } diff --git a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift index 09274f1f..355c72d7 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift @@ -11,7 +11,13 @@ // the thumb it's the last option); A always cycles forward, wrapping, so every option is reachable // with one button. Toggles read left = off, right = on — refusing a no-op with the same thud. // -// The trailing Profiles section (design/client-settings-profiles.md §5.2a/§5.4) is the pin manager +// The rows are split across SECTION TABS (`GpSettingsTab`) — L1/R1 on a pad, a tap elsewhere. They +// used to be one long scroll with inline group headers, which meant thumbing past Video and Audio +// to reach the controller settings; a tab is one shoulder press, and each tab remembers where its +// focus was. The tab names match the desktop console's and the Android client's, so a setting is +// found under the same word wherever you look for it. +// +// The trailing Profiles tab (design/client-settings-profiles.md §5.2a/§5.4) is the pin manager // for this controller-first surface: a row per catalog profile opens the pin-to-hosts picker — an // in-place swap of the row list (B peels back, the "one layer" rule GamepadAddHostView set) with // one toggle row per saved host, writing `StoredHost.pinnedProfileIDs` via HostStore.setPinned. @@ -27,6 +33,17 @@ import GameController import CoreHaptics #endif +/// The settings screen's sections. Order IS the strip order and the L1/R1 cycle order; the names +/// match `pf-console-ui`'s `TABS` and the Android client's `GpTab`. +enum GpSettingsTab: String, CaseIterable, Hashable { + case stream = "Stream" + case video = "Video" + case audio = "Audio" + case controller = "Controller" + case interface = "Interface" + case profiles = "Profiles" +} + struct GamepadSettingsView: View { @Environment(\.dismiss) private var dismiss /// The saved-host store — the pin picker writes `setPinned` through it and the profile rows @@ -55,6 +72,9 @@ struct GamepadSettingsView: View { @AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue @AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true @AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true + /// The gamepad UI's background colour family — the backdrop BEHIND this screen re-colours as + /// the row steps, which is why the picker lives here and not in a sheet. + @AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet" @AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true @AppStorage(DefaultsKey.presentPriority) private var presentPriority = SettingsOptions.presentPriorityDefault @@ -74,12 +94,23 @@ struct GamepadSettingsView: View { #if os(iOS) /// `.compact` in a landscape phone window — tighter chrome so more rows fit. @Environment(\.verticalSizeClass) private var vSizeClass + /// `.regular` only on an iPad-class window — see `showsSectionHint`. + @Environment(\.horizontalSizeClass) private var hSizeClass private var compact: Bool { vSizeClass == .compact } #else private let compact = false // no size classes on macOS; the sheet is sized generously #endif @State private var focusID: String? + /// The section showing. The pin picker ignores it — that layer replaces the whole list. + @State private var tab: GpSettingsTab = .stream + /// Where each tab's focus was when it was last left, so a detour doesn't lose your place. + @State private var tabFocus: [GpSettingsTab: String] = [:] + @Namespace private var tabHighlight + #if os(tvOS) + /// Real focus on the strip — the tvOS route to the sections (see `tabStrip`). + @FocusState private var focusedTab: GpSettingsTab? + #endif /// The pin-to-hosts picker's profile — non-nil swaps the row list for one toggle row per /// saved host (§5.2a); B (Menu on tvOS) peels back to the settings rows. @State private var pinTarget: StreamProfile? @@ -93,7 +124,8 @@ struct GamepadSettingsView: View { focusID: $focusID, onAdjust: { row, delta in adjust(id: row.id, by: delta) }, onActivate: { activate(id: $0.id) }, - onBack: { back() } + onBack: { back() }, + onShoulder: { step(tabBy: $0) } ) { row, focused in rowView(row, focused: focused) .frame(maxWidth: GamepadFormMetrics.rowMaxWidth) @@ -101,14 +133,19 @@ struct GamepadSettingsView: View { } .frame(maxWidth: .infinity) .safeAreaInset(edge: .top, spacing: 0) { - Text(title) - .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title)) - .foregroundStyle(.white) - .padding(.top, gamepadTitleTopPadding(compact: compact)) - .padding(.bottom, compact ? 4 : 8) - .frame(maxWidth: .infinity) - .overlay(alignment: .trailing) { closeButton.padding(.trailing, 20) } - .background { GamepadTrayScrim(edge: .top) } + VStack(spacing: compact ? 4 : 8) { + Text(title) + .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .overlay(alignment: .trailing) { closeButton.padding(.trailing, 20) } + // The picker is one layer deeper — its rows aren't sections of anything, so the + // strip would be a control that does nothing while it's up. + if pinTarget == nil { tabStrip } + } + .padding(.top, gamepadTitleTopPadding(compact: compact)) + .padding(.bottom, compact ? 4 : 8) + .background { GamepadTrayScrim(edge: .top) } } .safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 8) { @@ -127,8 +164,9 @@ struct GamepadSettingsView: View { .frame(maxWidth: .infinity, alignment: .leading) .background { GamepadTrayScrim(edge: .bottom) } } - // No aurora here — the settings read as clean Liquid Glass over a quiet dark base, so the - // glass rows are the only material on the screen. + // 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 + // applies here too, so this screen previews the row you're stepping. .background { GamepadFormBackground() } .onAppear { gamepads.refresh() @@ -137,6 +175,101 @@ struct GamepadSettingsView: View { .onDisappear { gamepads.stopDiscovery() } } + /// The section switcher. Horizontally scrollable so a narrow phone in landscape never has to + /// squeeze six pills — the selected one is always scrolled into view, whether it was reached + /// by shoulder button, tap, or (tvOS) the focus engine. + private var tabStrip: some View { + ScrollViewReader { proxy in + ScrollView(.horizontal) { + HStack(spacing: 6) { + ForEach(GpSettingsTab.allCases, id: \.self) { t in + #if os(tvOS) + // Focusable, because L1/R1 is NOT a route here: a Siri Remote has no + // extended gamepad profile, so it never reaches GamepadMenuList's poll. + // As focusable Buttons the pills are simply above the rows, and moving + // focus up onto one switches section — the standard tvOS tab bar. + Button { select(tab: t) } label: { pill(t) } + .buttonStyle(ConsoleBareButtonStyle()) + .focused($focusedTab, equals: t) + .id(t) + #else + pill(t) + .contentShape(Capsule()) + .onTapGesture { select(tab: t) } + .id(t) + #endif + } + } + .padding(.horizontal, 24) + } + .scrollIndicators(.never) + .animation(.smooth(duration: 0.22), value: tab) + .onChange(of: tab) { _, t in + withAnimation(.easeOut(duration: 0.2)) { proxy.scrollTo(t) } + } + #if os(tvOS) + .onChange(of: focusedTab) { _, t in + // Focus IS selection on a tab bar; nil means focus dropped back into the rows. + if let t { select(tab: t) } + } + #endif + } + } + + 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 ? .white : .white.opacity(0.55)) + .padding(.horizontal, 13) + .padding(.vertical, 7) + .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. + if selected { + Capsule() + .fill(Color.brand.opacity(0.85)) + .matchedGeometryEffect(id: "tab", in: tabHighlight) + } + } + } + + /// Whether the legend advertises the shoulder shortcut. Held back on an iPhone, whose legend + /// is already at its width and would push "Done" off the edge — the strip is visible and + /// tappable there anyway. Never on tvOS: a Siri Remote has no shoulders, and its route to the + /// sections is the focus engine (see `tabStrip`). + private var showsSectionHint: Bool { + #if os(tvOS) + false + #elseif os(iOS) + hSizeClass == .regular + #else + true + #endif + } + + /// L1/R1 — one section along, wrapping (the strip is a ring, like A's value cycle). + private func step(tabBy delta: Int) { + guard pinTarget == nil else { return } + let all = GpSettingsTab.allCases + guard let i = all.firstIndex(of: tab) else { return } + let n = all.count + select(tab: all[((i + delta) % n + n) % n]) + } + + private func select(tab next: GpSettingsTab) { + guard next != tab else { return } + tabFocus[tab] = focusID + // Restore where this tab was, if that row is still in it (a row can come and go with the + // hardware it depends on); otherwise the focus list seeds its first row. Resolved against + // `allRows` rather than `rows` so it doesn't depend on `tab`'s write being visible yet. + let landing = tabFocus[next].flatMap { id in + allRows.contains { $0.tab == next && $0.id == id } ? id : nil + } + tab = next + focusID = landing + } + /// Touch/click fallback for closing — the controller path is B, a hardware keyboard's Esc /// rides the cancel action. private var closeButton: some View { @@ -166,12 +299,19 @@ struct GamepadSettingsView: View { /// layer" rule), and a hostless picker has nothing to pin, so only Back remains. private var hints: [GamepadHint] { guard pinTarget != nil else { + // The shoulders change section, so that cell leads — where it fits and where the + // shoulders exist at all (see `showsSectionHint`). + let sections: [GamepadHint] = showsSectionHint + ? [.init(glyph: buttonGlyph(\.leftShoulder, fallback: "l1.rectangle.roundedbottom"), + text: "Section")] + : [] // 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 [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done")] + return sections + + [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done")] } - return [ + return sections + [ .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"), @@ -201,15 +341,9 @@ struct GamepadSettingsView: View { private func rowView(_ row: Row, focused: Bool) -> some View { let m = GamepadFormMetrics.self + // 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) { - if let header = row.header { - Text(header) - .font(.geist(m.headerFont, .semibold, relativeTo: .caption)) - .tracking(1.4) - .foregroundStyle(.white.opacity(0.45)) - .padding(.leading, m.rowHPad) - .padding(.top, 14) - } HStack(spacing: 14) { Image(systemName: row.icon) .font(.system(size: m.iconFont)) @@ -276,8 +410,9 @@ struct GamepadSettingsView: View { private struct Row: Identifiable { let id: String - /// Section header drawn above this row (the first row of each group carries it). - var header: String? + /// Which section tab this row belongs to. Every row has exactly one, and `rows` shows + /// only the current tab's — see `allRows`. + var tab: GpSettingsTab = .stream let icon: String let label: String let value: String @@ -313,10 +448,17 @@ struct GamepadSettingsView: View { row.activate() } + /// What the focus list actually shows: the current tab's rows — or the pin picker's, which + /// replaces the whole list while it's up (same screen, one layer deeper, so the focus list's + /// controller wiring and the tvOS focus engine carry over as is). private var rows: [Row] { - // The pin picker replaces the whole list while it's up — same screen, one layer deeper, - // so the focus list's controller wiring (and the tvOS focus engine) carries over as is. if let profile = pinTarget { return pinRows(for: profile) } + return allRows.filter { $0.tab == tab } + } + + /// Every row on the screen, tagged with its section. Built as one list (not per tab) so the + /// platform-conditional insertions below can still place a row RELATIVE to another by id. + private var allRows: [Row] { let resolution = resolutionOptions let refresh = SettingsOptions.refreshRates(including: hz) .map { (label: "\($0) Hz", tag: $0) } @@ -324,7 +466,7 @@ struct GamepadSettingsView: View { let controllers = SettingsOptions.controllerOptions(gamepads) var list: [Row] = [ choiceRow( - id: "resolution", header: "Stream", icon: "aspectratio", + id: "resolution", tab: .stream, icon: "aspectratio", label: "Resolution", detail: "The host creates a virtual display at exactly this size — no scaling.", options: resolution, current: "\(width)x\(height)" @@ -335,53 +477,48 @@ struct GamepadSettingsView: View { height = parts[1] }, choiceRow( - id: "refresh", icon: "gauge.with.needle", label: "Refresh rate", + id: "refresh", tab: .stream, icon: "gauge.with.needle", label: "Refresh rate", detail: "Rates this display can actually show.", options: refresh, current: hz ) { hz = $0 }, choiceRow( - id: "bitrate", icon: "speedometer", label: "Bitrate", + id: "bitrate", tab: .stream, icon: "speedometer", label: "Bitrate", detail: "Automatic uses the host's default (20 Mbps). " + "Run a speed test from the touch UI for an informed value.", options: bitrate, current: bitrateKbps ) { bitrateKbps = $0 }, choiceRow( - id: "compositor", icon: "macwindow", label: "Compositor", + id: "compositor", tab: .stream, icon: "macwindow", label: "Compositor", detail: "Which compositor drives the virtual output — honored only if " + "available on the host.", options: SettingsOptions.compositors, current: compositor ) { compositor = $0 }, - toggleRow( - id: "autoWake", icon: "power", label: "Auto-wake on connect", - detail: "Send Wake-on-LAN to a sleeping saved host and wait for it before " - + "streaming. Off connects straight through.", - value: $autoWakeEnabled), - choiceRow( - id: "codec", header: "Video", icon: "film", label: "Video codec", + id: "codec", tab: .video, icon: "film", label: "Video codec", detail: "A preference — the host falls back if it can't encode this one " + "(10-bit and 4:4:4 are HEVC-only).", options: SettingsOptions.codecs, current: codec ) { codec = $0 }, toggleRow( - id: "hdr", icon: "sun.max", label: "10-bit HDR", + id: "hdr", tab: .video, icon: "sun.max", label: "10-bit HDR", detail: "HDR10 — engages when the host sends HDR content and this display " + "supports it.", value: $hdrEnabled), toggleRow( - id: "chroma", icon: "textformat", label: "Full chroma (4:4:4)", + id: "chroma", tab: .video, icon: "textformat", label: "Full chroma (4:4:4)", detail: "Sharper text and UI at more bandwidth — needs host opt-in and " + "hardware decode.", value: $enable444), choiceRow( - id: "presentPriority", icon: "rectangle.stack", label: "Prioritize", + id: "presentPriority", tab: .video, icon: "rectangle.stack", label: "Prioritize", detail: "Lowest latency shows each frame the moment the display can take it; " + "Smoothness buffers a few frames to even out network hiccups. Applies " + "from the next session.", options: SettingsOptions.presentPriorities, current: presentPriority ) { presentPriority = $0 }, choiceRow( - id: "smoothBuffer", icon: "square.stack.3d.up", label: "Smoothness buffer", + id: "smoothBuffer", tab: .video, icon: "square.stack.3d.up", + label: "Smoothness buffer", detail: "How many frames Smoothness holds — each adds about a refresh of " + "display latency and absorbs about a refresh of jitter. Only applies " + "when prioritizing smoothness.", @@ -389,22 +526,22 @@ struct GamepadSettingsView: View { ) { smoothBuffer = $0 }, choiceRow( - id: "audio", header: "Audio", icon: "speaker.wave.2", label: "Audio channels", + id: "audio", tab: .audio, icon: "speaker.wave.2", label: "Audio channels", detail: "The speaker layout requested from the host.", options: SettingsOptions.audioChannels, current: audioChannels ) { audioChannels = $0 }, toggleRow( - id: "mic", icon: "mic", label: "Microphone", + id: "mic", tab: .audio, icon: "mic", label: "Microphone", detail: "Send this device's microphone to the host's virtual mic.", value: $micEnabled), toggleRow( - id: "echoCancel", icon: "waveform", label: "Echo cancellation", + id: "echoCancel", tab: .audio, icon: "waveform", label: "Echo cancellation", detail: "Cancel the audio this device plays out of the mic signal — stops " + "speaker setups feeding the game back to the host.", value: $echoCancel), toggleRow( - id: "padForward", header: "Controller", icon: "gamecontroller", + id: "padForward", tab: .controller, icon: "gamecontroller", label: "Forward controllers", detail: "Send this device's controllers to the host. Turn it off when your " + "controller already reaches the host another way — USB passthrough such " @@ -415,26 +552,28 @@ struct GamepadSettingsView: View { // `.disabled(!effective.gamepadForwarding)`. This screen could not express it until // `Row.enabled` existed, so it alone left them live and steppable. choiceRow( - id: "pad", icon: "gamecontroller", label: "Use controller", + id: "pad", tab: .controller, icon: "gamecontroller", label: "Use controller", detail: "Which pad is forwarded to the host, as player 1.", options: controllers, current: gamepads.preferredID, enabled: gamepadForwarding ) { gamepads.preferredID = $0 }, choiceRow( - id: "padType", icon: "dpad", label: "Controller type", + id: "padType", tab: .controller, icon: "dpad", label: "Controller type", detail: "The virtual pad the host creates — Automatic matches this controller.", options: SettingsOptions.padTypes, current: gamepadType, enabled: gamepadForwarding ) { gamepadType = $0 }, choiceRow( - id: "systemButtons", icon: "house.circle", label: "Guide button", + id: "systemButtons", tab: .controller, icon: "house.circle", + label: "Guide button", detail: "Where the guide (Xbox/PS) and share presses go while streaming — " + "Automatic sends them to the host whenever this device delivers them.", options: SettingsOptions.systemButtons, current: systemButtons, enabled: gamepadForwarding ) { systemButtons = $0 }, choiceRow( - id: "guideGesture", icon: "hand.point.up.left", label: "Hold Select for guide", + id: "guideGesture", tab: .controller, icon: "hand.point.up.left", + label: "Hold Select for guide", detail: "Hold Select alone to press the host's guide button — keep holding " + "for a Gaming-Mode host's quick-access menu. A tap still goes through.", options: SettingsOptions.guideGestures, current: guideGesture, @@ -442,33 +581,47 @@ struct GamepadSettingsView: View { ) { guideGesture = $0 }, choiceRow( - id: "hud", header: "Interface", icon: "chart.bar", label: "Statistics overlay", + id: "palette", tab: .interface, icon: "paintpalette", label: "Background", + detail: "The colour family this backdrop drifts through — it changes as you " + + "step, so pick by looking. Appearance only.", + options: GamepadPalette.all.map { (label: $0.name, tag: $0.id) }, + current: GamepadPalette.named(paletteID).id + ) { paletteID = $0 }, + toggleRow( + id: "autoWake", tab: .interface, icon: "power", label: "Auto-wake on connect", + detail: "Send Wake-on-LAN to a sleeping saved host and wait for it before " + + "streaming. Off connects straight through.", + value: $autoWakeEnabled), + choiceRow( + id: "hud", tab: .interface, icon: "chart.bar", label: "Statistics overlay", detail: "How much to show while streaming — Compact is a one-line pill, " + "Detailed adds the latency stage breakdown.", options: SettingsOptions.statsVerbosities, current: statsVerbosityRaw ) { statsVerbosityRaw = $0 }, choiceRow( - id: "hudPlacement", icon: "rectangle.inset.topright.filled", label: "Overlay position", + id: "hudPlacement", tab: .interface, icon: "rectangle.inset.topright.filled", + label: "Overlay position", detail: "Which corner the statistics overlay sits in.", options: SettingsOptions.hudPlacements, current: hudPlacement ) { hudPlacement = $0 }, toggleRow( - id: "library", icon: "square.grid.2x2", label: "Game library", + id: "library", tab: .interface, icon: "square.grid.2x2", label: "Game library", detail: "Browse and launch the host's games with \(buttonName(\.buttonY, "Y")).", value: $libraryEnabled), toggleRow( - id: "gamepadUI", icon: "hand.tap", label: "Controller-optimized UI", + id: "gamepadUI", tab: .interface, icon: "hand.tap", + label: "Controller-optimized UI", detail: "Turn off to use the touch interface even with a controller connected.", value: $gamepadUIEnabled), ] #if os(macOS) // The windowed safe-present toggle slots in after "Smoothness buffer" (staying inside - // the Video group) — macOS only, mirroring the touch SettingsView's Presentation row + // the Video tab) — macOS only, mirroring the touch SettingsView's Presentation row // (the DCP swapID-panic mitigation; see DefaultsKey.windowedSafePresent). if let at = list.firstIndex(where: { $0.id == "smoothBuffer" }) { list.insert( toggleRow( - id: "windowedSafePresent", icon: "macwindow.badge.plus", + id: "windowedSafePresent", tab: .video, icon: "macwindow.badge.plus", label: "Safe windowed presentation", detail: "Windowed streams present in step with the compositor — avoids a " + "macOS display-driver crash on high-refresh displays, at a small " @@ -478,14 +631,14 @@ struct GamepadSettingsView: View { } #endif #if os(iOS) - // The device-rumble mirror slots in after "Controller type" (staying inside the - // Controller group — the next row carries the "Interface" header). iPhone only in - // practice: hidden where the device itself can't play haptics (iPad). + // The device-rumble mirror slots in after "Controller type", inside the Controller tab. + // iPhone only in practice: hidden where the device itself can't play haptics (iPad). if CHHapticEngine.capabilitiesForHardware().supportsHaptics, let at = list.firstIndex(where: { $0.id == "padType" }) { list.insert( toggleRow( - id: "deviceRumble", icon: "iphone.radiowaves.left.and.right", + id: "deviceRumble", tab: .controller, + icon: "iphone.radiowaves.left.and.right", label: "Rumble on this iPhone", detail: "Also play player 1's rumble on the phone's own Taptic Engine — " + "for clip-on pads without rumble motors.", @@ -505,17 +658,17 @@ struct GamepadSettingsView: View { private var profileRows: [Row] { guard !profiles.profiles.isEmpty else { return [Row( - id: "noProfiles", header: "Profiles", icon: "slider.horizontal.3", + id: "noProfiles", tab: .profiles, icon: "slider.horizontal.3", label: "No profiles yet", value: "", detail: emptyCatalogDetail, adjustable: false, adjust: { _ in false }, activate: {})] } - return profiles.profiles.enumerated().map { i, profile in + return profiles.profiles.map { profile in let pins = store.hosts .filter { ($0.pinnedProfileIDs ?? []).contains(profile.id) }.count return Row( - id: "profile-\(profile.id)", header: i == 0 ? "Profiles" : nil, + id: "profile-\(profile.id)", tab: .profiles, icon: "slider.horizontal.3", label: profile.name, value: pins == 0 ? "Not pinned" : "Pinned to \(pins) host\(pins == 1 ? "" : "s")", detail: profileDetail, @@ -537,7 +690,8 @@ struct GamepadSettingsView: View { private func pinRows(for profile: StreamProfile) -> [Row] { guard !store.hosts.isEmpty else { return [Row( - id: "noHosts", icon: "desktopcomputer", label: "No saved hosts yet", + id: "noHosts", tab: .profiles, icon: "desktopcomputer", + label: "No saved hosts yet", value: "", detail: "Pair with a host first, then pin this profile to it.", adjustable: false, @@ -547,7 +701,7 @@ struct GamepadSettingsView: View { let hostID = host.id let pinned = (host.pinnedProfileIDs ?? []).contains(profile.id) return Row( - id: "pinHost-\(hostID.uuidString)", icon: "desktopcomputer", + id: "pinHost-\(hostID.uuidString)", tab: .profiles, icon: "desktopcomputer", label: host.displayName, value: pinned ? "Pinned" : "Off", detail: "A pinned profile appears as its own card on the host — one press " @@ -609,13 +763,13 @@ struct GamepadSettingsView: View { // MARK: - Row builders private func choiceRow( - id: String, header: String? = nil, icon: String, label: String, detail: String, + id: String, tab: GpSettingsTab, icon: String, label: String, detail: String, options: [(label: String, tag: T)], current: T, enabled: Bool = true, write: @escaping (T) -> Void ) -> Row { let index = options.firstIndex { $0.tag == current } return Row( - id: id, header: header, icon: icon, label: label, + id: id, tab: tab, icon: icon, label: label, value: index.map { options[$0].label } ?? "—", detail: detail, enabled: enabled, @@ -638,11 +792,11 @@ struct GamepadSettingsView: View { } private func toggleRow( - id: String, header: String? = nil, icon: String, label: String, detail: String, + id: String, tab: GpSettingsTab, icon: String, label: String, detail: String, value: Binding, enabled: Bool = true ) -> Row { Row( - id: id, header: header, icon: icon, label: label, + id: id, tab: tab, icon: icon, label: label, value: value.wrappedValue ? "On" : "Off", detail: detail, enabled: enabled, diff --git a/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift index 56f97896..79bb74f6 100644 --- a/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift +++ b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift @@ -179,6 +179,14 @@ public enum DefaultsKey { /// layout (the console launcher, gamepad-navigable settings, a coverflow-style library) /// whenever a gamepad is connected. On by default; see `GamepadUIEnvironment.isActive`. public static let gamepadUIEnabled = "punktfunk.gamepadUIEnabled" + /// Which colour family the gamepad UI's living backdrop drifts through — a + /// `GamepadPalette` id ("violet" = the brand default, then "tide"/"forest"/"ember"/ + /// "rose"/"graphite"). The cross-client `ui_palette` key: the desktop console and the + /// Android client carry the same table under the same names. Presentation only, so it is + /// a device preference and never part of a stream profile. An unknown value reads as the + /// default rather than failing — a newer client may have shipped a palette this build + /// doesn't know. + public static let uiPalette = "punktfunk.uiPalette" /// iPhone: ALSO play the rumble the host addresses to controller 1 (wire pad 0) on this /// device's own Taptic Engine — for phone-clip pads that ship without rumble motors, where /// the phone body is the only actuator in the player's hands. Off by default (opt-in); read diff --git a/clients/apple/Sources/PunktfunkShared/GamepadPalette.swift b/clients/apple/Sources/PunktfunkShared/GamepadPalette.swift new file mode 100644 index 00000000..c5c1b976 --- /dev/null +++ b/clients/apple/Sources/PunktfunkShared/GamepadPalette.swift @@ -0,0 +1,79 @@ +// The gamepad UI's background colour families. +// +// A palette is NOT a second hand-tuned colour field: it is a hue rotation + saturation scale +// applied to the ONE field GamepadScreenBackground already draws, so every palette inherits its +// structure (dark corners, bright interior pools, warm-left/cool-right) and the brand default is +// exactly the shipped look — `violet` is the identity transform. +// +// The table and the `tint` math are mirrored in `pf-console-ui`'s `library.rs` (Rust) and the +// Android client's `GamepadPalette.kt` (Kotlin) under the same ids, so the shared `ui_palette` +// setting names the same colour family on every client. Keep the three copies in step: a palette +// added here without the others is a value the other clients will silently render as Violet. +// +// It lives in PunktfunkShared rather than next to the views because that is the target the tests +// can reach — the arithmetic below is the part that has to agree across three languages. + +import Foundation +import simd + +public struct GamepadPalette: Identifiable, Equatable, Sendable { + /// The stored `ui_palette` value (`DefaultsKey.uiPalette`). + public let id: String + /// What the settings row shows. + public let name: String + /// Hue rotation about the grey axis, degrees — positive runs red → green → blue. + public let hueDegrees: Double + /// Saturation scale about luminance; 1 keeps the source saturation. + public let saturation: Double + + /// The six shipped palettes, in cycling order: the brand violet, then cool → warm, then the + /// neutral. + public static let all: [GamepadPalette] = [ + GamepadPalette(id: "violet", name: "Violet", hueDegrees: 0, saturation: 1.0), + GamepadPalette(id: "tide", name: "Tide", hueDegrees: -70, saturation: 1.0), + GamepadPalette(id: "forest", name: "Forest", hueDegrees: -130, saturation: 0.9), + GamepadPalette(id: "ember", name: "Ember", hueDegrees: 105, saturation: 1.0), + GamepadPalette(id: "rose", name: "Rose", hueDegrees: 60, saturation: 0.95), + GamepadPalette(id: "graphite", name: "Graphite", hueDegrees: 0, saturation: 0.12), + ] + + /// The palette stored under `id`, falling back to the brand default — an unknown name is a + /// palette a newer client shipped, not a reason to draw nothing. + public static func named(_ id: String) -> GamepadPalette { + all.first { $0.id == id } ?? all[0] + } + + /// `true` for the identity transform, so the default path can skip the per-colour work. + public var isIdentity: Bool { hueDegrees == 0 && saturation == 1 } + + /// Apply this palette to one RGB triple. + public func tint(_ c: SIMD3) -> SIMD3 { + guard !isIdentity else { return c } + return GamepadPalette.tint(c, hueDegrees: hueDegrees, saturation: saturation) + } + + /// Rotate `c` about the grey axis by `hueDegrees` (Rodrigues — the same rotation the field's + /// own ±8° warm/cool sway uses, in the same orientation) and scale its saturation about + /// luminance. Clamped, because a large rotation can push a channel out of gamut. + /// + /// Deliberately computed here rather than left to SwiftUI's `.hueRotation`: that modifier's + /// exact behaviour is the framework's, and the Rust and Kotlin clients have no equivalent — + /// doing the arithmetic on the COLOURS keeps the three implementations identical. + public static func tint( + _ c: SIMD3, hueDegrees: Double, saturation: Double + ) -> SIMD3 { + let a = hueDegrees * .pi / 180 + let cs = cos(a) + let sn = sin(a) + let invSqrt3 = 1 / 3.0.squareRoot() + let grey = (c.x + c.y + c.z) / 3 * (1 - cs) + // The `sn` term is cross(k, c) with k = (1,1,1)/√3. + let rot = SIMD3( + c.x * cs + (c.z - c.y) * invSqrt3 * sn + grey, + c.y * cs + (c.x - c.z) * invSqrt3 * sn + grey, + c.z * cs + (c.y - c.x) * invSqrt3 * sn + grey) + let luma = 0.2126 * rot.x + 0.7152 * rot.y + 0.0722 * rot.z + func mix(_ v: Double) -> Double { min(max(luma + (v - luma) * saturation, 0), 1) } + return SIMD3(mix(rot.x), mix(rot.y), mix(rot.z)) + } +} diff --git a/clients/apple/Tests/PunktfunkKitTests/GamepadPaletteTests.swift b/clients/apple/Tests/PunktfunkKitTests/GamepadPaletteTests.swift new file mode 100644 index 00000000..8b2ab4f7 --- /dev/null +++ b/clients/apple/Tests/PunktfunkKitTests/GamepadPaletteTests.swift @@ -0,0 +1,81 @@ +// The gamepad UI's background palettes. These assertions are the CONTRACT the Rust +// (`pf-console-ui::library::tint`) and Kotlin (`GamepadPalette.tint`) ports have to reproduce — +// the same ids, the same rotation orientation, the same in-gamut results — so one `ui_palette` +// value names the same colour family on every client. + +import XCTest +import simd +@testable import PunktfunkShared + +final class GamepadPaletteTests: XCTestCase { + /// The brightest interior pool of the mesh field — the colour a palette is judged by. + private let violetPool = SIMD3(0.49, 0.39, 0.95) + + /// The brand default must be the IDENTITY transform. Every existing install already sees the + /// shipped violet backdrop, and a palette table that quietly restyled it would be a + /// regression dressed as a feature. + func testVioletIsTheUntouchedShippedField() { + let violet = GamepadPalette.named("violet") + XCTAssertEqual(GamepadPalette.all.first?.id, "violet") + XCTAssertTrue(violet.isIdentity) + XCTAssertEqual(violet.tint(violetPool), violetPool) + // An unknown name is a newer client's palette, not an error. + XCTAssertEqual(GamepadPalette.named("chartreuse").id, "violet") + XCTAssertEqual(GamepadPalette.named("").id, "violet") + } + + /// The ids and their order are the cross-client contract (the strip order, and the order + /// L1/R1 and A cycle through). + func testTableMatchesTheOtherClients() { + XCTAssertEqual( + GamepadPalette.all.map(\.id), + ["violet", "tide", "forest", "ember", "rose", "graphite"]) + XCTAssertEqual( + GamepadPalette.all.map(\.name), + ["Violet", "Tide", "Forest", "Ember", "Rose", "Graphite"]) + } + + /// A rotation moves the hue while roughly holding luminance, and the saturation scale + /// collapses toward grey — the same four checks the Rust test makes. + func testTintRotatesHueAndScalesSaturation() { + XCTAssertTrue(violetPool.z > violetPool.x && violetPool.z > violetPool.y, "blue-dominant") + + // +105° (Ember) turns the blue-dominant pool red-dominant… + let ember = GamepadPalette.named("ember").tint(violetPool) + XCTAssertGreaterThan(ember.x, ember.z, "\(ember) should be warm") + // …−130° (Forest) turns it green-dominant… + let forest = GamepadPalette.named("forest").tint(violetPool) + XCTAssertTrue(forest.y > forest.x && forest.y > forest.z, "\(forest)") + // …and −70° (Tide) lands on a cyan whose green and blue both beat red. + let tide = GamepadPalette.named("tide").tint(violetPool) + XCTAssertTrue(tide.y > tide.x && tide.z > tide.x, "\(tide)") + + // Graphite's saturation scale leaves the channels nearly equal… + let grey = GamepadPalette.named("graphite").tint(violetPool) + let spread = max(grey.x, grey.y, grey.z) - min(grey.x, grey.y, grey.z) + XCTAssertLessThan(spread, 0.08, "\(grey)") + // …at about the source's luminance (it desaturates, it doesn't dim). + let luma = 0.2126 * violetPool.x + 0.7152 * violetPool.y + 0.0722 * violetPool.z + XCTAssertEqual(grey.y, luma, accuracy: 0.05) + } + + /// Every palette stays in gamut on every colour the field is built from — an out-of-range + /// channel would clamp differently on each platform's rasteriser. + func testEveryPaletteStaysInGamut() { + let field: [SIMD3] = [ + SIMD3(0.075, 0.060, 0.160), SIMD3(0.34, 0.27, 0.72), SIMD3(0.30, 0.26, 0.74), + SIMD3(0.42, 0.20, 0.54), SIMD3(0.49, 0.39, 0.95), SIMD3(0.28, 0.31, 0.84), + SIMD3(0.16, 0.26, 0.64), SIMD3(0.45, 0.23, 0.60), SIMD3(0.53, 0.31, 0.75), + SIMD3(0.35, 0.35, 0.91), SIMD3(0.19, 0.28, 0.70), SIMD3(0.22, 0.18, 0.54), + SIMD3(0.24, 0.20, 0.58), + ] + for palette in GamepadPalette.all { + for c in field { + let t = palette.tint(c) + for v in [t.x, t.y, t.z] { + XCTAssertTrue((0...1).contains(v), "\(palette.id) \(c) → \(t)") + } + } + } + } +} diff --git a/crates/pf-client-core/src/trust.rs b/crates/pf-client-core/src/trust.rs index 3b10a192..1aeaecd2 100644 --- a/crates/pf-client-core/src/trust.rs +++ b/crates/pf-client-core/src/trust.rs @@ -1111,6 +1111,15 @@ pub struct Settings { /// Experimental: the game-library browser ("Browse library…" on saved cards) — /// mirrors the Apple client's "Show game library" toggle, default off. pub library_enabled: bool, + /// Which colour family the gamepad UI's living backdrop drifts through — the shared + /// `ui_palette` key (`"violet"` = the brand default, then `tide`/`forest`/`ember`/ + /// `rose`/`graphite`; see `pf-console-ui`'s palette table, and the Apple/Android + /// clients' twins). Presentation only: nothing about a stream depends on it, which is + /// why it is a device preference and never part of a settings profile. An unknown + /// name reads as the default rather than erroring — a newer client may have shipped a + /// palette this binary doesn't know. + #[serde(default = "default_ui_palette")] + pub ui_palette: String, /// Send Wake-on-LAN before connecting to a saved host and wait for it to boot (the /// Apple client's "Auto-wake on connect"). Default ON — that was the unconditional /// behavior before this became a setting. Off is for hosts reached over a VPN, where @@ -1195,6 +1204,10 @@ fn default_true() -> bool { true } +fn default_ui_palette() -> String { + "violet".into() +} + fn default_pad_speaker() -> String { "pad".into() } @@ -1303,6 +1316,7 @@ impl Default for Settings { stats_verbosity: None, fullscreen_on_stream: true, library_enabled: false, + ui_palette: default_ui_palette(), auto_wake: true, invert_scroll: false, speaker_device: String::new(), diff --git a/crates/pf-console-ui/src/library.rs b/crates/pf-console-ui/src/library.rs index d74fec31..cd6abe37 100644 --- a/crates/pf-console-ui/src/library.rs +++ b/crates/pf-console-ui/src/library.rs @@ -203,17 +203,118 @@ pub const MESH_INTERIOR: [(f64, f64, f64, f64, f64, f64); 4] = [ (0.667, 0.667, 0.12, 0.047, 0.061, 5.0), ]; -/// The mesh gradient as SkSL, palette + motion baked into the source (only time and -/// resolution are uniforms). A smooth bicubic blend of the 16 colours — a separable +// --- Background palettes ------------------------------------------------------------------- + +/// One background colour family for the console's living backdrop. A palette is NOT a second +/// hand-tuned 16-colour grid: it is a hue rotation + saturation scale applied to +/// [`MESH_COLORS`], so every palette inherits the field's structure (dark corners, bright +/// interior pools, warm-left/cool-right) and the brand default is exactly the shipped look — +/// `violet` is the identity transform. The Apple and Android clients carry the same table and +/// the same [`tint`] math, so a palette reads as the same colour family on every client. +pub struct Palette { + /// The stored `ui_palette` value (see `trust::Settings::ui_palette`). + pub id: &'static str, + /// What the settings row shows. + pub name: &'static str, + /// Hue rotation about the grey axis, degrees — positive runs red → green → blue. + pub hue_deg: f64, + /// Saturation scale about luminance; `1.0` keeps the source saturation. + pub sat: f64, +} + +/// The six shipped palettes, in cycling order (the brand violet first, then cool → warm, +/// then the neutral). Adding one here adds it to every console settings screen; the Apple +/// and Android tables must gain the same entry to keep the `ui_palette` key portable. +pub const PALETTES: [Palette; 6] = [ + Palette { + id: "violet", + name: "Violet", + hue_deg: 0.0, + sat: 1.0, + }, + Palette { + id: "tide", + name: "Tide", + hue_deg: -70.0, + sat: 1.0, + }, + Palette { + id: "forest", + name: "Forest", + hue_deg: -130.0, + sat: 0.9, + }, + Palette { + id: "ember", + name: "Ember", + hue_deg: 105.0, + sat: 1.0, + }, + Palette { + id: "rose", + name: "Rose", + hue_deg: 60.0, + sat: 0.95, + }, + Palette { + id: "graphite", + name: "Graphite", + hue_deg: 0.0, + sat: 0.12, + }, +]; + +/// The palette stored under `id`, falling back to the brand default — an unknown name is a +/// palette a newer client shipped, not a reason to draw nothing. +pub fn palette(id: &str) -> &'static Palette { + PALETTES.iter().find(|p| p.id == id).unwrap_or(&PALETTES[0]) +} + +/// Rotate `(r, g, b)` about the grey axis by `deg` (Rodrigues — the same rotation the shader +/// already uses for the ±8° warm/cool sway) and scale its saturation about luminance. Clamped, +/// because a large rotation can push a channel out of gamut. Ported verbatim to Swift and +/// Kotlin: keep the three copies in step or the palettes drift apart between clients. +pub fn tint(c: (f64, f64, f64), deg: f64, sat: f64) -> (f64, f64, f64) { + let (r, g, b) = c; + let a = deg.to_radians(); + let (sn, cs) = a.sin_cos(); + let inv_sqrt3 = 1.0 / 3.0f64.sqrt(); + let grey = (r + g + b) / 3.0 * (1.0 - cs); + // The `sn` term is `cross(k, c)` with k = (1,1,1)/√3 — the SAME orientation the shader's + // own `hue()` uses, so a palette rotation and the ±8° sway agree on which way is warmer. + let rot = ( + r * cs + (b - g) * inv_sqrt3 * sn + grey, + g * cs + (r - b) * inv_sqrt3 * sn + grey, + b * cs + (g - r) * inv_sqrt3 * sn + grey, + ); + let luma = 0.2126 * rot.0 + 0.7152 * rot.1 + 0.0722 * rot.2; + let mix = |v: f64| (luma + (v - luma) * sat).clamp(0.0, 1.0); + (mix(rot.0), mix(rot.1), mix(rot.2)) +} + +impl Palette { + /// [`MESH_COLORS`] under this palette's transform. + pub fn mesh_colors(&self) -> [(f64, f64, f64); 16] { + core::array::from_fn(|i| tint(MESH_COLORS[i], self.hue_deg, self.sat)) + } +} + +/// The mesh gradient as SkSL, palette + motion baked into the source (resolution, time and +/// the calm mix are uniforms). A smooth bicubic blend of the 16 colours — a separable /// cubic-Bézier basis in x then y, C∞ and edge-to-edge, the fragment-shader analogue of /// SwiftUI's `MeshGradient(smoothsColors: true)`. The four interior points drive a /// bounded (weighted-average) domain warp so the bright pools drift; then the whole field /// gets the ±8°/~5-min hue sway, an elliptical vignette, and the vertical legibility scrim, /// all matching the Swift `composite(at:)`. Runs on the GPU at full rate. -pub fn mesh_sksl() -> String { +/// +/// `u_tc.y` is the CALM mix, 0 → 1: at 1 the same living field is flattened toward its own +/// corner colour (`u_lift`), which is how the form screens (settings, add-host, pair) stay +/// restful while still drifting — the motion never changes speed, only the contrast, so the +/// crossfade between a launcher screen and a form screen can't make the field jump. +pub fn mesh_sksl(colors: &[(f64, f64, f64); 16]) -> String { // Colours as `float3(r, g, b)` literals, indices 0..15 (row-major 4×4). let c = |i: usize| { - let (r, g, b) = MESH_COLORS[i]; + let (r, g, b) = colors[i]; format!("float3({r}, {g}, {b})") }; // The four interior-point domain-warp accumulators. Displacement matches Swift `wob()`: @@ -224,14 +325,18 @@ pub fn mesh_sksl() -> String { warp.push_str(&format!( " q = uv - float2({bx}, {by});\n\ ww = exp(-dot(q, q) / (2.0 * 0.30 * 0.30));\n\ - d = float2({amp} * sin(u_t * {sx} + {ph}), \ - {amp} * cos(u_t * {sy} + {ph} * 1.3));\n\ + d = float2({amp} * sin(tt * {sx} + {ph}), \ + {amp} * cos(tt * {sy} + {ph} * 1.3));\n\ wsum += d * ww; wtot += ww;\n", )); } format!( "uniform float2 u_res;\n\ - uniform float u_t;\n\ + // x = seconds since the shell started, y = the calm mix (0 launcher, 1 form).\n\ + uniform float2 u_tc;\n\ + // rgb = the palette's corner colour scaled for the calm lift; a is unused (float4\n\ + // so the uniform block stays 16-byte aligned under any packing rule).\n\ + uniform float4 u_lift;\n\ \n\ // Cubic-Bézier basis over four control values — the smooth 4-point blend per axis.\n\ float bz(float t, float a, float b, float c, float d) {{\n\ @@ -250,6 +355,7 @@ pub fn mesh_sksl() -> String { }}\n\ \n\ half4 main(float2 xy) {{\n\ + \x20 float tt = u_tc.x; float calm = u_tc.y;\n\ \x20 float2 uv = xy / u_res;\n\ \x20 // Interior control points wander → bounded domain warp (pools follow them).\n\ \x20 float2 wsum = float2(0.0); float wtot = 0.0; float2 q; float ww; float2 d;\n\ @@ -263,11 +369,18 @@ pub fn mesh_sksl() -> String { \x20 float3 r3 = bz3(uv.x, {c12}, {c13}, {c14}, {c15});\n\ \x20 float3 col = bz3(uv.y, r0, r1, r2, r3);\n\ \n\ - \x20 col = hue(col, sin(u_t * 0.021) * 0.1396263);\n\ + \x20 col = hue(col, sin(tt * 0.021) * 0.1396263);\n\ + \n\ + \x20 // Calm: flatten the field toward its own corner colour — the pools dim and the\n\ + \x20 // corners lift, so a form screen keeps real colour under its glass rows while\n\ + \x20 // losing the launcher's contrast. Motion is untouched (see the doc comment).\n\ + \x20 col = mix(col, col * 0.60 + u_lift.rgb, calm);\n\ \n\ \x20 // Elliptical vignette: clear at r=0.25 → black·0.42 at r=1.15 (aspect-fit ellipse).\n\ + \x20 // Halved under calm: a launcher's cards sit in the pooled centre, but a form\n\ + \x20 // screen's rows run out toward the edges, where crushing to black just eats them.\n\ \x20 float2 e = (xy / u_res - 0.5) * 2.0;\n\ - \x20 float vig = clamp((length(e) - 0.25) / 0.90, 0.0, 1.0) * 0.42;\n\ + \x20 float vig = clamp((length(e) - 0.25) / 0.90, 0.0, 1.0) * mix(0.42, 0.21, calm);\n\ \x20 col *= 1.0 - vig;\n\ \n\ \x20 // Vertical legibility scrim: black 0.38/0.06/0.08/0.40 at 0/0.32/0.68/1.\n\ @@ -482,10 +595,56 @@ mod tests { /// 16 colours baked in, the five bicubic evals and four interior warp terms present). #[test] fn mesh_sksl_shape() { - let src = mesh_sksl(); + let src = mesh_sksl(&MESH_COLORS); assert!(src.matches("float3(").count() >= 16, "16 colours baked"); assert_eq!(src.matches("bz3(").count(), 6); // 1 definition + 5 call sites assert_eq!(src.matches("wtot +=").count(), 4); // one per interior point assert_eq!(src.matches('{').count(), src.matches('}').count()); } + + /// The brand default must be the IDENTITY transform — the shipped violet backdrop is + /// what every existing install already sees, and a palette table that quietly restyled + /// it would be a regression dressed as a feature. + #[test] + fn violet_is_the_untouched_shipped_field() { + assert_eq!(PALETTES[0].id, "violet"); + for (a, b) in palette("violet").mesh_colors().iter().zip(&MESH_COLORS) { + assert!((a.0 - b.0).abs() < 1e-9, "{a:?} vs {b:?}"); + assert!((a.1 - b.1).abs() < 1e-9, "{a:?} vs {b:?}"); + assert!((a.2 - b.2).abs() < 1e-9, "{a:?} vs {b:?}"); + } + // An unknown name is a newer client's palette, not an error. + assert_eq!(palette("chartreuse").id, "violet"); + assert_eq!(palette("").id, "violet"); + } + + /// The transform's two knobs do what they claim: a rotation moves the hue while holding + /// roughly the same luminance, and the saturation scale collapses toward grey. These are + /// the numbers the Swift and Kotlin ports have to reproduce. + #[test] + fn tint_rotates_hue_and_scales_saturation() { + let violet = MESH_COLORS[5]; // the brightest interior pool: blue dominates + assert!(violet.2 > violet.0 && violet.2 > violet.1); + // +105° (Ember) turns the blue-dominant pool red-dominant. + let ember = tint(violet, 105.0, 1.0); + assert!(ember.0 > ember.2, "{ember:?} should be warm"); + // −130° (Forest) turns it green-dominant. + let forest = tint(violet, -130.0, 1.0); + assert!(forest.1 > forest.0 && forest.1 > forest.2, "{forest:?}"); + // Graphite's saturation scale leaves the three channels nearly equal… + let grey = tint(violet, 0.0, 0.12); + let spread = grey.0.max(grey.1).max(grey.2) - grey.0.min(grey.1).min(grey.2); + assert!(spread < 0.08, "{grey:?} spread {spread}"); + // …at about the source's luminance (it desaturates, it doesn't dim). + let luma = 0.2126 * violet.0 + 0.7152 * violet.1 + 0.0722 * violet.2; + assert!((grey.1 - luma).abs() < 0.05, "{grey:?} vs luma {luma}"); + // Every palette stays in gamut on every mesh colour. + for p in &PALETTES { + for c in p.mesh_colors() { + for v in [c.0, c.1, c.2] { + assert!((0.0..=1.0).contains(&v), "{} {c:?}", p.id); + } + } + } + } } diff --git a/crates/pf-console-ui/src/screens.rs b/crates/pf-console-ui/src/screens.rs index a7e2730e..9a839ffe 100644 --- a/crates/pf-console-ui/src/screens.rs +++ b/crates/pf-console-ui/src/screens.rs @@ -21,9 +21,10 @@ use skia_safe::{Canvas, Rect}; /// What a screen draws over (the shell crossfades between them on push/pop). #[derive(Clone, Copy, PartialEq, Eq)] pub(crate) enum Bg { - /// The living mesh aurora (home, library). + /// The living mesh aurora at full contrast (home, library). Aurora, - /// The quiet indigo form backdrop (settings, add-host, pair). + /// The SAME living mesh, calmed — dimmed pools, lifted corners (settings, add-host, + /// pair). Not a second backdrop: the shell chases one `calm` uniform between the two. Form, } diff --git a/crates/pf-console-ui/src/screens/settings.rs b/crates/pf-console-ui/src/screens/settings.rs index 52f53702..4fe1d83b 100644 --- a/crates/pf-console-ui/src/screens/settings.rs +++ b/crates/pf-console-ui/src/screens/settings.rs @@ -2,13 +2,17 @@ //! restyled as glass rows and fully controller-navigable (the Swift //! `GamepadSettingsView`, re-homed): up/down moves focus, left/right steps the focused //! value (clamped — the boundary thud tells the thumb it's the last option), A cycles -//! forward wrapping, B closes. Every change persists immediately; the desktop shells -//! read the same file, so values round-trip freely. +//! forward wrapping, L1/R1 change SECTION, B closes. Every change persists immediately; +//! the desktop shells read the same file, so values round-trip freely. +//! +//! The rows are split across tabs (see [`TABS`]). They used to be one 30-row scroll with +//! inline headers, which on a Deck meant thumbing past Video and Audio to reach the pad +//! settings; a tab is one shoulder press, and each tab remembers where its cursor was. use crate::glyphs::{Hint, HintKey}; use crate::screens::{Ctx, Outbox, Screen}; use crate::theme::{Fonts, DIM, W}; -use crate::widgets::{ListMsg, MenuList, RowSpec}; +use crate::widgets::{ListMsg, MenuList, RowSpec, TabStrip, TAB_STRIP_H}; use pf_client_core::gamepad::{MenuEvent, MenuPulse}; use pf_client_core::trust::{MouseMode, StatsVerbosity, TouchMode}; use skia_safe::{Canvas, Rect}; @@ -51,6 +55,10 @@ enum RowId { Fullscreen, AutoWake, Library, + /// The gamepad UI's background colour family — see [`crate::library::PALETTES`]. The + /// backdrop behind this very row re-colours as it steps, which is the whole reason the + /// picker lives on a screen rather than in a dialog. + Palette, } // The couch-relevant subset grew 2026-07-31: this screen is the ONLY settings editor in @@ -58,39 +66,77 @@ enum RowId { // scroll/shortcut behavior, fullscreen-on-stream, auto-wake, the library toggle and echo // cancellation all were). Still deliberately smaller than the desktop dialogs — device // pickers (GPU/speaker/mic) stay desktop-only, and profiles are pinnable here (the -// trailing Profiles section) but created and edited only in the desktop app (design §5.4). -const ROWS: [RowId; 29] = [ - RowId::Resolution, - RowId::Refresh, - RowId::RenderScale, - RowId::Bitrate, - RowId::Compositor, - RowId::Codec, - RowId::Decoder, - RowId::Hdr, - RowId::Chroma444, - RowId::PresentPriority, - RowId::SmoothBuffer, - RowId::Vsync, - RowId::AllowVrr, - RowId::Audio, - RowId::Mic, - RowId::EchoCancel, - RowId::PadForward, - RowId::Pad, - RowId::PadType, - RowId::SystemButtons, - RowId::GuideGesture, - RowId::Touch, - RowId::Mouse, - RowId::InvertScroll, - RowId::Shortcuts, - RowId::Stats, - RowId::Fullscreen, - RowId::AutoWake, - RowId::Library, +// trailing Profiles tab) but created and edited only in the desktop app (design §5.4). +// +// The tab names are shared with the Apple and Android gamepad settings, so a setting is +// found under the same word on every client. Profiles is the trailing tab and is built +// from the catalog at render time, which is why it carries no rows here. +const TABS: [(&str, &[RowId]); 7] = [ + ( + "Stream", + &[ + RowId::Resolution, + RowId::Refresh, + RowId::RenderScale, + RowId::Bitrate, + RowId::Compositor, + ], + ), + ( + "Video", + &[ + RowId::Codec, + RowId::Decoder, + RowId::Hdr, + RowId::Chroma444, + RowId::PresentPriority, + RowId::SmoothBuffer, + RowId::Vsync, + RowId::AllowVrr, + ], + ), + ("Audio", &[RowId::Audio, RowId::Mic, RowId::EchoCancel]), + ( + "Controller", + &[ + RowId::PadForward, + RowId::Pad, + RowId::PadType, + RowId::SystemButtons, + RowId::GuideGesture, + ], + ), + ( + "Input", + &[ + RowId::Touch, + RowId::Mouse, + RowId::InvertScroll, + RowId::Shortcuts, + ], + ), + ( + "Interface", + &[ + RowId::Palette, + RowId::Stats, + RowId::Fullscreen, + RowId::AutoWake, + RowId::Library, + ], + ), + ("Profiles", &[]), ]; +/// The index of the trailing Profiles tab (built from the catalog, not from [`TABS`]). +const PROFILES_TAB: usize = TABS.len() - 1; + +/// How many sections the strip shows — for the shell's raster test, which walks all of them. +/// `cfg(test)` because nothing in a shipping build needs the count: a plain `cargo build` would +/// otherwise warn it dead, and this crate's lanes treat warnings as errors. +#[cfg(test)] +pub(crate) const TAB_COUNT: usize = TABS.len(); + const RESOLUTIONS: [(u32, u32); 6] = [ (0, 0), // native (1280, 720), @@ -169,6 +215,12 @@ const GUIDE_GESTURE: [(&str, &str); 3] = [("auto", "Automatic"), ("on", "On"), ( pub(crate) struct SettingsScreen { list: MenuList, + strip: TabStrip, + /// Which of [`TABS`] is showing. + tab: usize, + /// Where each tab's cursor was when it was last left. Coming back to Controller after a + /// detour through Video should land where you were, not at the top. + tab_cursors: [usize; TABS.len()], /// The profile catalog's `(id, name)` pairs, loaded once at construction — the console /// can't create profiles (design §5.4: the desktop app does), so the list is stable /// for the screen's lifetime. @@ -189,20 +241,37 @@ impl SettingsScreen { fn with_profiles(profiles: Vec<(String, String)>) -> SettingsScreen { SettingsScreen { list: MenuList::new(), + strip: TabStrip::new(), + tab: 0, + tab_cursors: [0; TABS.len()], profiles, } } - /// The full row list: the fixed settings rows, then the Profiles section — one row - /// per catalog profile, or the explainer placeholder while there are none. + /// The rows of the CURRENT tab. Profiles is built from the catalog: one row per + /// profile, or the explainer placeholder while there are none. fn row_ids(&self) -> Vec { - let mut ids = ROWS.to_vec(); - if self.profiles.is_empty() { - ids.push(RowId::NoProfiles); - } else { - ids.extend((0..self.profiles.len()).map(RowId::Profile)); + if self.tab != PROFILES_TAB { + return TABS[self.tab].1.to_vec(); } - ids + if self.profiles.is_empty() { + vec![RowId::NoProfiles] + } else { + (0..self.profiles.len()).map(RowId::Profile).collect() + } + } + + /// L1/R1 — move one tab, wrapping (the strip is a ring, like A's value cycle), keeping + /// each tab's own cursor. + fn switch_tab(&mut self, delta: i32) -> Option { + self.tab_cursors[self.tab] = self.list.cursor; + let n = TABS.len() as i32; + self.tab = (self.tab as i32 + delta).rem_euclid(n) as usize; + // Clamp the remembered cursor: the Profiles tab's length follows the catalog. + let len = self.row_ids().len(); + self.list + .jump_to(self.tab_cursors[self.tab].min(len.saturating_sub(1))); + Some(MenuPulse::Move) } pub(crate) fn menu( @@ -211,9 +280,14 @@ impl SettingsScreen { ctx: &mut Ctx, fx: &mut Outbox, ) -> Option { - if ev == MenuEvent::Back { - fx.pop(); - return None; + match ev { + MenuEvent::Back => { + fx.pop(); + return None; + } + MenuEvent::JumpBack => return self.switch_tab(-1), + MenuEvent::JumpForward => return self.switch_tab(1), + _ => {} } let ids = self.row_ids(); let (msg, pulse) = self.list.menu(ev, ids.len()); @@ -271,18 +345,22 @@ impl SettingsScreen { } pub(crate) fn hints(&self, _ctx: &Ctx) -> Vec { - match self.row_ids()[self.list.cursor] { - RowId::Profile(_) => vec![ + let ids = self.row_ids(); + // The shoulders always change section, so that hint leads on every row. + let mut hints = vec![Hint::new(HintKey::Shoulders, "Section")]; + hints.extend(match ids.get(self.list.cursor) { + Some(RowId::Profile(_)) => vec![ Hint::new(HintKey::Confirm, "Pin to hosts…"), Hint::new(HintKey::Back, "Done"), ], - RowId::NoProfiles => vec![Hint::new(HintKey::Back, "Done")], - _ => vec![ + Some(RowId::NoProfiles) | None => vec![Hint::new(HintKey::Back, "Done")], + Some(_) => vec![ Hint::new(HintKey::Adjust, "Adjust"), Hint::new(HintKey::Confirm, "Change"), Hint::new(HintKey::Back, "Done"), ], - } + }); + hints } pub(crate) fn render( @@ -294,11 +372,23 @@ impl SettingsScreen { fonts: &Fonts, ctx: &mut Ctx, ) { - // The focused row's explainer sits in a reserved band under the list. + // The tab strip takes the top band, the focused row's explainer a reserved band + // under the list; the rows get what's between. let detail_h = 34.0 * k; + let strip_h = TAB_STRIP_H * k; + let labels: Vec<&str> = TABS.iter().map(|(name, _)| *name).collect(); + self.strip.render( + canvas, + Rect::from_ltrb(rect.left, rect.top, rect.right, rect.top + strip_h as f32), + &labels, + self.tab, + fonts, + k, + dt, + ); let list_rect = Rect::from_ltrb( rect.left, - rect.top, + rect.top + strip_h as f32, rect.right, rect.bottom - detail_h as f32, ); @@ -309,7 +399,7 @@ impl SettingsScreen { .collect(); self.list .render(canvas, list_rect, &rows, fonts, k, dt, true); - let detail = detail(ids[self.list.cursor]); + let detail = ids.get(self.list.cursor).copied().map_or("", detail); fonts.centered( canvas, detail, @@ -335,7 +425,7 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec { .filter(|h| h.pin.as_ref().is_some_and(|p| &p.id == pid)) .count(); return RowSpec { - header: (i == 0).then_some("Profiles"), + header: None, label: name.clone(), value: Some(match pins { 0 => "Not pinned".into(), @@ -349,9 +439,7 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec { }; } RowId::NoProfiles => { - let mut row = RowSpec::action("No profiles yet", false); - row.header = Some("Profiles"); - return row; + return RowSpec::action("No profiles yet", false); } _ => {} } @@ -372,7 +460,7 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec { }; let (header, label, value): (Option<&'static str>, &str, String) = match id { RowId::Resolution => ( - Some("Stream"), + None, "Resolution", if s.match_window { "Match window".into() @@ -416,11 +504,7 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec { "Compositor", label_for(&COMPOSITORS, &s.compositor).into(), ), - RowId::Codec => ( - Some("Video"), - "Video codec", - label_for(&CODECS, &s.codec).into(), - ), + RowId::Codec => (None, "Video codec", label_for(&CODECS, &s.codec).into()), RowId::Decoder => (None, "Decoder", label_for(&DECODERS, &s.decoder).into()), RowId::Hdr => (None, "10-bit HDR", on_off(s.hdr_enabled).into()), RowId::Chroma444 => (None, "Full chroma (4:4:4)", on_off(s.enable_444).into()), @@ -441,7 +525,7 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec { RowId::Vsync => (None, "V-Sync", on_off(s.vsync).into()), RowId::AllowVrr => (None, "Follow variable refresh", on_off(s.allow_vrr).into()), RowId::Audio => ( - Some("Audio"), + None, "Audio channels", AUDIO .iter() @@ -452,7 +536,7 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec { RowId::Mic => (None, "Microphone", on_off(s.mic_enabled).into()), RowId::EchoCancel => (None, "Echo cancellation", on_off(s.echo_cancel).into()), RowId::PadForward => ( - Some("Controller"), + None, "Forward controllers", on_off(s.gamepad_forwarding).into(), ), @@ -483,11 +567,7 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec { "Hold Select for guide", label_for(&GUIDE_GESTURE, &s.guide_gesture).into(), ), - RowId::Touch => ( - Some("Touchscreen"), - "Touch mode", - s.touch_mode().label().into(), - ), + RowId::Touch => (None, "Touch mode", s.touch_mode().label().into()), RowId::Mouse => (None, "Mouse mode", s.mouse_mode().label().into()), RowId::InvertScroll => (None, "Invert scroll", on_off(s.invert_scroll).into()), RowId::Shortcuts => ( @@ -495,8 +575,13 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec { "Capture system shortcuts", on_off(s.inhibit_shortcuts).into(), ), + RowId::Palette => ( + None, + "Background", + crate::library::palette(&s.ui_palette).name.into(), + ), RowId::Stats => ( - Some("Interface"), + None, "Statistics overlay", s.stats_verbosity().label().into(), ), @@ -603,6 +688,10 @@ fn detail(id: RowId) -> &'static str { "Alt+Tab, Super and friends reach the host while input is captured. \ Off, they act on this device instead." } + RowId::Palette => { + "The colour family this backdrop drifts through — it changes as you step, so \ + pick by looking. Appearance only; nothing about a stream depends on it." + } RowId::Stats => { "How much the overlay shows: Compact (one line) → Normal → Detailed. \ Ctrl+Alt+Shift+S cycles it live while streaming." @@ -766,6 +855,11 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool { step_option(cur, StatsVerbosity::ALL.len(), delta, wrap) .map(|i| s.set_stats_verbosity(StatsVerbosity::ALL[i])) } + RowId::Palette => { + let all = &crate::library::PALETTES; + let cur = all.iter().position(|p| p.id == s.ui_palette); + step_option(cur, all.len(), delta, wrap).map(|i| s.ui_palette = all[i].id.to_string()) + } RowId::Fullscreen => toggle(&mut s.fullscreen_on_stream, delta, wrap), RowId::AutoWake => toggle(&mut s.auto_wake, delta, wrap), RowId::Library => toggle(&mut s.library_enabled, delta, wrap), @@ -1071,19 +1165,18 @@ mod tests { ("p1".into(), "Work".into()), ("p2".into(), "Game".into()), ]); + s.tab = PROFILES_TAB; let ids = s.row_ids(); - assert_eq!(ids.len(), ROWS.len() + 2); - assert_eq!(ids[ROWS.len()], RowId::Profile(0)); + assert_eq!(ids, vec![RowId::Profile(0), RowId::Profile(1)]); let spec = row_spec(RowId::Profile(0), &ctx, &s.profiles); - assert_eq!(spec.header, Some("Profiles")); + assert_eq!(spec.header, None, "the tab pill names the section"); assert_eq!(spec.label, "Work"); assert_eq!(spec.value.as_deref(), Some("Pinned to 1 host")); let spec = row_spec(RowId::Profile(1), &ctx, &s.profiles); - assert_eq!(spec.header, None, "only the first row carries the header"); assert_eq!(spec.value.as_deref(), Some("Not pinned")); - s.list.cursor = ROWS.len(); // onto "Work" + s.list.cursor = 0; // onto "Work" let mut fx = Outbox::default(); s.menu(MenuEvent::Confirm, &mut ctx, &mut fx); assert!( @@ -1118,10 +1211,10 @@ mod tests { t: 0.0, }; let mut s = SettingsScreen::with_profiles(Vec::new()); + s.tab = PROFILES_TAB; let ids = s.row_ids(); - assert_eq!(*ids.last().unwrap(), RowId::NoProfiles); + assert_eq!(ids, vec![RowId::NoProfiles]); let spec = row_spec(RowId::NoProfiles, &ctx, &s.profiles); - assert_eq!(spec.header, Some("Profiles")); assert!(!spec.enabled); s.list.cursor = ids.len() - 1; @@ -1130,4 +1223,103 @@ mod tests { assert!(matches!(pulse, Some(MenuPulse::Boundary))); assert!(fx.nav.is_none()); } + + /// Every row the screen knows about must live in exactly one tab — a row missing from + /// [`TABS`] is a setting that became unreachable in Gaming Mode, which is precisely + /// what this screen exists to prevent. + #[test] + fn every_row_has_exactly_one_tab() { + let mut seen: Vec = Vec::new(); + for (_, rows) in &TABS { + for id in *rows { + assert!(!seen.contains(id), "{id:?} is in two tabs"); + seen.push(*id); + } + } + // The pre-tab flat list, plus the palette row this change added. + assert_eq!(seen.len(), 30, "{seen:?}"); + assert!(seen.contains(&RowId::Palette)); + // The catalog rows belong to the trailing tab, which builds them at render time. + assert!(TABS[PROFILES_TAB].1.is_empty()); + assert_eq!(TABS[PROFILES_TAB].0, "Profiles"); + } + + /// L1/R1 wrap around the strip and each tab keeps its own cursor, so a detour into + /// another section doesn't lose your place. + #[test] + fn shoulders_cycle_tabs_and_keep_each_cursor() { + let (mut settings, pads) = ctx_parts(); + let library = crate::library::LibraryShared::default(); + let mut ctx = Ctx { + hosts: &[], + library: &library, + settings: &mut settings, + pads: &pads, + deck: false, + device_name: "t", + t: 0.0, + }; + let mut s = SettingsScreen::with_profiles(Vec::new()); + let mut fx = Outbox::default(); + assert_eq!(s.tab, 0); + s.list.cursor = 3; // "Bitrate", in Stream + s.menu(MenuEvent::JumpForward, &mut ctx, &mut fx); + assert_eq!(s.tab, 1); + assert_eq!(s.list.cursor, 0, "a fresh tab starts at its first row"); + s.list.cursor = 2; // "10-bit HDR", in Video + s.menu(MenuEvent::JumpBack, &mut ctx, &mut fx); + assert_eq!((s.tab, s.list.cursor), (0, 3), "Stream kept its place"); + // Backwards off the first tab wraps to the last… + s.menu(MenuEvent::JumpBack, &mut ctx, &mut fx); + assert_eq!(s.tab, PROFILES_TAB); + // …whose (catalog-built) length clamps a remembered cursor that no longer fits. + assert_eq!(s.list.cursor, 0); + s.menu(MenuEvent::JumpForward, &mut ctx, &mut fx); + assert_eq!(s.tab, 0); + // Switching sections is navigation, never a settings write. + assert!(fx.nav.is_none() && fx.cmds.is_empty()); + } + + /// The palette row steps the shared `ui_palette` key through the table and wraps on A, + /// like every other choice row. + #[test] + fn palette_row_steps_the_shared_key() { + let (mut settings, pads) = ctx_parts(); + let library = crate::library::LibraryShared::default(); + let mut ctx = Ctx { + hosts: &[], + library: &library, + settings: &mut settings, + pads: &pads, + deck: false, + device_name: "t", + t: 0.0, + }; + assert_eq!(ctx.settings.ui_palette, "violet", "the brand default ships"); + assert_eq!( + row_spec(RowId::Palette, &ctx, &[]).value.as_deref(), + Some("Violet") + ); + assert!( + !adjust(RowId::Palette, -1, false, &mut ctx), + "already the first = thud" + ); + assert!(adjust(RowId::Palette, 1, false, &mut ctx)); + assert_eq!(ctx.settings.ui_palette, crate::library::PALETTES[1].id); + // A from the last entry wraps home. + ctx.settings.ui_palette = crate::library::PALETTES + .last() + .expect("non-empty") + .id + .to_string(); + assert!(adjust(RowId::Palette, 1, true, &mut ctx)); + assert_eq!(ctx.settings.ui_palette, "violet"); + // A store written by a newer client shows that client's value, not a blank row. + ctx.settings.ui_palette = "chartreuse".into(); + assert_eq!( + row_spec(RowId::Palette, &ctx, &[]).value.as_deref(), + Some("Violet"), + "an unknown palette reads as the default it actually draws" + ); + } } diff --git a/crates/pf-console-ui/src/shell.rs b/crates/pf-console-ui/src/shell.rs index 6994109f..f92cc23c 100644 --- a/crates/pf-console-ui/src/shell.rs +++ b/crates/pf-console-ui/src/shell.rs @@ -11,7 +11,7 @@ use crate::anim::Progress; use crate::glyphs::GlyphStyle; -use crate::library::{mesh_sksl, LibraryShared}; +use crate::library::{mesh_sksl, palette, LibraryShared}; use crate::model::{ConsoleBus, ConsoleCmd, ConsoleShared, HostRow, PairPhase, WakeStatus}; use crate::screens::{Bg, ConnectIntent, Ctx, Nav, Outbox, Screen}; use anyhow::{anyhow, Result}; @@ -81,7 +81,17 @@ pub(crate) struct Shell { wake_optimistic: bool, toast: Option, mesh: RuntimeEffect, - /// 0 = aurora, 1 = form — chased, so backdrops crossfade with the transition. + /// The `ui_palette` the compiled `mesh` bakes. The settings screen can change the palette + /// mid-frame-loop, so [`Self::sync`] recompiles when this falls out of step — the backdrop + /// re-colours under the cursor as the row is stepped, which is the whole point of putting + /// the picker on a screen the backdrop is behind. + mesh_palette: String, + /// The palette's corner colour × 0.4 — the calm lift, precomputed with `mesh`. Chosen so + /// `col*0.6 + lift` leaves a corner EXACTLY where it was and pulls the bright pools down + /// to it: the form screens lose the launcher's contrast, not its colour. + mesh_lift: [f32; 3], + /// 0 = launcher aurora, 1 = the calm form field — chased, so the backdrop settles into + /// (or out of) calm alongside the screen transition. bg_mix: f64, glyphs: GlyphStyle, chip: Option, @@ -99,8 +109,8 @@ impl Shell { stack: Vec, ) -> Result { anyhow::ensure!(!stack.is_empty(), "the console needs a root screen"); - let mesh = RuntimeEffect::make_for_shader(mesh_sksl(), None) - .map_err(|e| anyhow!("mesh-gradient SkSL: {e}"))?; + let settings = trust::Settings::load(); + let (mesh, mesh_lift) = build_mesh(&settings.ui_palette)?; let bg_mix = match stack.last().expect("non-empty").background() { Bg::Aurora => 0.0, Bg::Form => 1.0, @@ -112,7 +122,8 @@ impl Shell { library, bus, actions: VecDeque::new(), - settings: trust::Settings::load(), + mesh_palette: settings.ui_palette.clone(), + settings, hosts: Vec::new(), hosts_gen: u64::MAX, device_name: opts.device_name, @@ -123,6 +134,7 @@ impl Shell { wake_optimistic: false, toast: None, mesh, + mesh_lift, bg_mix, glyphs: GlyphStyle::Keyboard, chip: None, @@ -188,6 +200,26 @@ impl Shell { // --- Model sync (hosts, pairing, wake) — before input and before render -------------- fn sync(&mut self) { + // The settings screen writes `ui_palette` straight into `self.settings`; recompiling + // here is what makes the backdrop re-colour live under the row being stepped. A + // rejected compile keeps the palette that IS drawing — the field never goes black + // because someone picked a colour. + if self.settings.ui_palette != self.mesh_palette { + match build_mesh(&self.settings.ui_palette) { + Ok((mesh, lift)) => { + self.mesh = mesh; + self.mesh_lift = lift; + self.mesh_palette = self.settings.ui_palette.clone(); + } + Err(e) => { + tracing::warn!( + "console: {} palette rejected: {e}", + self.settings.ui_palette + ); + self.mesh_palette = self.settings.ui_palette.clone(); + } + } + } if self.console.hosts_gen() != self.hosts_gen { (self.hosts, self.hosts_gen) = self.console.hosts_snapshot(); } @@ -432,12 +464,25 @@ impl Shell { } } - fn draw_aurora(&self, canvas: &Canvas, w: f64, h: f64, t: f64) { - let uniforms: [f32; 3] = [w as f32, h as f32, t as f32]; - // SAFETY: `uniforms` is a local `[f32; 3]` — exactly 12 bytes — and `f32` has no padding or + /// The living backdrop. `calm` 0 = the launcher's aurora, 1 = the quiet field the form + /// screens sit on; the shell chases it, so there is only ever ONE backdrop pass — the + /// former aurora-over-static-form crossfade is now a single uniform. + fn draw_aurora(&self, canvas: &Canvas, w: f64, h: f64, t: f64, calm: f64) { + // Laid out to match the SkSL block: u_res (float2), u_tc (float2), u_lift (float4). + let uniforms: [f32; 8] = [ + w as f32, + h as f32, + t as f32, + calm as f32, + self.mesh_lift[0], + self.mesh_lift[1], + self.mesh_lift[2], + 0.0, + ]; + // SAFETY: `uniforms` is a local `[f32; 8]` — exactly 32 bytes — and `f32` has no padding or // invalid bit patterns, so reading it as bytes is sound; the slice is copied by // `Data::new_copy` before `uniforms` goes out of scope. - let bytes = unsafe { std::slice::from_raw_parts(uniforms.as_ptr().cast::(), 12) }; + let bytes = unsafe { std::slice::from_raw_parts(uniforms.as_ptr().cast::(), 32) }; match self.mesh.make_shader(Data::new_copy(bytes), &[], None) { Some(shader) => { let mut paint = Paint::default(); @@ -451,5 +496,30 @@ impl Shell { } } +/// Compile the mesh shader for a palette, returning it with its precomputed calm lift. +/// `uniform_size` is checked rather than assumed: the byte buffer [`Shell::draw_aurora`] +/// hands Skia is hand-packed, and a silent layout change would feed the field garbage +/// instead of failing. +fn build_mesh(palette_id: &str) -> Result<(RuntimeEffect, [f32; 3])> { + let p = palette(palette_id); + let colors = p.mesh_colors(); + let effect = RuntimeEffect::make_for_shader(mesh_sksl(&colors), None) + .map_err(|e| anyhow!("mesh-gradient SkSL: {e}"))?; + anyhow::ensure!( + effect.uniform_size() == 32, + "mesh uniform block is {} bytes, expected 32 (u_res, u_tc, u_lift)", + effect.uniform_size() + ); + let corner = colors[0]; + Ok(( + effect, + [ + (corner.0 * 0.4) as f32, + (corner.1 * 0.4) as f32, + (corner.2 * 0.4) as f32, + ], + )) +} + #[cfg(test)] mod tests; diff --git a/crates/pf-console-ui/src/shell/overlays.rs b/crates/pf-console-ui/src/shell/overlays.rs index e8fa7b44..24601855 100644 --- a/crates/pf-console-ui/src/shell/overlays.rs +++ b/crates/pf-console-ui/src/shell/overlays.rs @@ -166,7 +166,7 @@ impl Shell { canvas.save_layer_alpha_f(None, appear as f32); // Opaque aurora — the same living backdrop the home wears, so the takeover reads as the // console taking over rather than a card popping up. - self.draw_aurora(canvas, w, h, t); + self.draw_aurora(canvas, w, h, t, 0.0); // A soft pool of shade under the centre seats the white text against a bright aurora. let mut vignette = Paint::default(); vignette.set_shader(gradient_shader::radial( diff --git a/crates/pf-console-ui/src/shell/render.rs b/crates/pf-console-ui/src/shell/render.rs index 09a89019..c9c99eec 100644 --- a/crates/pf-console-ui/src/shell/render.rs +++ b/crates/pf-console-ui/src/shell/render.rs @@ -8,7 +8,7 @@ use crate::screens::{Bg, Ctx, Screen}; use crate::theme::{white, Fonts, PanelStroke, W, WHITE}; use pf_client_core::gamepad::PadInfo; use pf_client_core::trust; -use skia_safe::{Canvas, Color4f, Rect}; +use skia_safe::{Canvas, Rect}; use std::time::Instant; use super::{Motion, Shell, BOTTOM_BAND, TOP_BAND}; @@ -67,7 +67,9 @@ impl Shell { } }; - // Backdrop crossfade follows the top screen. + // The backdrop settles into (or out of) calm with the screen transition. It is the + // SAME living field either way — a form screen quiets it, it doesn't replace it — + // so this is one shader pass with a chased uniform, not two stacked backdrops. let bg_target = match self.stack.last().expect("non-empty").background() { Bg::Aurora => 0.0, Bg::Form => 1.0, @@ -76,16 +78,7 @@ impl Shell { if (self.bg_mix - bg_target).abs() < 0.005 { self.bg_mix = bg_target; } - if self.bg_mix < 1.0 { - self.draw_aurora(canvas, w, h, t); - } else { - canvas.clear(Color4f::new(0.0, 0.0, 0.0, 1.0)); - } - if self.bg_mix > 0.0 { - canvas.save_layer_alpha_f(None, self.bg_mix as f32); - crate::theme::draw_form_background(canvas, w, h); - canvas.restore(); - } + self.draw_aurora(canvas, w, h, t, self.bg_mix); // The screens, through the transition choreography. let content = Rect::from_ltrb( diff --git a/crates/pf-console-ui/src/shell/tests.rs b/crates/pf-console-ui/src/shell/tests.rs index d49e1a44..2f196e79 100644 --- a/crates/pf-console-ui/src/shell/tests.rs +++ b/crates/pf-console-ui/src/shell/tests.rs @@ -167,6 +167,47 @@ fn wake_gates_input_in_the_same_press() { assert!(s.handle_menu(MenuEvent::Move(MenuDir::Left)).is_some()); } +/// Every settings tab actually RASTERS. The eyeball dump below is `#[ignore]`d, so without +/// this nothing in the normal gate ever ran the tab strip's layout arithmetic or a settings +/// screen's rows — a bad index there would only surface on a Deck. CPU raster: the SkSL +/// backdrop, the layers and the text all run without a GPU. +#[test] +fn every_settings_tab_rasters() { + let fonts = crate::theme::build_fonts().unwrap(); + let (w, h) = (1280u32, 800u32); + let pads: Vec = Vec::new(); + let mut surface = skia_safe::surfaces::raster_n32_premul((w as i32, h as i32)).unwrap(); + let (mut s, _console, _library) = shell(vec![Screen::Home(HomeScreen::new())]); + s.handle_menu(MenuEvent::Tertiary); // X → Settings + + let mut frame = |s: &mut Shell| { + s.render( + surface.canvas(), + w, + h, + &fonts, + Some("Xbox Wireless Controller"), + Some(GamepadPref::Xbox360), + &pads, + ); + }; + // One lap of the strip — R1 wraps back to where it started. Every tab's rows fit on an + // 800-tall window at once, so ONE frame per tab draws all of them; the cursor is walked to + // the end first (input only, no render) so the focused and unfocused row paths both run. + // Deliberately frugal: a full-screen SkSL field on the CPU costs the better part of a second + // per frame in a debug build, and this test's job is to catch a panic, not to look pretty. + for _ in 0..crate::screens::settings::TAB_COUNT { + for _ in 0..12 { + s.handle_menu(MenuEvent::Move(MenuDir::Down)); + } + frame(&mut s); + s.handle_menu(MenuEvent::JumpForward); + } + // A narrow window is the case the strip has to shrink for (the pills are laid out from + // measured text, so a too-small width must clamp rather than lay out off-screen). + s.render(surface.canvas(), 640, 400, &fonts, None, None, &pads); +} + /// Render every console scene to PNGs for the eyeball pass (ignored; run with /// `PF_CONSOLE_DUMP= cargo test -p pf-console-ui --release -- --ignored dump`). /// CPU raster — the SkSL aurora, layers and text all run without a GPU. @@ -208,6 +249,26 @@ fn dump_console_screens() { dump(&mut s, 3, 25, "02-transition", true); dump(&mut s, 40, 8, "03-settings", true); + // The Interface tab (5 shoulder presses along) leads with the Background row, so this frame + // shows both the strip mid-list and the palette picker… + for _ in 0..5 { + s.handle_menu(MenuEvent::JumpForward); + } + dump(&mut s, 40, 8, "03b-settings-interface", true); + // …and cycling it three times lands on Ember, which is the whole point: the CALM backdrop + // behind these rows recolours live. + for _ in 0..3 { + s.handle_menu(MenuEvent::Confirm); + } + dump(&mut s, 40, 8, "03c-settings-ember", true); + // Back to the brand default and the first tab so the later scenes look like they always did. + for _ in 0..3 { + s.handle_menu(MenuEvent::Confirm); + } + for _ in 0..5 { + s.handle_menu(MenuEvent::JumpBack); + } + // Add Host with the keyboard tray up (keyboard glyph style: no pad). s.handle_menu(MenuEvent::Back); dump(&mut s, 40, 8, "_back", true); diff --git a/crates/pf-console-ui/src/theme.rs b/crates/pf-console-ui/src/theme.rs index 194fe600..fb59b0e9 100644 --- a/crates/pf-console-ui/src/theme.rs +++ b/crates/pf-console-ui/src/theme.rs @@ -112,59 +112,12 @@ pub(crate) fn drop_shadow(canvas: &Canvas, rect: Rect, corner: f32, k: f32, alph } // --- The form backdrop (settings / add-host / pair) -------------------------------------- - -/// The calm backdrop for the form screens — NOT the launcher's aurora (this stays still -/// and quiet), and deliberately not near-black: a deep indigo base plus two soft static -/// glows give the glass rows real color to sit on. A light top/bottom scrim grounds the -/// pinned title and hint bar (the Swift build blurs a tray instead; same job). -pub(crate) fn draw_form_background(canvas: &Canvas, w: f64, h: f64) { - let (wf, hf) = (w as f32, h as f32); - canvas.draw_rect( - Rect::from_wh(wf, hf), - &Paint::new(Color4f::new(0.075, 0.062, 0.150, 1.0), None), - ); - // Violet lift top-leading, cooler indigo bottom-trailing — elliptical (window - // aspect) via a unit-radius radial gradient under a scale. - for (cx, cy, color, alpha) in [ - (0.26, 0.14, Color4f::new(0.40, 0.31, 0.68, 1.0), 0.9f32), - (0.82, 0.90, Color4f::new(0.20, 0.24, 0.58, 1.0), 0.75), - ] { - let mut paint = Paint::default(); - let c = Color4f::new(color.r, color.g, color.b, alpha); - paint.set_shader(gradient_shader::radial( - Point::new(0.0, 0.0), - 0.78, - gradient_shader::GradientShaderColors::Colors(&[ - c.to_color(), - Color4f::new(color.r, color.g, color.b, 0.0).to_color(), - ]), - None, - TileMode::Clamp, - None, - None, - )); - canvas.save(); - canvas.translate((wf * cx, hf * cy)); - canvas.scale((wf, hf)); - canvas.draw_rect(Rect::from_ltrb(-1.0, -1.0, 1.0, 1.0), &paint); - canvas.restore(); - } - let mut scrim = Paint::default(); - scrim.set_shader(gradient_shader::linear( - (Point::new(0.0, 0.0), Point::new(0.0, hf)), - gradient_shader::GradientShaderColors::Colors(&[ - Color4f::new(0.0, 0.0, 0.0, 0.30).to_color(), - Color4f::new(0.0, 0.0, 0.0, 0.0).to_color(), - Color4f::new(0.0, 0.0, 0.0, 0.0).to_color(), - Color4f::new(0.0, 0.0, 0.0, 0.32).to_color(), - ]), - Some(&[0.0, 0.22, 0.74, 1.0][..]), - TileMode::Clamp, - None, - None, - )); - canvas.draw_rect(Rect::from_wh(wf, hf), &scrim); -} +// +// There isn't one any more. The form screens used to sit on a STATIC deep-indigo field +// drawn here, crossfaded over the launcher's aurora; they now wear the same living mesh at +// `calm = 1` (see `library::mesh_sksl` and `Shell::draw_aurora`), which keeps the glass rows +// on real colour, keeps the console's one backdrop palette-themed everywhere, and means no +// screen in the gamepad UI is ever backed by a still image. /// The loading/connecting spinner: a rotating 270° arc driven by the shell clock. pub(crate) fn spinner(canvas: &Canvas, cx: f64, cy: f64, r: f64, t: f64) { diff --git a/crates/pf-console-ui/src/widgets.rs b/crates/pf-console-ui/src/widgets.rs index 8ce4bb74..b5f9fa0b 100644 --- a/crates/pf-console-ui/src/widgets.rs +++ b/crates/pf-console-ui/src/widgets.rs @@ -84,6 +84,9 @@ pub(crate) struct MenuList { bump: Spring, scroll: f64, focus: Vec, + /// Next render, seat the scroll and the focus ease instantly instead of chasing — see + /// [`MenuList::jump_to`]. + snap: bool, } impl MenuList { @@ -93,9 +96,18 @@ impl MenuList { bump: Spring::rest(0.0), scroll: 0.0, focus: Vec::new(), + snap: true, } } + /// Move the cursor WITHOUT the scroll gliding there. For a tab switch, where the whole + /// row set is replaced: chasing would sweep the viewport through rows that no longer + /// exist, which reads as a glitch rather than as motion. + pub(crate) fn jump_to(&mut self, cursor: usize) { + self.cursor = cursor; + self.snap = true; + } + /// Route a menu event. Up/down move focus (Boundary = recoil), left/right become /// [`ListMsg::Adjust`], A becomes [`ListMsg::Activate`]. B is the SCREEN's. pub(crate) fn menu(&mut self, ev: MenuEvent, len: usize) -> (ListMsg, Option) { @@ -136,10 +148,19 @@ impl MenuList { dt: f64, active: bool, ) { + if self.snap { + // A replaced row set has no shared history with the old one — start every row's + // focus ease from scratch so the new cursor is simply THERE. + self.focus.clear(); + } self.focus.resize(rows.len(), 0.0); for (i, f) in self.focus.iter_mut().enumerate() { let target = if active && i == self.cursor { 1.0 } else { 0.0 }; - *f = approach(*f, target, dt, 0.06); + *f = if self.snap { + target + } else { + approach(*f, target, dt, 0.06) + }; } self.bump.step(0.0, BUMP_K, BUMP_C, dt); self.bump.settle(0.0, 0.3, 4.0); @@ -160,7 +181,11 @@ impl MenuList { // The scroll chases the focused row into the middle band, clamped to content. let focused_center = tops.get(self.cursor).map_or(0.0, |t| (t + ROW_H / 2.0) * k); let target = (focused_center - view_h / 2.0).clamp(0.0, (content_h - view_h).max(0.0)); - self.scroll = approach(self.scroll, target, dt, 0.08); + self.scroll = if std::mem::take(&mut self.snap) { + target + } else { + approach(self.scroll, target, dt, 0.08) + }; let row_w = (ROW_MAX_W * k).min(f64::from(rect.width()) - 48.0 * k); let x0 = f64::from(rect.left) + (f64::from(rect.width()) - row_w) / 2.0; @@ -272,6 +297,98 @@ impl MenuList { } } +// --- Tab strip --------------------------------------------------------------------------- + +/// The strip's design height, including the air under it before the first row. +pub(crate) const TAB_STRIP_H: f64 = 46.0; + +/// The horizontal section switcher above a menu list. Purely presentational — the SCREEN +/// owns which tab is selected and what the shoulders do; this draws the pills and slides +/// one highlight between them, so switching sections reads as travel rather than a swap. +pub(crate) struct TabStrip { + /// Chased highlight geometry `(x, width)` in device px. `None` until the first render, + /// so a freshly opened screen doesn't animate its highlight in from x = 0. + indicator: Option<(f64, f64)>, +} + +impl TabStrip { + pub(crate) fn new() -> TabStrip { + TabStrip { indicator: None } + } + + /// Draw the pills centered in `rect`'s top band. Returns nothing — the caller already + /// knows the band is [`TAB_STRIP_H`] tall. + #[allow(clippy::too_many_arguments)] // the crate's render signature, same as MenuList's + pub(crate) fn render( + &mut self, + canvas: &Canvas, + rect: Rect, + labels: &[&str], + selected: usize, + fonts: &Fonts, + k: f64, + dt: f64, + ) { + if labels.is_empty() { + return; + } + let size = 13.0 * k; + let pad_x = 13.0 * k; + let gap = 7.0 * k; + let pill_h = 30.0 * k; + let widths: Vec = labels + .iter() + .map(|l| f64::from(fonts.measure(l, W::SemiBold, size)) + 2.0 * pad_x) + .collect(); + let total: f64 = widths.iter().sum::() + gap * (labels.len() - 1) as f64; + let mut x = f64::from(rect.left) + (f64::from(rect.width()) - total) / 2.0; + let top = f64::from(rect.top) + 2.0 * k; + + // Where the highlight wants to be, then the eased position it actually draws at. + let sel = selected.min(labels.len() - 1); + let target = ( + x + widths[..sel].iter().sum::() + gap * sel as f64, + widths[sel], + ); + let (ix, iw) = match self.indicator { + None => target, + Some((cx, cw)) => ( + approach(cx, target.0, dt, 0.07), + approach(cw, target.1, dt, 0.07), + ), + }; + self.indicator = Some((ix, iw)); + crate::theme::panel( + canvas, + Rect::from_xywh(ix as f32, top as f32, iw as f32, pill_h as f32), + (pill_h / 2.0 / k) as f32, + Some(brand(0.85)), + PanelStroke::Plain(0.22), + k as f32, + ); + + let baseline = top + pill_h / 2.0 + size * 0.36; + for (i, label) in labels.iter().enumerate() { + // Fade each label toward white by how much the highlight actually covers it, so + // the two labels a sliding highlight passes between light up together. + let pill_x = x; + let overlap = (pill_x + widths[i]).min(ix + iw) - pill_x.max(ix); + let covered = (overlap / widths[i]).clamp(0.0, 1.0) as f32; + let tw = f64::from(fonts.measure(label, W::SemiBold, size)); + fonts.draw( + canvas, + label, + pill_x + (widths[i] - tw) / 2.0, + baseline, + W::SemiBold, + size, + white(0.5 + 0.5 * covered), + ); + x += widths[i] + gap; + } + } +} + /// Middle-of-nowhere helper: drop chars from the FRONT until the tail fits. fn truncate_head(fonts: &Fonts, text: &str, w: W, size: f64, max_w: f64) -> String { if f64::from(fonts.measure(text, w, size)) <= max_w { From b25e6eda913f750f71d147830d093f0ac46817ad Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 6 Aug 2026 13:30:10 +0200 Subject: [PATCH 11/18] fix(clients): host discovery heals itself, and every client can rescan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A field report from an iPad: the host is not found on first run, and restarting the client finds it. Pull-to-refresh appeared to do nothing. Both were real. The Apple client's discovery had three ways to go permanently deaf, each needing an app relaunch to clear: - A failed resolve was never retried. `browseResultsChangedHandler` only fires when the result SET changes, and a host whose resolve failed is still in the set — so nothing ever re-offered it. - A stuck resolve never ended. `NWConnection` has no timeout, so the throwaway UDP flow used to resolve an address could sit in `.preparing`/`.waiting` forever, and a service with a connection in flight was skipped. - `NWBrowser` parking in `.waiting` was ignored (only `.failed` re-armed). On iOS that is where the local-network privacy prompt lands on first launch after install: the browse starts, the system asks, and the browser waits. Granting does not revive that browser — only a new one sees the grant. That is the reported first-run bug. HostDiscovery now runs a 1 Hz sweep that times out stuck resolves, retries failed ones on a 1→30 s backoff, and re-arms a browser that stopped working; the advert's TXT is re-read on every browse report, so a host that re-keys or flips its pairing policy is followed. Returning to the foreground re-arms the browse (iOS/tvOS: `onAppear` does not fire across background/foreground, and a suspended browse stays dead). Pull-to-refresh did nothing because there was no `.refreshable` in the client at all. Added, plus the explicit control the report asked for: a toolbar Refresh on iOS/macOS, an action-row button on tvOS, a Rescan tile in the gamepad launcher, Scan Again on the empty state, a header-bar button in the GTK client, a hosts-page button on Windows, and Scan again on Android. Decky already had one. The desktop/Android browses needed a rescan trigger to make those buttons mean anything: mdns-sd re-queries on a doubling backoff capped at ONE HOUR, so a long-lived browse is effectively passive and a host that appears later can stay invisible. `discovery::Rescan` forces a fresh query; the wake-and-wait loops use it too, so a host that just booted is noticed in seconds rather than at the next backoff tick. Also fixed, found on the way: clients/windows/src/discovery.rs is a second copy of the browse that d0fa8bd3 ("pin mDNS discovery to IPv4 on every client") missed. It took an arbitrary first address, so when a host's OS responder answered AAAA the Windows GUI rendered a card that failed on every click. It also never noticed a dropped receiver, leaking a thread and a :5353 socket per wake-and-wait. Gates: Apple macOS + iOS (arm64-apple-ios17.0, proven non-vacuous) build clean, 195 tests pass incl. a new one asserting a rescan re-finds a still-advertising host. On .21: fmt, clippy --all-targets -D warnings and build clean for pf-client-core + client-linux + client-session, 117 tests pass. Android :kit: and :app: compileDebugKotlin clean. The Windows client is UNGATED — its CI runner was unreachable. --- .../kotlin/io/unom/punktfunk/ConnectScreen.kt | 52 ++- .../punktfunk/kit/discovery/HostDiscovery.kt | 21 + .../Sources/PunktfunkClient/ContentView.swift | 14 + .../Home/GamepadHomeView.swift | 23 +- .../PunktfunkClient/Home/HomeView.swift | 43 +- .../Connection/HostDiscovery.swift | 366 ++++++++++++++---- .../HostDiscoveryTests.swift | 16 + clients/linux/src/ui_hosts.rs | 25 +- clients/linux/src/ui_trust.rs | 11 +- clients/session/src/console.rs | 14 +- clients/windows/src/app/connect.rs | 10 +- clients/windows/src/app/hosts.rs | 16 + clients/windows/src/app/mod.rs | 8 +- clients/windows/src/discovery.rs | 62 ++- clients/windows/src/main.rs | 2 +- crates/pf-client-core/src/discovery.rs | 50 ++- 16 files changed, 625 insertions(+), 108 deletions(-) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt index c8e80229..a0264bac 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt @@ -168,8 +168,7 @@ fun ConnectScreen( lnpPrompt = false // The browse started while blocked (its sockets failed or received nothing) — restart it // now that the grant makes them work. - discovery.stop() - discovery.start() + discovery.restart() } else { lnpPrompt = true // rationale + "Open settings" (a permanently-denied request returns instantly) } @@ -191,12 +190,27 @@ fun ConnectScreen( // or otherwise notify the app — this observer is what turns the grant into a live discovery. DisposableEffect(Unit) { val lifecycle = (context as? LifecycleOwner)?.lifecycle + // Whether we've actually been away. ON_RESUME also fires on first entry, right after the + // effect below starts the browse — restarting it there would be pure churn. + var wasPaused = false val obs = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME && !lnpGranted && hasLocalNetworkPermission(context)) { - lnpGranted = true - lnpPrompt = false - discovery.stop() - discovery.start() + when (event) { + Lifecycle.Event.ON_PAUSE -> wasPaused = true + Lifecycle.Event.ON_RESUME -> { + if (!lnpGranted && hasLocalNetworkPermission(context)) { + lnpGranted = true + lnpPrompt = false + discovery.restart() + } else if (wasPaused) { + // Coming back from the background: the browse may have been sitting idle + // (or had its multicast socket torn out from under it) while we were away, + // and its own re-query interval has kept doubling. Re-arm and ask again, + // so returning to the screen is enough — no app restart. + discovery.restart() + } + wasPaused = false + } + else -> {} } } lifecycle?.addObserver(obs) @@ -1009,20 +1023,28 @@ fun ConnectScreen( // 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. - if (lnpGranted && !connecting && discovered.isEmpty()) { + // 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, ) { - 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, - ) + 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") } } } } diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/discovery/HostDiscovery.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/discovery/HostDiscovery.kt index e67b766a..18b6bd39 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/discovery/HostDiscovery.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/discovery/HostDiscovery.kt @@ -132,6 +132,27 @@ class HostDiscovery(context: Context) { handler.post(poll) } + /** + * Tear the browse down and start a fresh one. This is the manual rescan, and the recovery path + * for a browse that started while blocked (permission not yet granted, multicast filtered) or + * that never started at all ([start] gives up when `nativeDiscoveryStart` returns 0, and + * nothing else would ever retry it). + * + * It also puts a query back on the wire: `mdns-sd` re-queries on a doubling backoff that caps + * at an hour, so a long-lived browse is effectively passive — a host that appeared since, or + * whose announcement was lost to multicast, may never be asked for again. + * + * The currently-shown host set is left alone across the swap (rather than blinking empty via + * [stop]'s notification); the first poll of the new browse publishes the fresh set. + */ + fun restart() { + val keep = onChange + onChange = null + stop() + onChange = keep + start() + } + fun stop() { if (!running && nativeHandle == 0L) return running = false diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index c28d6add..d4b0e776 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -206,6 +206,20 @@ struct ContentView: View { model.setStatsVerbosity(StatsVerbosity(rawValue: raw) ?? .normal) } #if os(iOS) || os(tvOS) + // Coming back to the app re-arms the LAN browse. The home's `onAppear`/`onDisappear` do + // NOT fire across background/foreground, and a browse the system suspended while we were + // away does not resume on its own — so the host grid came back empty and stayed empty + // until the app was relaunched. No-op unless the browse is already running (mid-session + // the home has deliberately torn it down). + // + // Mobile only: macOS never suspends the process, and its `scenePhase` flips on every + // window focus change — re-arming there would rebuild the browser each time you alt-tab. + // A Mac browse that genuinely breaks is caught by `HostDiscovery`'s own sweep instead. + .onChange(of: scenePhase) { _, phase in + if phase == .active { discovery.refreshIfRunning() } + } + #endif + #if os(iOS) || os(tvOS) // Backgrounding driver. Only .background/.active matter; .inactive (a transient peek) is // ignored so neither branch fires for a Control-Center pull. // diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift index 2db6afc4..1282fdb8 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift @@ -23,14 +23,15 @@ import SwiftUI #if os(iOS) || os(macOS) || os(tvOS) import GameController -/// One navigable tile: a saved host, a discovered-but-unsaved one, or the trailing Add Host -/// action. Hashable so it can be the carousel's scroll-position identity. +/// One navigable tile: a saved host, a discovered-but-unsaved one, or one of the trailing +/// actions. Hashable so it can be the carousel's scroll-position identity. private enum GamepadHomeTarget: Hashable { /// A saved host's own tile, or one of its pinned host+profile cards (§5.2a) — which on a /// controller-first surface are THE profile affordance: focus and press, no menus. case saved(UUID, profile: String?) case discovered(String) case addHost + case rescan } /// A fully-resolved launcher tile — display fields + the activate action, built fresh each render @@ -262,10 +263,14 @@ struct GamepadHomeView: View { private var hints: [GamepadHint] { let selected = tiles.first { $0.id == selection } + let action: String? = switch selected?.id { + case .addHost: "Add Host" + case .rescan: "Rescan" + default: nil + } var hints = [GamepadHint( glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), - text: selected?.id == .addHost ? "Add Host" - : (selected?.canWake == true ? "Wake & Connect" : "Connect"))] + text: action ?? (selected?.canWake == true ? "Wake & Connect" : "Connect"))] if libraryEnabled, selected?.hasLibrary == true { hints.append(.init(glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library")) } @@ -325,7 +330,15 @@ struct GamepadHomeView: View { subtitle: "Register a host by address", icon: "plus", activate: { showAddHost = true }) - return saved + discovered + [add] + // A controller surface has no toolbar and no pull-to-refresh, so the rescan the field + // asked for is a tile like any other — one press from wherever the stick already is. + let rescan = HomeTile( + id: .rescan, + title: "Rescan", + subtitle: discovery.isScanning ? "Scanning…" : "Look for hosts on this network", + icon: "arrow.clockwise", + activate: { discovery.refresh() }) + return saved + discovered + [add, rescan] } /// Only saved hosts have a library — matches the touch grid, where "Browse Library…" is a diff --git a/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift b/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift index f0f68331..c0936b1e 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift @@ -53,7 +53,18 @@ struct HomeView: View { NavigationStack { Group { if store.hosts.isEmpty && discoveredUnsaved.isEmpty { - emptyState + #if os(tvOS) + emptyState // no pull-to-refresh on a remote; the action row carries Refresh + #else + // Inside a ScrollView purely so the pull gesture works on the ONE screen + // where a rescan matters most: the one that found nothing. + ScrollView { + emptyState + .frame(maxWidth: .infinity) + .containerRelativeFrame(.vertical) + } + .refreshable { await discovery.rescan() } + #endif } else { ScrollView { if !store.hosts.isEmpty { @@ -94,6 +105,7 @@ struct HomeView: View { } label: { Label("Settings", systemImage: "gearshape") } + refreshButton } .padding(.top, 24) // One FULL-WIDTH focus target for any downward move out of the grid. @@ -106,6 +118,9 @@ struct HomeView: View { .focusSection() #endif } + #if !os(tvOS) + .refreshable { await discovery.rescan() } + #endif } } .navigationTitle("Punktfunk") @@ -151,6 +166,7 @@ struct HomeView: View { if showsArrangeMenu { ToolbarItem(placement: .topBarTrailing) { arrangeMenu } } + ToolbarItem(placement: .topBarTrailing) { refreshButton } ToolbarItem(placement: .topBarTrailing) { addHostButton } #else if showsArrangeMenu { @@ -159,6 +175,10 @@ struct HomeView: View { .help("Sort and group the host list") } } + ToolbarItem(placement: .primaryAction) { + refreshButton + .help("Scan the network for hosts again") + } ToolbarItem(placement: .primaryAction) { addHostButton .help("Add a host") @@ -324,13 +344,20 @@ struct HomeView: View { ContentUnavailableView { Label("No Hosts", systemImage: "rectangle.connected.to.line.below") } description: { - Text("Add your punktfunk host with the + button.") + Text("Add your punktfunk host with the + button, or scan the network again.") } actions: { Button("Add Host") { showAddHost = true } .glassProminentButtonStyle() #if os(iOS) .controlSize(.large) #endif + // The screen a host SHOULD have appeared on is where a rescan is worth offering + // outright rather than hiding behind a pull gesture. + Button("Scan Again") { discovery.refresh() } + .disabled(discovery.isScanning) + #if os(iOS) + .controlSize(.large) + #endif #if os(tvOS) Button("Settings") { showSettings = true } #endif @@ -345,6 +372,18 @@ struct HomeView: View { } } + /// Re-run mDNS discovery from scratch. Discovery heals itself now (`HostDiscovery`'s sweep), + /// so this is the fallback the field asked for — and the fastest way past the iOS + /// local-network permission gate, which only a NEW browser can clear. + private var refreshButton: some View { + Button { + discovery.refresh() + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .disabled(discovery.isScanning) + } + #if !os(tvOS) /// One host has no order and nothing to divide, so the control stays out of the way until /// there is a list to arrange. diff --git a/clients/apple/Sources/PunktfunkKit/Connection/HostDiscovery.swift b/clients/apple/Sources/PunktfunkKit/Connection/HostDiscovery.swift index 3bc17a8a..316f2c8d 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/HostDiscovery.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/HostDiscovery.swift @@ -9,6 +9,25 @@ // // iOS/tvOS gate Bonjour browsing on Info.plist `NSBonjourServices` listing `_punktfunk._udp` // (Config/Info.plist) — without it the system blocks the browse and nothing is returned. +// +// SELF-HEALING is what the bookkeeping below is for. Neither Network.framework primitive +// recovers on its own, and all three failure modes read as "the host isn't there": +// +// - `browseResultsChangedHandler` fires only when the result SET changes. A service that is +// found but whose resolve fails is never re-offered — from the browser's point of view +// nothing changed — so one unlucky resolve hid that host for the life of the process. +// - `NWConnection` has no timeout. A resolve that cannot complete (v6-only advert against our +// IPv4 pin, Wi-Fi still associating, host mid-reboot) parks in `.preparing`/`.waiting` +// forever instead of failing, so the retry path above was never even reached. +// - `NWBrowser` parks in `.waiting` when the browse is blocked. On iOS that is where the LOCAL +// NETWORK PRIVACY gate lands the first launch after install: the browse starts, the system +// puts up its "find and connect to devices on your local network" prompt, and the browser +// waits. Granting permission does NOT revive that browser — only a new one sees the grant. +// +// Every one of those presented as "restarting the app fixes it", which is what field reports +// described. A 1 Hz `sweep` therefore times out stuck resolves, retries failed ones on a backoff +// and re-arms a browser that stopped working; `refresh()` forces the same recovery immediately, +// behind the UI's pull-to-refresh and Refresh button. #if canImport(Network) import Foundation @@ -48,12 +67,50 @@ public struct DiscoveredHost: Identifiable, Sendable, Equatable { public final class HostDiscovery: ObservableObject { /// Currently-visible hosts, deduped by `id`, sorted by name. Main-actor. @Published public private(set) var hosts: [DiscoveredHost] = [] + /// True for a moment after a rescan is kicked off, so a Refresh control can show that it did + /// something on the surfaces with no pull-to-refresh spinner of their own (macOS, tvOS). + @Published public private(set) var isScanning = false private var browser: NWBrowser? - /// Keyed by the service endpoint's description (a stable, Sendable handle we can capture - /// into the resolve callbacks without smuggling non-Sendable Network types across hops). - private var resolved: [String: DiscoveredHost] = [:] + /// Every service the browser currently reports, keyed by the endpoint's description (a stable, + /// Sendable handle we can capture into the resolve callbacks without smuggling non-Sendable + /// Network types across hops). Held — not just diffed — so a retry can re-resolve a service + /// the browser will never report again (see the file header). + private var services: [String: NWBrowser.Result] = [:] + /// The transport address a completed resolve produced, per service key. The rest of a + /// `DiscoveredHost` comes from the advert's TXT, which is re-read on every browse report. + private var addresses: [String: (host: String, port: UInt16)] = [:] private var connections: [String: NWConnection] = [:] + /// Deadline for each in-flight resolve — `NWConnection` has none of its own. + private var deadlines: [String: Date] = [:] + /// Consecutive failed resolves per service, and when the next attempt is allowed. + private var failures: [String: Int] = [:] + private var retryAt: [String: Date] = [:] + /// Services whose address should be re-resolved even though we already have one — set by + /// `refresh()`. The old address keeps showing until the new one lands, so a rescan never + /// blinks the list empty; without this a manual Refresh silently skipped every host it had + /// already resolved, which is exactly the host whose address may have moved. + private var staleAddresses: Set = [] + /// Consecutive non-ready browser states, and when to tear it down and re-arm. nil = healthy. + private var browserFailures = 0 + private var browserRearmAt: Date? + /// Bumped on every re-arm so callbacks from a superseded browser — and from the resolves it + /// started — are ignored instead of clobbering the current generation's bookkeeping. + private var generation = 0 + /// The 1 Hz maintenance tick. Nothing else re-drives a stuck resolve or a sick browser. + private var sweep: Task? + private var scanningUntil: Date? + + /// A LAN resolve answers in milliseconds; this only has to outlast a slow Wi-Fi wake. + private static let resolveTimeout: TimeInterval = 6 + /// How long `isScanning` holds — and `rescan()` waits — after a manual refresh. + private static let scanSettle: TimeInterval = 1.5 + /// 1s, 2s, 4s, 8s … capped at 30s, for the resolve retry and the browser re-arm alike. Long + /// enough that a genuinely-down network doesn't spin the main queue, short enough that a host + /// coming back is picked up while the user is still looking at the screen. + private static func backoff(_ failures: Int) -> TimeInterval { + min(pow(2, Double(max(0, failures - 1))), 30) + } public init() {} @@ -63,34 +120,73 @@ public final class HostDiscovery: ObservableObject { guard !debugPinned else { return } // a seeded advert set outranks the live LAN #endif guard browser == nil else { return } - let browser = NWBrowser( - for: .bonjourWithTXTRecord(type: "_punktfunk._udp", domain: nil), - using: NWParameters()) - browser.browseResultsChangedHandler = { results, _ in - MainActor.assumeIsolated { [weak self] in self?.reconcile(results) } - } - browser.stateUpdateHandler = { state in - // A failed browser never recovers on its own; tear down and re-arm so transient - // network changes (Wi-Fi flip, VPN) don't leave discovery silently dead. - MainActor.assumeIsolated { [weak self] in - if case .failed = state { self?.restart() } - } - } - self.browser = browser - browser.start(queue: .main) + armBrowser() + startSweep() } /// Stop browsing and drop all discovered state. public func stop() { + sweep?.cancel() + sweep = nil + generation &+= 1 browser?.cancel() browser = nil for conn in connections.values { conn.cancel() } connections.removeAll() - resolved.removeAll() + deadlines.removeAll() + services.removeAll() + addresses.removeAll() + failures.removeAll() + retryAt.removeAll() + staleAddresses.removeAll() + browserFailures = 0 + browserRearmAt = nil + scanningUntil = nil + if isScanning { isScanning = false } if !hosts.isEmpty { hosts = [] } } + /// Force a rescan now: re-arm the browser and retry every service whose resolve had failed, + /// clearing the backoffs so nothing is left waiting. This is the manual escape hatch for the + /// failure modes in the file header — and the only thing that clears the iOS local-network + /// permission gate without an app restart, since only a NEW browser sees a permission the + /// user granted after the old one started. + /// + /// Also starts discovery if it wasn't running, so a Refresh button does the obvious thing. + public func refresh() { + #if DEBUG + guard !debugPinned else { return } // as in `start()` — the harness's set is the truth + #endif + isScanning = true + scanningUntil = Date().addingTimeInterval(Self.scanSettle) + failures.removeAll() + retryAt.removeAll() + staleAddresses = Set(services.keys) + browserFailures = 0 + armBrowser() + startSweep() + pump() + } + + /// `refresh()` for a `.refreshable` gesture: holds briefly so the control's spinner reflects a + /// browse that had time to answer instead of blinking out instantly. + public func rescan() async { + refresh() + try? await Task.sleep(nanoseconds: UInt64(Self.scanSettle * 1_000_000_000)) + } + + /// `refresh()`, but only when discovery is already running — the app-foreground hook. iOS + /// suspends a backgrounded process's browse and `onAppear`/`onDisappear` don't fire across + /// background/foreground, so a browse that died while suspended stayed dead on return; this + /// re-arms it without starting a browse on a screen that deliberately isn't browsing + /// (mid-session, where the home tore discovery down). + public func refreshIfRunning() { + guard browser != nil else { return } + refresh() + } + deinit { + sweep?.cancel() browser?.cancel() for conn in connections.values { conn.cancel() } } @@ -124,48 +220,103 @@ public final class HostDiscovery: ObservableObject { } #endif - private func restart() { - stop() - start() + // MARK: - Browser + + /// Build and start a fresh browser, retiring the previous one and every resolve it started. + /// Those resolves' callbacks are gated on `generation`, so they must not be left holding map + /// entries — `pump()` restarts them against the new generation. + private func armBrowser() { + generation &+= 1 + browser?.cancel() + for conn in connections.values { conn.cancel() } + connections.removeAll() + deadlines.removeAll() + browserRearmAt = nil + + let generation = self.generation + let browser = NWBrowser( + for: .bonjourWithTXTRecord(type: "_punktfunk._udp", domain: nil), + using: NWParameters()) + browser.browseResultsChangedHandler = { results, _ in + MainActor.assumeIsolated { [weak self] in + guard let self, generation == self.generation else { return } + self.reconcile(results) + } + } + browser.stateUpdateHandler = { state in + MainActor.assumeIsolated { [weak self] in + guard let self, generation == self.generation else { return } + self.browserStateChanged(state) + } + } + self.browser = browser + browser.start(queue: .main) } - /// Diff the browser's current result set against what we're tracking: drop departed - /// services, resolve newly-seen ones. - private func reconcile(_ results: Set) { - let live = Set(results.map { Self.key($0) }) - for key in resolved.keys where !live.contains(key) { resolved[key] = nil } - for key in connections.keys where !live.contains(key) { - connections[key]?.cancel() - connections[key] = nil + /// A browser that stops working never recovers on its own, and it has two ways to stop: + /// `.failed` (dead) and `.waiting` (blocked — a network change, or the iOS local-network + /// permission gate described in the file header). Schedule a re-arm for both, on a backoff: + /// re-arming synchronously on `.failed` alone both missed the permission case entirely and + /// could spin the main queue on a browser that fails instantly every time. + private func browserStateChanged(_ state: NWBrowser.State) { + switch state { + case .ready: + browserFailures = 0 + browserRearmAt = nil + case .failed, .waiting: + guard browserRearmAt == nil else { return } // one re-arm already scheduled + browserFailures += 1 + browserRearmAt = Date().addingTimeInterval(Self.backoff(browserFailures)) + default: + break // .setup / .cancelled — nothing to heal } + } + + /// Diff the browser's current result set against what we're tracking: drop departed services, + /// record the rest — re-reading the advert every time, so a host that re-keys, moves or flips + /// its pairing policy republishes under the same name and the card follows it — then resolve + /// whatever still needs an address. + private func reconcile(_ results: Set) { + var live: Set = [] for result in results { let key = Self.key(result) - if resolved[key] == nil, connections[key] == nil { resolve(result) } + live.insert(key) + services[key] = result } + for key in Array(services.keys) where !live.contains(key) { forget(key) } publish() + pump() + } + + private func forget(_ key: String) { + connections[key]?.cancel() + connections[key] = nil + deadlines[key] = nil + services[key] = nil + addresses[key] = nil + failures[key] = nil + retryAt[key] = nil + staleAddresses.remove(key) + } + + // MARK: - Resolve + + /// Start the resolves that are due: every live service with no address yet, nothing in flight, + /// and past its retry time. + private func pump() { + let now = Date() + for (key, result) in services { + guard addresses[key] == nil || staleAddresses.contains(key) else { continue } + guard connections[key] == nil else { continue } + if let at = retryAt[key], at > now { continue } + resolve(key, result) + } } /// Resolve one service to IP:port via a short UDP connection (it reaches `.ready` once the - /// path is established — no data is sent), reading the TXT up front so the callback only - /// captures Sendable values + the endpoint key. - private func resolve(_ result: NWBrowser.Result) { - let key = Self.key(result) - let name = Self.instanceName(result.endpoint) - var fp: String? - var pair: String? - var id: String? - var macs: [String] = [] - var osChain = "" - if case let .bonjour(txt) = result.metadata { - fp = Self.entry(txt, "fp") - pair = Self.entry(txt, "pair") - id = Self.entry(txt, "id") - macs = (Self.entry(txt, "mac") ?? "") - .split(separator: ",") - .map { $0.trimmingCharacters(in: .whitespaces) } - .filter { !$0.isEmpty } - osChain = sanitizeOsChain(Self.entry(txt, "os") ?? "") - } + /// path is established — no data is sent). The TXT is NOT read here: it comes from the browse + /// result at publish time, so a re-advertised host doesn't need a fresh resolve to be re-read. + private func resolve(_ key: String, _ result: NWBrowser.Result) { // Resolve over IPv4 only: Network.framework prefers IPv6 (RFC 6724), and the host's OS // mDNS responder often answers AAAA for its hostname even though the punktfunk host stack // (control QUIC + data UDP) binds IPv4 sockets exclusively — a v6-resolved address would @@ -177,44 +328,125 @@ public final class HostDiscovery: ObservableObject { } let conn = NWConnection(to: result.endpoint, using: params) connections[key] = conn + deadlines[key] = Date().addingTimeInterval(Self.resolveTimeout) + let generation = self.generation conn.stateUpdateHandler = { state in MainActor.assumeIsolated { [weak self] in - guard let self, let conn = self.connections[key] else { return } + // Look the connection back up rather than capturing it — capturing it here would + // retain the connection through its own handler. + guard let self, generation == self.generation, + let conn = self.connections[key] else { return } switch state { case .ready: - if case let .hostPort(host, port)? = conn.currentPath?.remoteEndpoint, - let address = Self.hostString(host) { - self.resolved[key] = DiscoveredHost( - id: (id?.isEmpty == false) ? id! : name, - name: name, host: address, port: port.rawValue, - fingerprintHex: fp, requiresPairing: pair == "required", - allowsTofu: pair == "optional", macAddresses: macs, - osChain: osChain) - self.publish() - } - conn.cancel() + let endpoint = conn.currentPath?.remoteEndpoint self.connections[key] = nil + self.deadlines[key] = nil + conn.cancel() + if case let .hostPort(host, port)? = endpoint, + let address = Self.hostString(host) { + self.addresses[key] = (address, port.rawValue) + self.failures[key] = nil + self.retryAt[key] = nil + self.staleAddresses.remove(key) + self.publish() + } else { + // Ready but no usable remote — a failed attempt, not a finished one. + self.resolveFailed(key) + } case .failed, .cancelled: self.connections[key] = nil + self.deadlines[key] = nil + self.resolveFailed(key) default: - break + break // .preparing / .waiting — the sweep's deadline is what ends these } } } conn.start(queue: .main) } - /// Publish the resolved set, deduped by `id` (a host on several interfaces / re-advertising - /// collapses to one row), sorted by name. + private func resolveFailed(_ key: String) { + let count = (failures[key] ?? 0) + 1 + failures[key] = count + retryAt[key] = Date().addingTimeInterval(Self.backoff(count)) + } + + // MARK: - Sweep + + private func startSweep() { + sweep?.cancel() + sweep = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 1_000_000_000) + guard !Task.isCancelled, let self else { return } + self.tick() + } + } + } + + private func tick() { + let now = Date() + // Time out the resolves that parked. Without this they never end, and `pump()` skips a + // service that has a connection in flight — so that host stayed invisible indefinitely. + for key in deadlines.filter({ $0.value <= now }).keys { + connections[key]?.cancel() + connections[key] = nil + deadlines[key] = nil + resolveFailed(key) + } + if let at = browserRearmAt, at <= now { armBrowser() } + pump() + if let until = scanningUntil, until <= now { + scanningUntil = nil + isScanning = false + } + } + + // MARK: - Publish + + /// Publish the live adverts that have an address, deduped by `id` (a host on several + /// interfaces / re-advertising collapses to one row), sorted by name. private func publish() { var byID: [String: DiscoveredHost] = [:] - for host in resolved.values { byID[host.id] = host } + for key in services.keys.sorted() { + guard let result = services[key], let address = addresses[key] else { continue } + let host = Self.host(from: result, address: address.host, port: address.port) + byID[host.id] = host + } let next = byID.values.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } if next != hosts { hosts = next } } + /// Join a browse result's advert (instance name + TXT) to a resolved address. + private static func host( + from result: NWBrowser.Result, address: String, port: UInt16 + ) -> DiscoveredHost { + let name = instanceName(result.endpoint) + var fp: String? + var pair: String? + var id: String? + var macs: [String] = [] + var osChain = "" + if case let .bonjour(txt) = result.metadata { + fp = entry(txt, "fp") + pair = entry(txt, "pair") + id = entry(txt, "id") + macs = (entry(txt, "mac") ?? "") + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + osChain = sanitizeOsChain(entry(txt, "os") ?? "") + } + return DiscoveredHost( + id: (id?.isEmpty == false) ? id! : name, + name: name, host: address, port: port, + fingerprintHex: fp, requiresPairing: pair == "required", + allowsTofu: pair == "optional", macAddresses: macs, + osChain: osChain) + } + private static func key(_ result: NWBrowser.Result) -> String { "\(result.endpoint)" } diff --git a/clients/apple/Tests/PunktfunkKitTests/HostDiscoveryTests.swift b/clients/apple/Tests/PunktfunkKitTests/HostDiscoveryTests.swift index c8b58346..59db6ff3 100644 --- a/clients/apple/Tests/PunktfunkKitTests/HostDiscoveryTests.swift +++ b/clients/apple/Tests/PunktfunkKitTests/HostDiscoveryTests.swift @@ -50,5 +50,21 @@ final class HostDiscoveryTests: XCTestCase { XCTAssertEqual(host.fingerprintHex, String(repeating: "ab", count: 32)) XCTAssertFalse(host.host.isEmpty, "a resolved address is required to connect") XCTAssertGreaterThan(host.port, 0, "a resolved port is required to connect") + + // A rescan tears the browser down and re-arms it (the only way past the iOS local-network + // permission gate without relaunching). The host must come BACK — `refresh()` cancels every + // in-flight resolve and invalidates the previous generation's callbacks, so a re-arm that + // failed to re-drive them would leave the list permanently empty. + await discovery.rescan() + var reappeared = false + let rescanDeadline = Date().addingTimeInterval(10) + while Date() < rescanDeadline { + if await discovery.hosts.contains(where: { $0.id == uniqueid }) { + reappeared = true + break + } + try await Task.sleep(nanoseconds: 200_000_000) + } + XCTAssertTrue(reappeared, "a rescan must re-find a host that is still advertising") } } diff --git a/clients/linux/src/ui_hosts.rs b/clients/linux/src/ui_hosts.rs index b1da11fd..e40a0894 100644 --- a/clients/linux/src/ui_hosts.rs +++ b/clients/linux/src/ui_hosts.rs @@ -674,6 +674,9 @@ pub struct HostsPage { saved: FactoryVecDeque, discovered: FactoryVecDeque, widgets: PageWidgets, + /// Forces the mDNS browse to re-query (the header's Refresh button). `None` only if the + /// browse never started — the button then just re-renders, which is what it did before. + rescan: Option, } struct PageWidgets { @@ -693,6 +696,10 @@ pub enum HostsMsg { }, /// Reload the disk store and re-render (fresh pairings, renames, the library gate). Refresh, + /// Re-query mDNS *and* re-render — the header's Refresh button. Distinct from [`Self::Refresh`], + /// which only re-reads local state: after a while `mdns-sd` re-queries about once an hour, so a + /// host that appeared since (or whose announcement was lost) needs an actual query to show up. + Rescan, /// A completed reachability sweep: saved-host key → reachable. Merged into the online pips. Probed(HashMap), /// Mark the card matching `ConnectRequest::card_key` as connecting; `None` restores. @@ -841,6 +848,13 @@ impl SimpleComponent for HostsPage { add_host_btn.set_tooltip_text(Some("Add host")); add_host_btn.set_action_name(Some("win.add-host")); header.pack_start(&add_host_btn); + let rescan_btn = gtk::Button::from_icon_name("view-refresh-symbolic"); + rescan_btn.set_tooltip_text(Some("Scan the network for hosts again")); + { + let sender = sender.clone(); + rescan_btn.connect_clicked(move |_| sender.input(HostsMsg::Rescan)); + } + header.pack_start(&rescan_btn); let menu = gio::Menu::new(); menu.append(Some("Preferences"), Some("win.preferences")); menu.append(Some("Keyboard Shortcuts"), Some("win.shortcuts")); @@ -867,8 +881,8 @@ impl SimpleComponent for HostsPage { } // Stream mDNS adverts into the model; every add/remove re-evaluates both grids. + let (rx, rescan) = discovery::browse(); { - let rx = discovery::browse(); let sender = sender.clone(); glib::spawn_future_local(async move { while let Ok(event) = rx.recv().await { @@ -937,6 +951,7 @@ impl SimpleComponent for HostsPage { disc_heading, searching, }, + rescan: Some(rescan), }; model.rebuild(); @@ -954,6 +969,14 @@ impl SimpleComponent for HostsPage { self.rebuild(); } HostsMsg::Refresh => self.rebuild(), + HostsMsg::Rescan => { + if let Some(rescan) = &self.rescan { + rescan.request(); + } + // Adverts stream in as they answer; re-render now so the local half is current + // either way. + self.rebuild(); + } HostsMsg::Probed(map) => { self.probed = map; self.rebuild(); diff --git a/clients/linux/src/ui_trust.rs b/clients/linux/src/ui_trust.rs index f078462f..791218b4 100644 --- a/clients/linux/src/ui_trust.rs +++ b/clients/linux/src/ui_trust.rs @@ -46,8 +46,13 @@ pub fn wake_and_connect( let sender = sender.clone(); glib::spawn_future_local(async move { use std::time::Duration; - let events = crate::discovery::browse(); + let (events, rescan) = crate::discovery::browse(); let mut wait = WakeWait::new(); + // A waking host starts advertising at a moment we can't predict, and `mdns-sd`'s own + // re-query interval has doubled well past a minute by the time a boot finishes — so ask + // again periodically instead of waiting to be told. Every 5th tick: often enough that a + // host that came up is noticed promptly, rare enough not to hammer multicast. + let mut ticks: u32 = 0; loop { if cancel.get() { waiting.close(); @@ -100,6 +105,10 @@ pub fn wake_and_connect( } None => {} } + ticks += 1; + if ticks % 5 == 0 { + rescan.request(); + } glib::timeout_future(Duration::from_secs(1)).await; } }); diff --git a/clients/session/src/console.rs b/clients/session/src/console.rs index a424d70a..a2a7bdfc 100644 --- a/clients/session/src/console.rs +++ b/clients/session/src/console.rs @@ -343,6 +343,7 @@ impl Service { probe_inflight: Arc::new(AtomicBool::new(false)), last_probe: Instant::now() - Duration::from_secs(60), wake_cancel: None, + rescan: None, } .run(stop_w) }) @@ -373,11 +374,14 @@ struct ServiceState { last_probe: Instant, /// Cancels the active wake thread (it owns the model's wake status). wake_cancel: Option>, + /// Forces the mDNS browse to re-query. Installed by `run`; `None` before it starts. + rescan: Option, } impl ServiceState { fn run(mut self, stop: Arc) { - let discovery_rx = discovery::browse(); + let (discovery_rx, rescan) = discovery::browse(); + self.rescan = Some(rescan); while !stop.load(Ordering::SeqCst) { // mDNS churn. while let Ok(ev) = discovery_rx.try_recv() { @@ -512,6 +516,14 @@ impl ServiceState { } ConsoleCmd::Probe => { self.last_probe = Instant::now() - Duration::from_secs(60); + // "Refresh presence" means the mDNS half too, not just the QUIC sweep: the browse + // runs for the process's lifetime and `mdns-sd` backs its re-query interval off to + // as much as an hour, so a host that appeared since startup may never be asked + // for again. (No console screen emits Probe yet — every face button on the home + // screen is spoken for — but the plumbing is correct for when one does.) + if let Some(r) = &self.rescan { + r.request(); + } } ConsoleCmd::SetPin { key, diff --git a/clients/windows/src/app/connect.rs b/clients/windows/src/app/connect.rs index 7e5a4fde..af2ba382 100644 --- a/clients/windows/src/app/connect.rs +++ b/clients/windows/src/app/connect.rs @@ -490,9 +490,13 @@ fn wake_and_connect( let (ctx, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone()); std::thread::spawn(move || { - let rx = crate::discovery::browse(); + let (rx, rescan) = crate::discovery::browse(); let mut seen: Vec = Vec::new(); let mut wait = WakeWait::new(); + // A waking host starts advertising at a moment we can't predict, and `mdns-sd`'s own + // re-query interval has doubled well past a minute by the time a boot finishes — so ask + // again periodically instead of waiting to be told (matches the GTK client's wake wait). + let mut ticks: u32 = 0; loop { // Cancel already returned the UI to the host list — stop re-sending and tear down. if cancel.load(Ordering::SeqCst) { @@ -555,6 +559,10 @@ fn wake_and_connect( } None => {} } + ticks += 1; + if ticks % 5 == 0 { + rescan.request(); + } std::thread::sleep(Duration::from_secs(1)); } }); diff --git a/clients/windows/src/app/hosts.rs b/clients/windows/src/app/hosts.rs index a27245f4..eb56ba73 100644 --- a/clients/windows/src/app/hosts.rs +++ b/clients/windows/src/app/hosts.rs @@ -595,6 +595,22 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { move || sa.call(true) }) .into()]; + // Re-query mDNS. The browse runs for the app's lifetime, and `mdns-sd` backs its + // re-query interval off to as much as an hour — so a host that appeared since + // startup, or whose announcement was lost to multicast, may need an actual ask. + actions.push( + icon_btn("Scan the network for hosts again", Symbol::Refresh) + .on_click({ + let (c, st) = (ctx.clone(), set_status.clone()); + move || { + if let Some(r) = c.shared.rescan.lock().unwrap().as_ref() { + r.request(); + } + st.call("Scanning the network\u{2026}".to_string()); + } + }) + .into(), + ); // The couch UI's front door, beside the other page actions. Absent on ARM64, // where the session binary ships without its Skia console. if CONSOLE_UI_AVAILABLE { diff --git a/clients/windows/src/app/mod.rs b/clients/windows/src/app/mod.rs index df1826ef..fbf74786 100644 --- a/clients/windows/src/app/mod.rs +++ b/clients/windows/src/app/mod.rs @@ -147,6 +147,10 @@ impl PartialEq for Svc { #[derive(Default)] pub(crate) struct Shared { pub(crate) target: Mutex, + /// Forces the app's single LAN browse to re-query — the hosts page's Refresh. Installed by + /// the discovery effect below; `None` until then (and if the browse never started, in which + /// case Refresh is simply inert rather than a second, competing browse). + pub(crate) rescan: Mutex>, /// The live session child (spawn mode) — the status page's Disconnect and the /// request-access Cancel kill it. A FRESH handle is installed per spawn. pub(crate) session: Mutex, @@ -459,8 +463,10 @@ fn root(cx: &mut RenderCx, ctx: &Arc) -> Element { cx.use_effect((), { let set_hosts = set_hosts.clone(); + let ctx = ctx.clone(); move || { - let rx = discovery::browse(); + let (rx, rescan) = discovery::browse(); + *ctx.shared.rescan.lock().unwrap() = Some(rescan); std::thread::spawn(move || { let mut acc: Vec = Vec::new(); while let Ok(h) = rx.recv_blocking() { diff --git a/clients/windows/src/discovery.rs b/clients/windows/src/discovery.rs index c10ab505..afce6dd5 100644 --- a/clients/windows/src/discovery.rs +++ b/clients/windows/src/discovery.rs @@ -3,6 +3,12 @@ //! results to the UI. Ported verbatim from the GTK client (`mdns-sd` is cross-platform). use mdns_sd::{ServiceDaemon, ServiceEvent}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +/// DNS-SD service type punktfunk hosts advertise (host side: `punktfunk_host::discovery`). +const SERVICE_TYPE: &str = "_punktfunk._udp.local."; #[derive(Clone, Debug, PartialEq)] pub struct DiscoveredHost { @@ -24,10 +30,25 @@ pub struct DiscoveredHost { pub os: String, } -/// Browse continuously for the app's lifetime. The thread exits when the receiver is -/// dropped (the send fails) or the daemon dies. -pub fn browse() -> async_channel::Receiver { +/// Forces the running browse to re-query now — the hosts page's Refresh. Mirrors +/// `pf_client_core::discovery::Rescan`; see there for why a client needs one (`mdns-sd` re-queries +/// on a backoff that doubles out to an hour, so a long-lived browse is effectively passive). +#[derive(Clone, Debug)] +pub struct Rescan(Arc); + +impl Rescan { + /// Ask the browse thread to put a fresh query on the wire. Coalesces; returns immediately. + pub fn request(&self) { + self.0.store(true, Ordering::Relaxed); + } +} + +/// Browse continuously for the app's lifetime, with a handle that forces an immediate re-query. +/// The thread exits when the receiver is dropped (the send fails) or the daemon dies. +pub fn browse() -> (async_channel::Receiver, Rescan) { let (tx, rx) = async_channel::unbounded(); + let flag = Arc::new(AtomicBool::new(false)); + let requested = flag.clone(); std::thread::Builder::new() .name("punktfunk-mdns".into()) .spawn(move || { @@ -38,18 +59,45 @@ pub fn browse() -> async_channel::Receiver { return; } }; - let receiver = match daemon.browse("_punktfunk._udp.local.") { + let mut receiver = match daemon.browse(SERVICE_TYPE) { Ok(r) => r, Err(e) => { tracing::warn!(error = %e, "mDNS browse failed — discovery disabled"); return; } }; - while let Ok(event) = receiver.recv() { + loop { + // The worker has to notice that its consumer went away even when NOTHING is + // arriving — the normal state of a LAN with no hosts on it. The old blocking + // `recv()` only ever learned that from a failed send, so a bounded consumer (the + // wake-and-wait below spawns one browse per wake) left this thread and its daemon + // — another thread, and a socket bound to :5353 — running for the app's lifetime. + // Checked at the TOP so the `continue` arms below can't skip it either. + if tx.is_closed() { + break; + } + // Re-browsing the same type replaces the daemon's listener: it replays the cache + // into the new channel, queries immediately, and resets the backoff. + if requested.swap(false, Ordering::Relaxed) { + match daemon.browse(SERVICE_TYPE) { + Ok(r) => receiver = r, + Err(e) => tracing::warn!(error = %e, "mDNS rescan failed"), + } + } + let event = match receiver.recv_timeout(Duration::from_millis(250)) { + Ok(event) => event, + Err(_) if receiver.is_disconnected() && receiver.is_empty() => break, + Err(_) => continue, // timed out — go round and look for a rescan request + }; if let ServiceEvent::ServiceResolved(info) = event { let props = info.get_properties(); let val = |k: &str| props.get_property_val_str(k).unwrap_or("").to_string(); - let Some(addr) = info.get_addresses().iter().next().map(|a| a.to_string()) + // IPv4 only, like every other client (`pf_client_core::discovery`): the core + // dials `format!("{host}:{port}").parse::()`, which cannot parse a + // bare IPv6 literal, and the host stack binds IPv4 sockets exclusively. Taking + // an arbitrary first address here rendered cards that failed on every click, + // because a host's OS responder commonly answers AAAA for its hostname. + let Some(addr) = info.get_addresses_v4().iter().next().map(|a| a.to_string()) else { continue; }; @@ -85,5 +133,5 @@ pub fn browse() -> async_channel::Receiver { let _ = daemon.shutdown(); }) .expect("spawn mdns thread"); - rx + (rx, Rescan(flag)) } diff --git a/clients/windows/src/main.rs b/clients/windows/src/main.rs index 80644027..a8624466 100644 --- a/clients/windows/src/main.rs +++ b/clients/windows/src/main.rs @@ -245,7 +245,7 @@ fn run_headless_cli(args: &[String], identity: (String, String)) { fn discover_and_print() { use std::time::{Duration, Instant}; println!("Browsing the LAN for punktfunk hosts (~5 s)…"); - let rx = discovery::browse(); + let (rx, _rescan) = discovery::browse(); let deadline = Instant::now() + Duration::from_secs(5); let mut seen = std::collections::HashSet::new(); while Instant::now() < deadline { diff --git a/crates/pf-client-core/src/discovery.rs b/crates/pf-client-core/src/discovery.rs index 7df42411..4432a6cb 100644 --- a/crates/pf-client-core/src/discovery.rs +++ b/crates/pf-client-core/src/discovery.rs @@ -5,8 +5,13 @@ use mdns_sd::{ServiceDaemon, ServiceEvent}; use std::collections::BTreeMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; use std::time::{Duration, Instant}; +/// DNS-SD service type punktfunk hosts advertise (host side: `punktfunk_host::discovery`). +const SERVICE_TYPE: &str = "_punktfunk._udp.local."; + #[derive(Clone, Debug)] pub struct DiscoveredHost { /// Stable row key: the advertised host id, falling back to the mDNS fullname. @@ -54,10 +59,32 @@ pub enum DiscoveryEvent { Removed { fullname: String }, } -/// Browse continuously. The worker exits when the returned receiver is dropped, or when the -/// daemon dies — checked on a tick, so it stops even on a LAN where no advert ever arrives. -pub fn browse() -> async_channel::Receiver { +/// Forces the running browse to re-query now. Cheap to clone and hand to a UI thread; a request +/// made after the browse has ended is simply never read. +/// +/// Why a client needs one at all: `mdns-sd` re-queries on a DOUBLING backoff (1s, 2s, 4s … capped +/// at one hour), so a browse that has been up a while is effectively passive — it is listening for +/// announcements rather than asking. A host that starts advertising later, or whose announcement +/// was dropped (ordinary for multicast over Wi-Fi), can stay invisible for a very long time. +/// Re-querying resets that clock, which is what a Refresh button should do. +#[derive(Clone, Debug)] +pub struct Rescan(Arc); + +impl Rescan { + /// Ask the browse thread to put a fresh query on the wire. Returns immediately; the query + /// follows within a tick. Coalesces — several requests in a row cost one query. + pub fn request(&self) { + self.0.store(true, Ordering::Relaxed); + } +} + +/// Browse continuously, with a handle that forces an immediate re-query ([`Rescan`]). The worker +/// exits when the returned receiver is dropped, or when the daemon dies — checked on a tick, so +/// it stops even on a LAN where no advert ever arrives. +pub fn browse() -> (async_channel::Receiver, Rescan) { let (tx, rx) = async_channel::unbounded(); + let flag = Arc::new(AtomicBool::new(false)); + let requested = flag.clone(); std::thread::Builder::new() .name("punktfunk-mdns".into()) .spawn(move || { @@ -68,7 +95,7 @@ pub fn browse() -> async_channel::Receiver { return; } }; - let receiver = match daemon.browse("_punktfunk._udp.local.") { + let mut receiver = match daemon.browse(SERVICE_TYPE) { Ok(r) => r, Err(e) => { tracing::warn!(error = %e, "mDNS browse failed — discovery disabled"); @@ -88,6 +115,17 @@ pub fn browse() -> async_channel::Receiver { if tx.is_closed() { break; } + // Also at the TOP, and for the same reason: every `continue` below would skip it. + if requested.swap(false, Ordering::Relaxed) { + // Browsing the same type again REPLACES the daemon's listener for it: it + // replays the cache into the new channel (so nothing already known is lost), + // puts a fresh PTR query on the wire immediately, and — the point — resets the + // re-query backoff described on `Rescan`. + match daemon.browse(SERVICE_TYPE) { + Ok(r) => receiver = r, + Err(e) => tracing::warn!(error = %e, "mDNS rescan failed"), + } + } let event = match receiver.recv_timeout(Duration::from_millis(250)) { Ok(event) => event, Err(_) if receiver.is_disconnected() => break, @@ -147,7 +185,7 @@ pub fn browse() -> async_channel::Receiver { let _ = daemon.shutdown(); }) .expect("spawn mdns thread"); - rx + (rx, Rescan(flag)) } /// The advert map one browse window folded down to. Kept separate from [`discover_for`] so the @@ -174,7 +212,7 @@ fn fold(adverts: &mut Adverts, event: DiscoveryEvent) { /// wants one bounded call rather than a stream). The streaming [`browse`] stays the UI's door: /// a live hosts page wants adverts as they land, not a snapshot taken `timeout` after it opened. pub fn discover_for(timeout: Duration) -> Vec { - let rx = browse(); + let (rx, _rescan) = browse(); let deadline = Instant::now() + timeout; let mut adverts = Adverts::new(); while Instant::now() < deadline { From 78ad6755075acb16eacd3c7acf8f14afe7faa622 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 6 Aug 2026 13:44:01 +0200 Subject: [PATCH 12/18] feat(clients): a safe-area resolution that keeps the picture out of the notch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picking the device's native mode on a phone hands the host the panel's own aspect ratio, so the aspect-fit presenter fills every pixel — including the ones behind the sensor housing and under the four rounded corners. That is why the corners look cut off at max resolution while 1080p has always been fine: a 16:9 mode on a 20:9 phone pillarboxes, and those black bars land exactly on the unsafe regions. So the fix is entirely a sizing one — no layout change, no input change. Ask the host for a mode narrowed by the unsafe inset and the existing aspect-fit centres it inside the safe region; pointer mapping follows for free, because both clients derive the picture rect from the live host mode rather than assuming full-bleed. Apple: `SafeDisplay` (PunktfunkShared, pure + unit-tested) and a "This device (safe area)" row beside the native one, using Moonlight's formula — full native height, width less the left+right safe insets. The stream is always landscape but the settings screen may be portrait, where the same housing is reported on `top` and the horizontal insets read zero; the portrait top inset stands in, gated so an iPad's status bar never fabricates an inset. Android: the same shape via `SafeArea` + a `SAFE_AREA_MODE` sentinel resolved at connect like the existing `0`=native one. The cutout insets get the same portrait fallback, and the rounded corners are added on top — Android does not count them as cutout, and a full-height picture needs exactly the corner radius of horizontal clearance. Both even-floor and clamp, since `validate_dimensions` rejects odd dimensions and an inset subtraction lands odd about half the time. Where a display has neither cutout nor rounded corners the safe mode equals the native one, which on Apple lets the existing dedup drop the duplicate row. --- .../main/kotlin/io/unom/punktfunk/Settings.kt | 112 +++++++++++++++++- .../io/unom/punktfunk/SettingsScreen.kt | 17 ++- .../kotlin/io/unom/punktfunk/SafeAreaTest.kt | 48 ++++++++ .../Settings/SettingsOptions.swift | 38 +++++- .../Sources/PunktfunkShared/SafeDisplay.swift | 86 ++++++++++++++ .../PunktfunkKitTests/SafeDisplayTests.swift | 74 ++++++++++++ 6 files changed, 364 insertions(+), 11 deletions(-) create mode 100644 clients/android/app/src/test/kotlin/io/unom/punktfunk/SafeAreaTest.kt create mode 100644 clients/apple/Sources/PunktfunkShared/SafeDisplay.swift create mode 100644 clients/apple/Tests/PunktfunkKitTests/SafeDisplayTests.swift diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt index 83bf4f61..ea6328d4 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt @@ -437,6 +437,96 @@ fun nativeDisplayMode(context: Context): Triple { return Triple(maxOf(w, h), minOf(w, h), hz) } +/** + * Sentinel [Settings.width]/[Settings.height] meaning "the native mode, narrowed so the picture + * clears the display cutout and the rounded corners" — resolved at connect by [safeDisplayMode], + * exactly as `0` is resolved by [nativeDisplayMode]. Negative, so it can never collide with a real + * size; distinct from the UI's `-1` "Custom…" sentinel. + */ +const val SAFE_AREA_MODE = -2 + +/** + * Safe-area stream geometry — the pure part, so it is unit-testable without a Display. + * + * The phone clips the picture in HARDWARE: the cutout (notch / punch-hole) and the four rounded + * corners eat whatever the stream draws under them. [StreamScreen] deliberately draws edge-to-edge + * (`LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS`) and centres the video at its own aspect ratio + * (`Modifier.aspectRatio`), so which pixels survive is decided purely by the mode's aspect: + * + * * A 16:9 mode on a 20:9 phone pillarboxes, and those black bars land exactly on the unsafe + * regions — which is why the presets have always "just worked". + * * The NATIVE mode has the panel's own aspect, so it fills every pixel, cutout and corners + * included. That is the mode that loses its corners. + * + * So asking the host for a mode narrower by the unsafe inset is the entire fix: the existing + * aspect-fit centres it inside the safe region, and pointer mapping follows for free (MouseInput + * derives the picture rect from the live video size, not from the window). + */ +object SafeArea { + /** The host rejects odd dimensions and anything under 320 px wide (`validate_dimensions`). */ + const val MIN_WIDTH = 320 + + /** + * [nativeWidth] reduced by [perSideInsetPx] on each side, even-floored and clamped to the + * host's floor. Height is deliberately untouched: under aspect-fit only one axis can bind, and + * on a landscape phone that axis is always the horizontal one — insetting height as well would + * shrink the picture without uncovering anything. + */ + fun insetWidth(nativeWidth: Int, perSideInsetPx: Int): Int { + val inset = perSideInsetPx.coerceAtLeast(0) + return (nativeWidth - inset * 2).coerceAtLeast(MIN_WIDTH) / 2 * 2 + } +} + +/** + * The per-side inset, in pixels, that the **landscape** stream must clear on this display. + * + * Two contributions, and the larger wins: + * * **The cutout.** [DisplayCutout] is rotation-aware, so in landscape the housing shows up on + * `left`/`right`. The settings screen may be portrait though, where the very same housing is + * reported on `top`/`bottom` and the horizontal insets read zero — which would compute "no inset + * needed" for exactly the devices that need one. The stream is always landscape, so a vertical + * inset now becomes a horizontal one then: fall back to it. + * * **The rounded corners.** These are NOT part of the cutout insets. For a FULL-HEIGHT picture the + * horizontal clearance a corner of radius `r` needs is exactly `r`: at the topmost row the + * display boundary sits at `x = r`, so anything left of that is clipped. Not conservative — it is + * the precise requirement for a picture that spans the full height. + * + * `0` when the display has neither, which makes the safe mode identical to the native one. + */ +private fun displaySideInsetPx(context: Context): Int { + val display = probeDisplay(context) ?: return 0 + var inset = 0 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + display.cutout?.let { cut -> + val horizontal = maxOf(cut.safeInsetLeft, cut.safeInsetRight) + val vertical = maxOf(cut.safeInsetTop, cut.safeInsetBottom) + inset = maxOf(inset, if (horizontal > 0) horizontal else vertical) + } + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + for (position in intArrayOf( + android.view.RoundedCorner.POSITION_TOP_LEFT, + android.view.RoundedCorner.POSITION_TOP_RIGHT, + android.view.RoundedCorner.POSITION_BOTTOM_LEFT, + android.view.RoundedCorner.POSITION_BOTTOM_RIGHT, + )) { + display.getRoundedCorner(position)?.let { inset = maxOf(inset, it.radius) } + } + } + return inset +} + +/** + * The native mode narrowed to clear the cutout and the rounded corners — the [SAFE_AREA_MODE] + * resolution, as a landscape `(width, height, hz)`. Same height and refresh as [nativeDisplayMode]; + * only the width moves. + */ +fun safeDisplayMode(context: Context): Triple { + val (w, h, hz) = nativeDisplayMode(context) + return Triple(SafeArea.insetWidth(w, displaySideInsetPx(context)), h, hz) +} + /** * True when this device's display can actually present HDR10, so we should advertise HDR to the * host. On an SDR panel we advertise `0` instead — the host then sends a proper 8-bit BT.709 stream @@ -471,12 +561,21 @@ fun displaySupportsHdr(context: Context): Boolean { return supported } -/** Resolve [Settings] (with its 0=native placeholders) to the concrete mode to request. */ +/** + * Resolve [Settings] (with its `0`=native and [SAFE_AREA_MODE] placeholders) to the concrete mode to + * request. The safe-area sentinel is checked first because it resolves BOTH axes together — it is one + * mode, not an independent width and height, and mixing half of it with a native height would ask + * for a size neither sentinel means. + */ fun Settings.effectiveMode(context: Context): Triple { - val native = nativeDisplayMode(context) - val w = if (width > 0) width else native.first - val h = if (height > 0) height else native.second - val hz = if (hz > 0) hz else native.third + val base = if (width == SAFE_AREA_MODE && height == SAFE_AREA_MODE) { + safeDisplayMode(context) + } else { + nativeDisplayMode(context) + } + val w = if (width > 0) width else base.first + val h = if (height > 0) height else base.second + val hz = if (hz > 0) hz else base.third return Triple(w, h, hz) } @@ -530,9 +629,10 @@ val RENDER_SCALE_OPTIONS = RenderScale.PRESETS.map { it to RenderScale.label(it) // ---- UI option tables (value, label). The first entry is always the "auto/native" default. ---- -/** (width, height, label). `(0,0)` = native display. */ +/** (width, height, label). `(0,0)` = native display; [SAFE_AREA_MODE] = native minus the cutout. */ val RESOLUTION_OPTIONS = listOf( Triple(0, 0, "Native display"), + Triple(SAFE_AREA_MODE, SAFE_AREA_MODE, "Native display (safe area)"), Triple(1280, 720, "1280 × 720"), Triple(1920, 1080, "1920 × 1080"), Triple(2560, 1440, "2560 × 1440"), diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt index 8b902099..b150cca8 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt @@ -603,6 +603,10 @@ private fun GeneralSettings(s: Settings, update: (Settings) -> Unit) { @Composable private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: android.content.Context) { val (nw, nh, nhz) = nativeDisplayMode(context) + // The safe-area row carries its resolved size the same way the native row does. On a display with + // no cutout and square corners this equals the native mode — the row stays, honestly showing that + // it changes nothing here, rather than silently vanishing on some devices and not others. + val (sw, sh, _) = safeDisplayMode(context) // "Custom…" picked while the stored size is still a preset — keeps the size fields visible // until an edit actually makes it custom (or a preset is re-picked). Custom itself is detected // from the stored size, never flagged (see [isCustomResolution]), so nothing new persists. @@ -611,7 +615,13 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an SettingsGroup("Resolution") { SettingDropdown( label = "Resolution", - options = RESOLUTION_OPTIONS.map { (w, h, lbl) -> (w to h) to (if (w == 0) "$lbl ($nw × $nh)" else lbl) } + + options = RESOLUTION_OPTIONS.map { (w, h, lbl) -> + (w to h) to when (w) { + 0 -> "$lbl ($nw × $nh)" + SAFE_AREA_MODE -> "$lbl ($sw × $sh)" + else -> lbl + } + } + // The (-1, -1) sentinel can't collide with a real size; once a custom size is // stored its label carries the live value, like the native row carries ($nw × $nh). ((-1 to -1) to if (s.isCustomResolution()) "Custom (${s.width} × ${s.height})" else "Custom…"), @@ -620,7 +630,10 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an caption = "The host makes a display exactly this size — no scaling. Native follows " + "this device's panel.", ) { (w, h) -> - if (w < 0) { + // ONLY -1 is "Custom…". The other negative value is the safe-area sentinel, which is a + // stored mode like any preset — a blanket `w < 0` here would open the custom fields for it + // and overwrite it with a concrete size. + if (w == -1) { // Seed from the current *effective* size so the fields start from something // sensible (the resolved native mode, not the 0 × 0 placeholder). customPicked = true diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/SafeAreaTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/SafeAreaTest.kt new file mode 100644 index 00000000..0c7075b5 --- /dev/null +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/SafeAreaTest.kt @@ -0,0 +1,48 @@ +package io.unom.punktfunk + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pure JVM test of the safe-area stream geometry ([SafeArea]) and the sentinel that selects it — + * the width-only inset that keeps the picture clear of the cutout and the rounded corners. + * Run: `./gradlew :app:testDebugUnitTest`. + */ +class SafeAreaTest { + @Test + fun insetsBothSidesAndStaysHostValid() { + // A punch-hole phone: 2400 px wide, 96 px of unsafe edge per side → 2208. + assertEquals(2400 - 96 * 2, SafeArea.insetWidth(2400, 96)) + // Odd results even-floor — the host rejects odd dimensions outright, and an inset + // subtraction lands odd about half the time. + assertEquals(0, SafeArea.insetWidth(2401, 95) % 2) + // No cutout and square corners → the native width, unchanged. + assertEquals(2400, SafeArea.insetWidth(2400, 0)) + } + + @Test + fun absurdInsetsCannotDriveTheModeUnderTheHostFloor() { + assertEquals(SafeArea.MIN_WIDTH, SafeArea.insetWidth(1280, 5000)) + // A negative reading is treated as no inset rather than widening past the panel. + assertEquals(1280, SafeArea.insetWidth(1280, -40)) + } + + @Test + fun safeModeIsNarrowerThanNativeWheneverThereIsAnInset() { + val native = 2556 + assertTrue(SafeArea.insetWidth(native, 60) < native) + } + + @Test + fun theSentinelIsAPresetAndNeverReadsAsCustom() { + // The safe-area mode is a stored preset, not a typed size: `isCustomResolution` must be + // false for it, or the touch settings would open the custom width/height fields on it and + // the gamepad screen would prepend a bogus "Custom · -2 × -2" row. + val s = Settings(width = SAFE_AREA_MODE, height = SAFE_AREA_MODE) + assertTrue(!s.isCustomResolution()) + // And it must be distinct from the UI's own "Custom…" sentinel (-1). + assertTrue(SAFE_AREA_MODE != -1) + assertTrue(RESOLUTION_OPTIONS.any { it.first == SAFE_AREA_MODE && it.second == SAFE_AREA_MODE }) + } +} diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift index fa23a22c..04fcef9c 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift @@ -171,14 +171,26 @@ enum SettingsOptions { /// This device's native mode first, then the presets, deduped by dimensions (native wins a /// tie). + /// + /// On iOS the native row is followed by its **safe-area** variant, which is the same mode + /// narrowed so the picture clears the sensor housing and the rounded corners — see + /// [`SafeDisplay`] for why a narrower mode is the whole fix. It is emitted unconditionally and + /// left to the dedup below: on a device with no housing the two modes are identical, the + /// duplicate is dropped, and no pointless row appears. @MainActor static func resolutionModes() -> [(name: String, w: Int, h: Int)] { var native: [(name: String, w: Int, h: Int)] = [] #if os(iOS) || os(tvOS) let bounds = UIScreen.main.nativeBounds // portrait-oriented pixels (tvOS: the TV mode) - native = [("This device", - Int(max(bounds.width, bounds.height)), - Int(min(bounds.width, bounds.height)))] + let nativeW = Int(max(bounds.width, bounds.height)) + let nativeH = Int(min(bounds.width, bounds.height)) + native = [("This device", nativeW, nativeH)] + #if os(iOS) + let safe = SafeDisplay.mode( + nativeWidth: nativeW, nativeHeight: nativeH, + sideInsetPoints: mainWindowSideInset(), scale: UIScreen.main.nativeScale) + native.append(("This device (safe area)", safe.width, safe.height)) + #endif #else if let screen = NSScreen.main { let scale = screen.backingScaleFactor @@ -191,6 +203,26 @@ enum SettingsOptions { return (native + resolutionPresets).filter { seen.insert("\($0.w)x\($0.h)").inserted } } + #if os(iOS) + /// The key window's per-side safe-area inset in points, resolved for the LANDSCAPE stream even + /// when this settings screen is currently portrait (see `SafeDisplay.sideInsetPoints`). + /// + /// Zero when no window is up yet — the safe mode then equals the native one and `resolutionModes` + /// dedups the row away, which is the right answer for a device we can't measure. + @MainActor + private static func mainWindowSideInset() -> Double { + let insets = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap(\.windows) + .first { $0.isKeyWindow }? + .safeAreaInsets + guard let insets else { return 0 } + return SafeDisplay.sideInsetPoints( + left: Double(insets.left), right: Double(insets.right), top: Double(insets.top), + isPhone: UIDevice.current.userInterfaceIdiom == .phone) + } + #endif + /// Refresh rates the device can actually display (no point asking the host to render frames /// the screen can't show), plus any stored custom value so it stays selectable. @MainActor diff --git a/clients/apple/Sources/PunktfunkShared/SafeDisplay.swift b/clients/apple/Sources/PunktfunkShared/SafeDisplay.swift new file mode 100644 index 00000000..e0baef82 --- /dev/null +++ b/clients/apple/Sources/PunktfunkShared/SafeDisplay.swift @@ -0,0 +1,86 @@ +// Safe-area stream sizing — the pure geometry behind the "safe area" resolution row. +// +// An iPhone clips the picture in HARDWARE: the sensor housing (notch / Dynamic Island) and the four +// rounded corners eat whatever the stream draws underneath them. The session view is deliberately +// edge-to-edge (ContentView's `.ignoresSafeArea()` on iOS) and the presenter aspect-FITS the host +// mode into it, so which pixels survive is decided entirely by the mode's aspect ratio: +// +// * A 16:9 mode on a 19.5:9 phone pillarboxes, and those black bars land exactly on the unsafe +// regions. That is why 1080p has always "just worked" and never needed a setting. +// * The device's NATIVE mode has the screen's own aspect ratio, so it fills every pixel — +// including the ones behind the housing and under the corner radii. That is the mode that +// loses its corners, and the reason this file exists. +// +// So the fix needs no layout change and no input change: ask the host for a mode that is narrower +// by the safe-area insets, and the existing aspect-fit centres it inside the safe region. Pointer +// input keeps mapping correctly for free, because `hostPoint(from:)` derives the video rect from +// the live host mode (`AVMakeRect(aspectRatio:insideRect:)`) instead of assuming full-bleed. +// +// The formula is Moonlight's (its settings' resolution table carries the same row): full native +// height, width reduced by the left+right safe-area insets. Width-only is not a simplification — +// under aspect-fit only one axis can bind, and on a landscape phone that axis is always the +// horizontal one. Insetting the height too would shrink the picture without uncovering anything. + +import Foundation + +public enum SafeDisplay { + /// The host rejects odd dimensions and anything under 320×200 (`validate_dimensions` in + /// `pf-encode`), so the computed mode is even-floored and clamped exactly like `RenderScale`. + public static let minWidth = 320 + public static let minHeight = 200 + + /// A portrait top inset at or above this many points means a sensor housing rather than a + /// status bar. Notched and Dynamic Island iPhones report 44–59 pt; a plain status bar (older + /// iPhones, every iPad) reports 20–24 pt. Used only by [`sideInsetPoints`] and only when the + /// horizontal insets are unavailable — see there for why that case exists at all. + public static let housingTopInsetThreshold: Double = 40 + + /// The per-side inset, in points, that the **landscape** stream will be subject to — which is + /// not necessarily the inset the caller can read right now. + /// + /// The stream is always landscape, but the settings screen the resolution row is rendered in may + /// be portrait, and `safeAreaInsets` only ever describes the CURRENT orientation. In portrait a + /// notched iPhone reports its housing on `top` and reports `left`/`right` as zero, so reading + /// the horizontal insets there would compute "no inset needed" for exactly the devices that + /// need one. + /// + /// - In landscape, `max(left, right)` is the answer directly. (iOS symmetrizes the two so + /// content stays centred, so they normally agree; `max` is simply the safe reduction.) + /// - In portrait, the housing's portrait TOP inset equals its landscape SIDE inset on every + /// notched/Dynamic Island iPhone — the same physical intrusion, measured on the axis that + /// happens to be vertical at the time — so `top` is the correct stand-in. It is accepted only + /// on phones and only past [`housingTopInsetThreshold`], so an iPad's status bar (or an older + /// iPhone's) never fabricates an inset for a device with nothing to avoid. + /// + /// Returns 0 when there is no housing to route around, which makes the safe mode identical to + /// the native one — and the caller's dedup then drops the duplicate row on its own. + public static func sideInsetPoints( + left: Double, right: Double, top: Double, isPhone: Bool + ) -> Double { + let horizontal = max(left, right) + if horizontal > 0 { return horizontal } + if isPhone, top >= housingTopInsetThreshold { return top } + return 0 + } + + /// The landscape safe-area mode in PIXELS: full native height, width reduced by + /// `sideInsetPoints` on each side. + /// + /// `nativeWidth`/`nativeHeight` are the device's native landscape pixels (the long edge first — + /// `UIScreen.main.nativeBounds` is portrait-oriented, so the caller swaps). `scale` converts the + /// point-valued insets into those same pixels and must therefore be `nativeScale`, not `scale`: + /// with Display Zoom on, the two differ and only the former matches `nativeBounds`. + /// + /// Even-floored and clamped so the result is directly host-valid — an odd width is rejected + /// outright by the encoder, and an inset subtraction lands odd about half the time. + public static func mode( + nativeWidth: Int, nativeHeight: Int, sideInsetPoints: Double, scale: Double + ) -> (width: Int, height: Int) { + let insetPixels = max(0, sideInsetPoints) * max(scale, 1) * 2 // both sides + let width = Double(nativeWidth) - insetPixels + let evenFloor: (Double, Int) -> Int = { value, minimum in + max(Int(value.rounded(.down)), minimum) / 2 * 2 + } + return (evenFloor(width, minWidth), evenFloor(Double(nativeHeight), minHeight)) + } +} diff --git a/clients/apple/Tests/PunktfunkKitTests/SafeDisplayTests.swift b/clients/apple/Tests/PunktfunkKitTests/SafeDisplayTests.swift new file mode 100644 index 00000000..74f67c8f --- /dev/null +++ b/clients/apple/Tests/PunktfunkKitTests/SafeDisplayTests.swift @@ -0,0 +1,74 @@ +// The safe-area stream mode (SafeDisplay), as pure geometry: Moonlight's formula — full native +// height, width reduced by the left+right safe insets — plus the host's dimension rules (even, and +// never under 320×200) and the landscape-inset resolution that makes the row correct even when the +// settings screen it is rendered on is currently portrait. + +import XCTest + +import PunktfunkShared +@testable import PunktfunkKit + +final class SafeDisplayTests: XCTestCase { + func testLandscapeUsesTheHorizontalInsets() { + // Landscape: the housing is on a side and iOS symmetrizes the two, so either one is the + // per-side inset. + XCTAssertEqual( + SafeDisplay.sideInsetPoints(left: 59, right: 59, top: 0, isPhone: true), 59) + // Asymmetric (or mid-rotation) readings reduce to the larger — never under-inset. + XCTAssertEqual( + SafeDisplay.sideInsetPoints(left: 0, right: 44, top: 0, isPhone: true), 44) + } + + func testPortraitFallsBackToTheHousingTopInset() { + // Portrait on a notched phone: left/right are zero and the housing sits on `top`. Reading + // the horizontal insets here would compute "no inset" for exactly the devices that need one, + // so the portrait top inset stands in — it is the same physical intrusion. + XCTAssertEqual( + SafeDisplay.sideInsetPoints(left: 0, right: 0, top: 59, isPhone: true), 59) + // A plain status bar is not a housing: an iPad (or a pre-notch iPhone) must not fabricate an + // inset for a device with nothing to route around. + XCTAssertEqual( + SafeDisplay.sideInsetPoints(left: 0, right: 0, top: 24, isPhone: true), 0) + XCTAssertEqual( + SafeDisplay.sideInsetPoints(left: 0, right: 0, top: 59, isPhone: false), 0) + } + + func testModeInsetsWidthOnlyAndKeepsFullHeight() { + // A Dynamic Island phone: 2556×1179 native, 59 pt per side at nativeScale 3 → 177 px per + // side, 354 px total. Height is untouched — under aspect-fit only the horizontal axis binds. + let m = SafeDisplay.mode( + nativeWidth: 2556, nativeHeight: 1179, sideInsetPoints: 59, scale: 3) + XCTAssertEqual(m.width, 2202, "2556 − 2×177") + XCTAssertEqual(m.height, 1178, "odd native heights even-floor") + // The safe mode must be NARROWER than native, or it would still fill the housing. + XCTAssertLessThan(m.width, 2556) + } + + func testNoHousingYieldsTheNativeModeSoTheRowDedups() { + // Zero inset ⇒ identical to native (bar the even-floor). `resolutionModes` dedups by + // dimensions, so this is what makes the extra row vanish on a device that has no housing + // rather than showing a pointless duplicate. + let m = SafeDisplay.mode( + nativeWidth: 2360, nativeHeight: 1640, sideInsetPoints: 0, scale: 2) + XCTAssertEqual(m.width, 2360) + XCTAssertEqual(m.height, 1640) + } + + func testResultIsAlwaysHostValid() { + // Odd widths even-floor: `validate_dimensions` rejects odd outright, and an inset + // subtraction lands odd about half the time. + let odd = SafeDisplay.mode( + nativeWidth: 2001, nativeHeight: 1001, sideInsetPoints: 0, scale: 1) + XCTAssertEqual(odd.width % 2, 0) + XCTAssertEqual(odd.height % 2, 0) + // An absurd inset can't drive the mode under the host's floor. + let tiny = SafeDisplay.mode( + nativeWidth: 1280, nativeHeight: 720, sideInsetPoints: 5000, scale: 3) + XCTAssertEqual(tiny.width, SafeDisplay.minWidth) + XCTAssertEqual(tiny.height, 720) + // A negative inset is treated as none rather than widening past the panel. + let neg = SafeDisplay.mode( + nativeWidth: 1280, nativeHeight: 720, sideInsetPoints: -40, scale: 3) + XCTAssertEqual(neg.width, 1280) + } +} From 76e8bd1b981f70c857220c5cec6f3d7f8e314bb8 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 6 Aug 2026 13:58:24 +0200 Subject: [PATCH 13/18] fix(host/gamelease): a game that exited stops counting as running MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a launched game's processes are all gone, the watcher asks one last out-of-band question before ending the session: does the launcher still think the game is up? On Windows that reads Steam's per-app `Running` registry flag. It was only ever meant to be a tie-breaker for a scan that momentarily can't see the game — a launcher re-execing, an engine relaunching itself into a new pid. It had no bound. Honouring the flag reset the confirm window every pass, so a flag Steam left set — it does that whenever it doesn't cleanly observe the exit: it crashed, it was closed first, the game re-parented — pinned the lease in `running` for the life of the host. The console kept showing the game, `session_on_game_exit` never fired, and the only way to get the stream back was a manual "End". Reported from the field on Windows 0.24.0. `steam_running_hint` also believes the FIRST hive that says so, so a stale flag in any loaded profile was enough. The absence timer now keeps running instead of being reset, and that is what bounds it: past `VETO_LIMIT` (30 s) with nothing of the game on the box, the launcher's opinion is stale rather than early and the session ends anyway, logged at WARN so it is visible. Ending a moment early is the cheaper failure — the stream drops while the game lives, the user reconnects, and nothing is ever killed. Ending never was the bug. The rule is now a pure `exit_confirmed(gone_for, hint_running)` with a test. The watch loop polls a live process table and can't be unit-tested, which is exactly how an unbounded veto shipped unnoticed. --- crates/punktfunk-host/src/gamelease.rs | 108 +++++++++++++++++++++---- 1 file changed, 92 insertions(+), 16 deletions(-) diff --git a/crates/punktfunk-host/src/gamelease.rs b/crates/punktfunk-host/src/gamelease.rs index 975198a8..ed1885ec 100644 --- a/crates/punktfunk-host/src/gamelease.rs +++ b/crates/punktfunk-host/src/gamelease.rs @@ -52,6 +52,24 @@ const EXIT_CONFIRM: Duration = Duration::from_secs(3); const SHIM_WINDOW: Duration = Duration::from_secs(5); /// How long a game gets to close on its own after a polite request, before it is killed outright. const TERM_GRACE: Duration = Duration::from_secs(10); +/// How long [`crate::procscan::running_hint`] may hold off the exit once the game's processes have +/// all gone. +/// +/// The hint is a tie-breaker for a scan that momentarily cannot see the game — a launcher re-execing, +/// an engine relaunching itself into a new pid — and those gaps are over in seconds, an order of +/// magnitude inside this window. Past it, a game nothing can find is gone whatever the hint says. +/// +/// **Bounded because the hint's backing state is not guaranteed to be truthful.** Windows reads +/// Steam's per-app `Running` registry flag, which Steam leaves set whenever it does not cleanly +/// observe the exit (Steam crashed or was closed first, the game re-parented, a launcher appid stays +/// set) — and `steam_running_hint` believes the first hive that says so, including a stale one left +/// in another profile. An UNBOUNDED veto turns that into a session that never ends on its own: the +/// console shows the game running for as long as the host does, `session_on_game_exit` never fires, +/// and only a manual "End" gets the stream back (field report 2026-08-06, Windows host 0.24.0). +/// +/// Ending a moment too early is the cheaper failure: the stream drops while the game lives (the user +/// reconnects, and `finish` never kills anything). Ending never is the bug above. +const VETO_LIMIT: Duration = Duration::from_secs(30); /// A child process the host spawned for a launch, and what may safely be signalled for it. #[derive(Clone, Copy, Debug)] @@ -540,29 +558,59 @@ fn watch(shared: Arc, mut child: Option, on_ex gone_since = None; vetoed = false; shared.last_seen_ms.store(now_ms(), Ordering::Relaxed); - } else if gone_since.get_or_insert_with(Instant::now).elapsed() >= EXIT_CONFIRM { - // Last check before ending a session: does anything outside the process scan still think - // the game is up? Only a veto, never a reason to call it running — see - // `procscan::running_hint`. The failure mode of honoring it is a stream that stays up. - if crate::procscan::running_hint(&shared.spec) == Some(true) { - if !vetoed { - vetoed = true; - tracing::info!( - title = %shared.game.title, - "no game processes found, but its launcher still reports it running — not \ - ending the session" - ); + } else { + // How long the game's processes have been CONTINUOUSLY absent. Deliberately not reset by + // the veto below — letting it run on is exactly what bounds the veto. + let gone_for = gone_since.get_or_insert_with(Instant::now).elapsed(); + if gone_for >= EXIT_CONFIRM { + // Last check before ending a session: does anything outside the process scan still + // think the game is up? Only a veto, never a reason to call it running — see + // `procscan::running_hint`. + let hint_running = crate::procscan::running_hint(&shared.spec) == Some(true); + if !exit_confirmed(gone_for, hint_running) { + if !vetoed { + vetoed = true; + tracing::info!( + title = %shared.game.title, + veto_limit_s = VETO_LIMIT.as_secs(), + "no game processes found, but its launcher still reports it running — \ + holding off on ending the session" + ); + } + } else { + if hint_running { + // The veto outlived its usefulness: nothing this scan can see has existed + // for VETO_LIMIT, so the launcher's opinion is stale, not early. + tracing::warn!( + title = %shared.game.title, + gone_for_s = gone_for.as_secs(), + "its launcher still reports the game running, but nothing of it has \ + been on the box for {}s — treating that as a stale flag and ending \ + the session", + VETO_LIMIT.as_secs() + ); + } + finish(&shared, &on_exit, "the game exited"); + return; } - gone_since = None; - } else { - finish(&shared, &on_exit, "the game exited"); - return; } } std::thread::sleep(POLL); } } +/// Whether a game nothing can find any more counts as exited: absent for at least [`EXIT_CONFIRM`], +/// and either unopposed or absent long enough that the opposition ([`crate::procscan::running_hint`] +/// saying `Some(true)`) has been overruled by [`VETO_LIMIT`]. +/// +/// Split out of the watch loop because it is the one rule in this file whose *bound* is the fix: +/// the loop itself polls a live process table and cannot be unit-tested, which is how an unbounded +/// veto shipped. Pure, so the table below is the whole contract. +#[cfg(any(target_os = "linux", windows))] +fn exit_confirmed(gone_for: Duration, hint_running: bool) -> bool { + gone_for >= EXIT_CONFIRM && (!hint_running || gone_for >= VETO_LIMIT) +} + /// Record the exit and, unless the host itself ended the game, run the session-ending action. #[cfg(any(target_os = "linux", windows))] fn finish(shared: &Arc, on_exit: &OnExit, why: &str) { @@ -1037,6 +1085,34 @@ mod tests { .any(|(s, _)| s.game.id.as_deref() == Some(id)) } + /// The exit rule, including the thing that was missing: the veto ENDS. + /// + /// Field 2026-08-06 (Windows 0.24.0): Steam's per-app `Running` flag was left set after the game + /// exited, the watcher honoured it on every pass and reset its own confirm window each time, so + /// the game read as running for the life of the host and the stream never auto-ended. The last + /// case below is that regression. + #[cfg(any(target_os = "linux", windows))] + #[test] + fn the_launcher_veto_expires_instead_of_pinning_a_session_open() { + let brief = EXIT_CONFIRM / 2; + let confirmed = EXIT_CONFIRM + Duration::from_secs(1); + let long = VETO_LIMIT + Duration::from_secs(1); + + // Too early to call it either way — a process swap is still plausible. + assert!(!exit_confirmed(brief, false)); + assert!(!exit_confirmed(brief, true)); + // Gone past the confirm window with nothing objecting: exited. + assert!(exit_confirmed(confirmed, false)); + // Same, but the launcher objects — that is what the veto is FOR, so hold off. + assert!(!exit_confirmed(confirmed, true)); + // …and this is the bound. Still objecting, but nothing of the game has existed for + // VETO_LIMIT, so the objection is stale and the session ends anyway. + assert!(exit_confirmed(long, true)); + assert!(exit_confirmed(long, false)); + // (The middle two cases together also pin VETO_LIMIT > EXIT_CONFIRM: a veto that did not + // outlast the window it overrides could never hold anything off in the first place.) + } + #[test] fn kind_follows_what_the_launch_gave_us() { // Nested wins over everything: the display layer owns the lifetime. From ea762b849d61f3b258422baf0f4caaedd2477688 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 6 Aug 2026 13:58:41 +0200 Subject: [PATCH 14/18] fix(client/ios): Escape stays in the game instead of freeing the pointer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pressing Escape mid-stream on an iPad handed the mouse back to iPadOS: the captured cursor was swapped for the system one and the game stopped receiving relative motion, so aiming died until you clicked back in. Two previous attempts treated that release as unavoidable and built recovery around it — a re-lock burst, then a click that re-asks. Both came back from the field unchanged, because both fought the release after it had already happened, inside the cooldown the platform applies straight after its own "let me out" gesture. The release was never unavoidable. This app had no UIKit key handling at all: every key arrives on the GameController path, which is a parallel HID feed that does not consume the UIKit event, and the only thing that ever became first responder was the video view, and only to summon the soft keyboard. So every hardware Escape reached UIKit unclaimed — and an unclaimed key press is precisely what lets the system apply its own default for that key. Apps that read a hardware keyboard the ordinary way consume the event as a side effect and never see this. So claim it. The stream controller becomes first responder while capture is engaged and takes Escape in pressesBegan/pressesEnded, passing every other press to super untouched. Escape still reaches the host on the GameController path, so in-game menus open exactly as before; only the system's own interpretation is suppressed. Scoped to captured input, so Escape keeps dismissing sheets and leaving full screen whenever the stream doesn't own the keyboard, and the deliberate ways out are untouched — Cmd-Escape and Ctrl-Opt-Shift-Q are read off the same GameController path and clear capture themselves. The recovery path stays as a backstop and is retimed to match what was measured: the old burst spent its entire budget within ~0.6 s of the drop, i.e. wholly inside the cooldown, where the answer can only be no. Retries now continue at 1.2 s and 2.4 s, and quietly — they don't hide the cursor or mute pointer motion the way the burst does, so a longer recovery costs nothing when it fails. --- .../PunktfunkKit/Views/StreamViewIOS.swift | 131 +++++++++++++++++- 1 file changed, 128 insertions(+), 3 deletions(-) diff --git a/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift b/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift index bc75217b..f0bca1e2 100644 --- a/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift +++ b/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift @@ -225,6 +225,15 @@ public final class StreamViewController: StreamViewControllerBase { /// How long an escalated attempt reports `prefersPointerLocked == false` before flipping back, /// so the system observes a real transition instead of coalescing the flip away. private static let pointerLockForcedOffHold: TimeInterval = 0.05 + /// Attempts spent in the QUIET tail (see `scheduleQuietRelock()`), reset with the burst. + private var pointerRelockQuietAttempt = 0 + /// When the quiet tail re-asks, measured from the drop. The visible burst above spends its whole + /// budget inside ~0.6 s — and the pointer-lock cooldown the platform applies right after its own + /// Escape gesture is about a second, so every one of those attempts asks while the answer can + /// only be no. These land AFTER it. They are "quiet" because unlike the burst they do not hide + /// the cursor or mute motion: the pointer behaves exactly as it does today while they run, so + /// stretching the recovery costs the user nothing if it also fails. + private static let pointerRelockQuietDelays: [TimeInterval] = [1.2, 2.4] #endif /// Reads whether the scene's pointer is actually locked right now; nil = state @@ -340,12 +349,80 @@ public final class StreamViewController: StreamViewControllerBase { // SwiftUI places us in the hierarchy AFTER start()'s setCaptured(true), and may reparent us // later — re-anchor the chain here so a lock requested before we had a parent still lands. updatePointerLockChain() + anchorKeyResponder() } public override func didMove(toParent parent: UIViewController?) { super.didMove(toParent: parent) updatePointerLockChain() // chain shape changed — re-anchor (or no-op if not yet in a window) } + + /// Put THIS controller on the responder chain for hardware key presses. + /// + /// Nothing of ours is otherwise a first responder during a normal stream: keys arrive on the + /// GameController (`GCKeyboard`) path, which is a parallel HID feed that does not consume the + /// UIKit event, and `StreamLayerUIView` only becomes first responder to summon the SOFT + /// keyboard (it is `UIKeyInput`, so making it one for any other reason would raise the on-screen + /// keyboard mid-game). With no responder of ours in the chain, every hardware key press reaches + /// UIKit unclaimed — and an unclaimed press is what lets the system apply its own default for + /// that key. `pressesBegan` below is where we claim Escape; this is what gets it delivered. + /// + /// A controller is not `UIKeyInput`, so being first responder raises no keyboard. Deferred to + /// the soft keyboard whenever the view has taken over, so the three-finger-swipe keyboard is + /// unaffected. + /// + /// Only while captured — the whole claim is scoped to "the stream owns the keyboard", and + /// holding the chain outside that would sit in front of SwiftUI's focus for no reason. Safe to + /// call from anywhere: `start()` engages capture BEFORE SwiftUI puts us in a window (where + /// `becomeFirstResponder` cannot succeed), so `viewDidAppear` calls it again to catch up. + private func anchorKeyResponder() { + guard captured, !streamView.isFirstResponder, !isFirstResponder else { return } + becomeFirstResponder() + } + + public override var canBecomeFirstResponder: Bool { true } + + /// Claim Escape while the stream owns the keyboard, so the SYSTEM never gets to act on it. + /// + /// This is the fix for "Escape hands the mouse back to iPadOS": the platform releases the + /// scene's pointer lock on an Escape that nothing claimed — the same "let me out" the web + /// Pointer Lock API mandates. Every recovery attempt before this one fought that release AFTER + /// the fact (a re-lock burst, then a click), and the platform's post-Escape cooldown means the + /// burst is refused by construction. Claiming the press means there is nothing to recover from. + /// + /// Escape is forwarded to the host on the GCKeyboard path, which is untouched by this — that + /// path never sees the UIKit responder chain, so the host still receives the keystroke and + /// in-game menus still open. Only the system's own interpretation is suppressed. + /// + /// Strictly scoped: only while `captured` (the stream owns input), and only Escape. Anything + /// else — including every key while the pointer is released — goes to `super` untouched, so + /// Escape still dismisses sheets, exits full screen and does everything else it should whenever + /// we are not holding the keyboard. The deliberate ways out are unaffected: ⌘⎋ and ⌃⌥⇧Q are + /// recognized on the GCKeyboard path and clear `captured` themselves. + public override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { + let unclaimed = presses.filter { !claimsPress($0) } + if !unclaimed.isEmpty || presses.isEmpty { + super.pressesBegan(unclaimed, with: event) + } + } + + public override func pressesEnded(_ presses: Set, with event: UIPressesEvent?) { + let unclaimed = presses.filter { !claimsPress($0) } + if !unclaimed.isEmpty || presses.isEmpty { + super.pressesEnded(unclaimed, with: event) + } + } + + public override func pressesCancelled(_ presses: Set, with event: UIPressesEvent?) { + // Never swallowed: a cancelled press is the system taking the key away from us, and + // dropping it here would strand UIKit's own bookkeeping for a press we did claim. + super.pressesCancelled(presses, with: event) + } + + /// Is this press one the stream owns outright (Escape while captured)? + private func claimsPress(_ press: UIPress) -> Bool { + captured && press.key?.keyCode == .keyboardEscape + } #endif #if os(tvOS) @@ -478,6 +555,7 @@ public final class StreamViewController: StreamViewControllerBase { if !down, self.wantsPointerLock, self.pointerLockWasEngaged, !self.pointerRelockPending, self.pointerLockEngaged() != true { self.pointerRelockAttempt = 0 + self.pointerRelockQuietAttempt = 0 // a real gesture buys a fresh tail too self.updatePointerLockChain() // a reparent since the drop would break the walk to us self.requestPointerRelock() } @@ -749,10 +827,16 @@ public final class StreamViewController: StreamViewControllerBase { guard captureEnabled, !captured, connection != nil else { return } inputCapture?.setForwarding(true, suppressClick: fromClick) captured = true + // Claim the responder chain for as long as we own the keyboard — `pressesBegan` has to + // be delivered to us before it can keep Escape away from the system. + anchorKeyResponder() } else { guard captured else { return } inputCapture?.setForwarding(false) captured = false + // Hand the chain back: released means Escape is the system's again, and staying first + // responder for a stream that no longer owns input would sit in front of SwiftUI focus. + if isFirstResponder { resignFirstResponder() } } setNeedsUpdateOfPrefersPointerLocked() updatePointerLockChain() // (re)anchor the SwiftUI ancestors so the lock actually resolves @@ -782,6 +866,7 @@ public final class StreamViewController: StreamViewControllerBase { pointerLockWasEngaged = true pointerRelockPending = false pointerRelockAttempt = 0 + pointerRelockQuietAttempt = 0 // granted — any scheduled tail finds nothing to do } else if wantsPointerLock, pointerLockWasEngaged { requestPointerRelock() } else { @@ -790,6 +875,7 @@ public final class StreamViewController: StreamViewControllerBase { if !wantsPointerLock { pointerLockWasEngaged = false } pointerRelockPending = false pointerRelockAttempt = 0 + pointerRelockQuietAttempt = 0 } let useGCMouse = captured && locked // Lock dropped (or capture ended) while the GCMouse path held a button down: once @@ -830,10 +916,12 @@ public final class StreamViewController: StreamViewControllerBase { pointerRelockAttempt = 0 } guard pointerRelockAttempt < Self.pointerRelockAttemptLimit else { - // Out of budget: fall back to exactly today's behavior — the iPadOS cursor comes back - // and a click into the video re-captures. The caller invalidates the interaction, so - // the cursor can never stay hidden on a lock the system won't grant. + // Out of VISIBLE budget: give the cursor straight back (the caller invalidates the + // interaction, so it can never stay hidden on a lock the system won't grant) and hand + // off to the quiet tail, which keeps asking after the platform's post-Escape cooldown + // without costing the user anything while it does. pointerRelockPending = false + scheduleQuietRelock() return } pointerRelockAttempt += 1 @@ -881,6 +969,43 @@ public final class StreamViewController: StreamViewControllerBase { } } } + + /// Keep asking for the lock after the visible burst has given up — past the cooldown the + /// platform applies to its own Escape gesture, which is the window the burst spends entirely. + /// + /// Deliberately NOT a longer burst. `pointerRelockPending` hides the cursor and mutes absolute + /// motion, which is only tolerable for the couple of frames a fast re-grab takes; holding that + /// for seconds would trade a released pointer for a frozen one. These attempts leave the + /// pointer fully usable — if they all fail the user sees exactly today's behaviour, and a click + /// is still the immediate way back. + /// + /// Each attempt presents a real false→true transition (the same escalation the burst uses on + /// its later tries) because re-asserting a value the system already holds is what didn't take. + /// A grant arrives as a `didChange` → `syncPointerLock`, which resets the counters, so a + /// successful attempt silently ends the tail. + private func scheduleQuietRelock() { + guard pointerRelockQuietAttempt < Self.pointerRelockQuietDelays.count else { return } + let delay = Self.pointerRelockQuietDelays[pointerRelockQuietAttempt] + pointerRelockQuietAttempt += 1 + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self else { return } + // Still wanted, still ours to want, and still not held — otherwise the tail is moot. + guard self.wantsPointerLock, self.pointerLockWasEngaged, + self.pointerLockEngaged() != true, + self.view.window?.windowScene?.activationState == .foregroundActive + else { return } + self.pointerLockForcedOff = true + self.setNeedsUpdateOfPrefersPointerLocked() + self.updatePointerLockChain() + DispatchQueue.main.asyncAfter(deadline: .now() + Self.pointerLockForcedOffHold) { + [weak self] in + guard let self else { return } + self.pointerLockForcedOff = false + self.setNeedsUpdateOfPrefersPointerLocked() + self.scheduleQuietRelock() // no-op once the delays are spent, or once granted + } + } + } #endif deinit { From d4dd5f7a3d6575726d77a318b5b6a3a2f97c9bda Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 6 Aug 2026 13:58:57 +0200 Subject: [PATCH 15/18] feat(client): a game exiting takes you back to its library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quit a game you launched from a host's library and the stream ended with "Session ended by ." on the host-selection screen — an error report for something you had just done on purpose, and several taps away from starting the next title. The host has always said what happened: it closes the connection with APP_EXITED when the game it launched for a session exits, and that code's own documentation describes this feature. Nothing ever read it — a search across every client found zero consumers. (It also could not reach anyone until the previous commit, since the close only happens once the lease declares the game gone.) The core now records the reason as it observes the close, latched before the shutdown flag because different threads watch the two, and exposes it as punktfunk_connection_game_exited. Purely additive: a client that never asks behaves exactly as before, the host sends identical bytes, and the wire version is untouched — ABI 17. The Apple client asks while the connection is still up, then treats a game exit as the normal finish it is: no error banner, and if the session began as a library launch it reopens that library so the next title is one tap away. Any other ending — a stop, the host going away, network loss — is unchanged. The other clients keep their existing end-of-session behaviour; the call is there when they want it. --- .../Sources/PunktfunkClient/ContentView.swift | 10 ++++++ .../Session/SessionModel.swift | 30 ++++++++++++++-- .../Connection/PunktfunkConnection.swift | 18 ++++++++++ crates/punktfunk-core/src/abi.rs | 36 +++++++++++++++++++ crates/punktfunk-core/src/client/mod.rs | 25 +++++++++++++ crates/punktfunk-core/src/client/pump.rs | 15 ++++++-- crates/punktfunk-core/src/client/worker.rs | 4 +++ crates/punktfunk-core/src/lib.rs | 8 ++++- include/punktfunk_core.h | 28 ++++++++++++++- 9 files changed, 168 insertions(+), 6 deletions(-) diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index c28d6add..11c2471d 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -335,6 +335,16 @@ struct ContentView: View { active: fullscreenForSession && model.connection != nil, isFullscreen: $isFullscreen)) #endif + // A game launched from the library just exited, so the session ended on purpose: put the + // player back in that host's library rather than on host selection. Set on the outer Group + // (like the sheets below) so it survives the streaming → home transition the disconnect + // drives, and consumed here — the model hands the host over once and we clear it, so a + // later manual dismiss of the library can't be undone by a stale value. + .onChange(of: model.returnToLibrary) { _, host in + guard let host else { return } + model.returnToLibrary = nil + libraryTarget = host + } // On the outer Group so the sheet survives the trust-prompt → home transition // (the "Pair with PIN instead" path disconnects first — the host's accept loop // is sequential, a pairing connection would queue behind the live session). diff --git a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift index 8cdf017f..26c51431 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift @@ -65,6 +65,14 @@ final class SessionModel: ObservableObject { @Published private(set) var connection: PunktfunkConnection? /// The host this session is for (a value copy; identity = id). @Published private(set) var activeHost: StoredHost? + /// The library entry this session was launched with (`connect(launchID:)`), or nil if the user + /// just connected to the host's desktop. Kept because where the client should go when the + /// session ends depends on where it came FROM: a title launched out of the library belongs back + /// in that library when its game exits, not on the host-selection screen. + private var launchedTitleID: String? + /// Set when a session ended because its game exited and it began as a library launch: the host + /// whose library to reopen. The view layer consumes it and sets it back to nil. + @Published var returnToLibrary: StoredHost? /// The settings THIS session runs on — the globals with its profile overlaid, resolved once at /// connect (design/client-settings-profiles.md §4.2). Also mirrored into `SessionSettings` for /// the readers that live in PunktfunkKit and can't see this model. @@ -249,6 +257,7 @@ final class SessionModel: ObservableObject { guard phase == .idle else { return } phase = .connecting activeHost = host + launchedTitleID = launchID errorMessage = nil settings = effective statsVerbosity = StatsVerbosity(rawValue: effective.statsVerbosity) ?? .normal @@ -607,6 +616,8 @@ final class SessionModel: ObservableObject { } connection = nil activeHost = nil + // Read by `sessionEnded` BEFORE it calls us, so clearing here can't rob it of the answer. + launchedTitleID = nil phase = .idle fps = 0 mbps = 0 @@ -626,10 +637,25 @@ final class SessionModel: ObservableObject { /// Called (via the main actor) when the pump hits end-of-session. func sessionEnded() { - guard connection != nil else { return } + guard let conn = connection else { return } let name = activeHost?.displayName ?? "host" + // WHY it ended, asked while the connection is still up — `disconnect` tears it down. + // The host closes with APP_EXITED when the game it launched for this session quit, which is + // a normal finish the player just performed, not a failure to report. + let gameExited = conn.endedBecauseGameExited + // Where a game exit sends us: back into the library this title was launched from, so the + // next one is a tap away. Only for a launch that CAME from the library — a game exiting in + // a plain desktop session has no library to return to. + let host = activeHost + let cameFromLibrary = launchedTitleID != nil disconnect(deliberate: false) // host/network ended it — keep the linger for a reconnect - errorMessage = "Session ended by \(name)." + if gameExited { + if cameFromLibrary, let host { + returnToLibrary = host + } + } else { + errorMessage = "Session ended by \(name)." + } } /// Resize overlay START (main actor — from the Match-window follower's `onResizeTarget`): the diff --git a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift index 93d319f0..3cf6b5be 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift @@ -1430,6 +1430,24 @@ public final class PunktfunkConnection { } } + /// Did this session end because **the game the host launched for it exited**, rather than the + /// stream dropping out? + /// + /// Only meaningful once the session HAS ended (a plane threw `.closed`, or `onSessionEnd` + /// fired) — before that it is simply false. False also covers every other ending: a user stop, + /// the host going away, network loss, an idle timeout. Read it before tearing the connection + /// down; once `close()` has been requested this reports false like any other ending, which is + /// the safe direction (the caller falls back to its normal end-of-session handling). + /// + /// A game ending is a normal finish, not a failure — that is the whole point of asking. See + /// `punktfunk_connection_game_exited` (ABI v17). + public var endedBecauseGameExited: Bool { + guard let h = liveHandle() else { return false } + var out: UInt8 = 0 + guard punktfunk_connection_game_exited(h, &out) == statusOK else { return false } + return out != 0 + } + deinit { close() } /// Snapshot the handle unless close is pending (callers hold their plane lock). diff --git a/crates/punktfunk-core/src/abi.rs b/crates/punktfunk-core/src/abi.rs index bc1a9772..f894e340 100644 --- a/crates/punktfunk-core/src/abi.rs +++ b/crates/punktfunk-core/src/abi.rs @@ -2273,6 +2273,42 @@ pub unsafe extern "C" fn punktfunk_connection_audio_channels( }) } +/// Did this session end because **the game the host launched for it exited**? `*out` is set to 1 +/// when it did and 0 otherwise; the return status reports only whether the handle was usable. +/// +/// A refinement of "the session ended", never a substitute — read it only once a plane has +/// returned [`PunktfunkStatus::Closed`] (or the embedder's own end-of-session signal fired), and +/// treat 0 as "ended for some other reason" (user stop, host gone, network loss, idle timeout). +/// It latches, so it is still readable while the connection is being torn down, and a client that +/// never calls it behaves exactly as it did before this existed. +/// +/// The point is that a game ending is a normal finish, not a failure: a launcher client can send +/// the player back to the host's library — one tap from the next title — rather than reporting an +/// error and dropping to host selection for something the player just did on purpose. +/// +/// # Safety +/// `c` is a valid connection handle; `out` is NULL or writable for one `u8`. +#[cfg(feature = "quic")] +#[no_mangle] +pub unsafe extern "C" fn punktfunk_connection_game_exited( + c: *mut PunktfunkConnection, + out: *mut u8, +) -> PunktfunkStatus { + guard(|| { + // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller + // has not yet freed, or null, which `as_ref` reports as `None` and the `match` handles. + let c = match unsafe { c.as_ref() } { + Some(c) => c, + None => return PunktfunkStatus::NullPointer, + }; + if !out.is_null() { + // SAFETY: `out` is non-null and the caller guarantees it is writable for one `u8`. + unsafe { *out = u8::from(c.inner.ended_because_game_exited()) }; + } + PunktfunkStatus::Ok + }) +} + /// One decoded audio frame from [`punktfunk_connection_next_audio_pcm`]: interleaved 32-bit /// float PCM at 48 kHz, in the canonical wire channel order `FL FR FC LFE RL RR SL SR` (the /// first `channels` of it). `samples` points at `frame_count * channels` floats and borrows diff --git a/crates/punktfunk-core/src/client/mod.rs b/crates/punktfunk-core/src/client/mod.rs index f9c15d8b..1db75644 100644 --- a/crates/punktfunk-core/src/client/mod.rs +++ b/crates/punktfunk-core/src/client/mod.rs @@ -180,6 +180,9 @@ pub struct NativeClient { /// Speed-test accumulator, shared with the data-plane pump + control task. probe: Arc>, shutdown: Arc, + /// Set with `shutdown` when the host's close carried [`crate::quic::APP_EXITED_CLOSE_CODE`] — + /// see [`NativeClient::ended_because_game_exited`]. + game_exited: Arc, /// Deliberate-quit flag: [`NativeClient::disconnect_quit`] sets it, so the worker closes the QUIC /// connection with [`crate::quic::QUIT_CLOSE_CODE`] (a user "stop") instead of code 0 — telling the /// host to skip the keep-alive linger. A plain drop leaves it false → an unwanted-disconnect close. @@ -448,6 +451,7 @@ impl NativeClient { std::sync::mpsc::sync_channel::(CURSOR_STATE_QUEUE); let (ready_tx, ready_rx) = std::sync::mpsc::channel::>(); let shutdown = Arc::new(AtomicBool::new(false)); + let game_exited = Arc::new(AtomicBool::new(false)); let quit = Arc::new(AtomicBool::new(false)); let mode_slot = Arc::new(std::sync::Mutex::new(mode)); let probe = Arc::new(Mutex::new(ProbeState::default())); @@ -463,6 +467,7 @@ impl NativeClient { let host = host.to_string(); let frame_chan_w = frame_chan.clone(); let shutdown_w = shutdown.clone(); + let game_exited_w = game_exited.clone(); let quit_w = quit.clone(); let mode_slot_w = mode_slot.clone(); let probe_w = probe.clone(); @@ -538,6 +543,7 @@ impl NativeClient { clip_cmd_rx, ready_tx, shutdown: shutdown_w, + game_exited: game_exited_w, quit: quit_w, mode_slot: mode_slot_w, probe: probe_w, @@ -591,6 +597,7 @@ impl NativeClient { host_caps: negotiated.host_caps, probe, shutdown, + game_exited, quit, worker: Some(worker), frames_dropped, @@ -809,6 +816,24 @@ impl NativeClient { self.shutdown.load(Ordering::SeqCst) } + /// Whether the session ended because **the game the host launched for it exited** — the host + /// closed with [`crate::quic::APP_EXITED_CLOSE_CODE`] rather than dropping out. + /// + /// A refinement of [`is_session_ended`](Self::is_session_ended), never a substitute: it is only + /// ever true once that is, and false covers every other ending (user stop, host gone, network + /// loss, idle timeout) — so a client that ignores it behaves exactly as before. + /// + /// What it is FOR: a game ending is a normal, expected finish, not a failure. A launcher client + /// can read this and go back to the host's library — where the player is one tap from the next + /// title — instead of showing "session ended by " and dropping to host selection, which + /// reads as an error for something the player just did on purpose. + /// + /// Poll it after the session ends (a `Closed` on any plane, or `is_session_ended`); it latches, + /// so it is still readable while the connection is being torn down. + pub fn ended_because_game_exited(&self) -> bool { + self.game_exited.load(Ordering::SeqCst) + } + /// Register the calling thread as latency-critical so a later /// [`hot_thread_ids`](Self::hot_thread_ids) includes it. An embedder calls this from its own /// plane threads (e.g. the Android client's decode + audio threads) to fold them into the same diff --git a/crates/punktfunk-core/src/client/pump.rs b/crates/punktfunk-core/src/client/pump.rs index e6ab8e58..08694306 100644 --- a/crates/punktfunk-core/src/client/pump.rs +++ b/crates/punktfunk-core/src/client/pump.rs @@ -65,6 +65,7 @@ pub(super) async fn run_pump(args: WorkerArgs) { clip_cmd_rx, ready_tx, shutdown, + game_exited, quit, mode_slot, probe, @@ -194,12 +195,22 @@ pub(super) async fn run_pump(args: WorkerArgs) { clip_cmd_rx, )); - // Watch for connection close → stop the pump. + // Watch for connection close → stop the pump, and record WHY if the host said so. { let shutdown = shutdown.clone(); + let game_exited = game_exited.clone(); let conn = conn.clone(); tokio::spawn(async move { - conn.closed().await; + let why = conn.closed().await; + // The host closes with APP_EXITED when the game it launched for this session exited. + // Latch that before `shutdown`, so any client that reacts to the shutdown flag can + // already read the reason — the two are observed by different threads. + if let quinn::ConnectionError::ApplicationClosed(ac) = &why { + if u32::try_from(u64::from(ac.error_code)) == Ok(crate::quic::APP_EXITED_CLOSE_CODE) + { + game_exited.store(true, Ordering::SeqCst); + } + } shutdown.store(true, Ordering::SeqCst); }); } diff --git a/crates/punktfunk-core/src/client/worker.rs b/crates/punktfunk-core/src/client/worker.rs index 35685135..d8029e75 100644 --- a/crates/punktfunk-core/src/client/worker.rs +++ b/crates/punktfunk-core/src/client/worker.rs @@ -68,6 +68,10 @@ pub(crate) struct WorkerArgs { pub(crate) clip_cmd_rx: tokio::sync::mpsc::UnboundedReceiver, pub(crate) ready_tx: std::sync::mpsc::Sender>, pub(crate) shutdown: Arc, + /// Set alongside `shutdown` when the HOST's close carried + /// [`crate::quic::APP_EXITED_CLOSE_CODE`] — the launched game exited (see + /// [`NativeClient::ended_because_game_exited`]). + pub(crate) game_exited: Arc, /// Deliberate-quit flag (see [`NativeClient::quit`]): the worker closes with the quit code if set. pub(crate) quit: Arc, pub(crate) mode_slot: Arc>, diff --git a/crates/punktfunk-core/src/lib.rs b/crates/punktfunk-core/src/lib.rs index 122486f3..1c8530b9 100644 --- a/crates/punktfunk-core/src/lib.rs +++ b/crates/punktfunk-core/src/lib.rs @@ -138,7 +138,13 @@ pub use stats::Stats; /// capability-gated end to end: the wire grows a new datagram tag (0xD1) an old client never /// receives (double-gated caps), a new 0xCD kind (0x06, dropped as unknown by old clients) and /// arrival flag bits 8/9 sent only toward a capable host, so [`WIRE_VERSION`] is unchanged. -pub const ABI_VERSION: u32 = 16; +/// v17: added `punktfunk_connection_game_exited` — asks, once a session has ended, whether it +/// ended because the game the host launched for it EXITED (the host's close carried +/// [`quic::APP_EXITED_CLOSE_CODE`], which it has sent since long before this bump; nothing +/// consumed it). Purely a read of state the core already had: no new call is required of an +/// embedder, a client that never calls it is unchanged, and the host sends exactly the same bytes +/// either way, so [`WIRE_VERSION`] is unchanged. +pub const ABI_VERSION: u32 = 17; /// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check. /// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface** diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index 847b97ee..ce197656 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -76,7 +76,13 @@ // capability-gated end to end: the wire grows a new datagram tag (0xD1) an old client never // receives (double-gated caps), a new 0xCD kind (0x06, dropped as unknown by old clients) and // arrival flag bits 8/9 sent only toward a capable host, so [`WIRE_VERSION`] is unchanged. -#define PUNKTFUNK_ABI_VERSION 16 +// v17: added `punktfunk_connection_game_exited` — asks, once a session has ended, whether it +// ended because the game the host launched for it EXITED (the host's close carried +// [`quic::APP_EXITED_CLOSE_CODE`], which it has sent since long before this bump; nothing +// consumed it). Purely a read of state the core already had: no new call is required of an +// embedder, a client that never calls it is unchanged, and the host sends exactly the same bytes +// either way, so [`WIRE_VERSION`] is unchanged. +#define PUNKTFUNK_ABI_VERSION 17 // The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check. // Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface** @@ -2498,6 +2504,26 @@ PunktfunkStatus punktfunk_connection_next_audio(PunktfunkConnection *c, PunktfunkStatus punktfunk_connection_audio_channels(PunktfunkConnection *c, uint8_t *out); #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Did this session end because **the game the host launched for it exited**? `*out` is set to 1 +// when it did and 0 otherwise; the return status reports only whether the handle was usable. +// +// A refinement of "the session ended", never a substitute — read it only once a plane has +// returned [`PunktfunkStatus::Closed`] (or the embedder's own end-of-session signal fired), and +// treat 0 as "ended for some other reason" (user stop, host gone, network loss, idle timeout). +// It latches, so it is still readable while the connection is being torn down, and a client that +// never calls it behaves exactly as it did before this existed. +// +// The point is that a game ending is a normal finish, not a failure: a launcher client can send +// the player back to the host's library — one tap from the next title — rather than reporting an +// error and dropping to host selection for something the player just did on purpose. +// +// # Safety +// `c` is a valid connection handle; `out` is NULL or writable for one `u8`. +PunktfunkStatus punktfunk_connection_game_exited(PunktfunkConnection *c, + uint8_t *out); +#endif + #if defined(PUNKTFUNK_FEATURE_QUIC) // Pull the next audio frame and **decode it in-core** to interleaved f32 PCM — for embedders // without a multistream-capable Opus decoder (e.g. Apple, whose AudioToolbox Opus path is From ec444962859ff8f11a09871e15e6cdfae6640831 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 6 Aug 2026 14:30:33 +0200 Subject: [PATCH 16/18] feat(client): tell clients WHY a session ended, not just that it did MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A session ending was a single bit. A player quitting their game, an operator ending the session from the console, a stop the client itself asked for, a host crashing and a Wi-Fi drop all arrived as the same "closed" — so every client had to write one message covering all of them, and every client picked an error. That is how quitting your own game came to be reported as trouble on all three. The information was already there and thrown away: the host closes with APP_EXITED when a launched game exits, with 0 when it ends the session cleanly and 1 when it fails, and a link that simply dies never closes at all. The connection watcher now classifies that into a PunktfunkEndReason — local, game exited, host ended, host error, lost — and latches it before the shutdown flag, since the two are read by different threads and the reason must never arrive second. Exposed as punktfunk_connection_end_reason. This replaces the game-exited flag added a moment ago rather than joining it: that question is one row of this table, and it was never released. Still additive to any embedder that ignores it, and the host sends the same bytes either way, so the wire is untouched. `is_normal()` is the question nearly every caller actually has, so both the Rust and C surfaces answer it directly rather than making each client re-derive which of five values are worth alarming a user about. --- crates/punktfunk-core/cbindgen.toml | 5 + crates/punktfunk-core/src/abi.rs | 27 +++-- crates/punktfunk-core/src/client/mod.rs | 128 ++++++++++++++++++--- crates/punktfunk-core/src/client/pump.rs | 19 ++- crates/punktfunk-core/src/client/worker.rs | 7 +- crates/punktfunk-core/src/lib.rs | 13 ++- include/punktfunk_core.h | 96 +++++++++++++--- 7 files changed, 224 insertions(+), 71 deletions(-) diff --git a/crates/punktfunk-core/cbindgen.toml b/crates/punktfunk-core/cbindgen.toml index 25742f4d..428cd854 100644 --- a/crates/punktfunk-core/cbindgen.toml +++ b/crates/punktfunk-core/cbindgen.toml @@ -18,6 +18,11 @@ parse_deps = false # undefined and the C harness fails to compile: the Apple batched recv (transport/udp.rs # `recvmsg_x` + `MsghdrX`) and the Android bionic mmsg bindings (`android_mmsg` module). exclude = ["MsghdrX", "recvmsg_x", "mmsghdr", "sendmmsg", "recvmmsg"] +# Reached by no exported SIGNATURE, so cbindgen's sweep misses it — but a C embedder needs the +# vocabulary: `punktfunk_connection_end_reason` writes one of these as a bare byte (deliberately, +# so the JNI/Swift sides can marshal a `u8` rather than an enum), which without this would leave +# the header documenting names it never defines. +include = ["PunktfunkEndReason"] [export.rename] "InputEvent" = "PunktfunkInputEvent" diff --git a/crates/punktfunk-core/src/abi.rs b/crates/punktfunk-core/src/abi.rs index f894e340..686b6c38 100644 --- a/crates/punktfunk-core/src/abi.rs +++ b/crates/punktfunk-core/src/abi.rs @@ -2273,24 +2273,27 @@ pub unsafe extern "C" fn punktfunk_connection_audio_channels( }) } -/// Did this session end because **the game the host launched for it exited**? `*out` is set to 1 -/// when it did and 0 otherwise; the return status reports only whether the handle was usable. +/// WHY this session ended: `*out` receives a [`PunktfunkEndReason`] byte +/// (`PUNKTFUNK_END_REASON_*`). The return status reports only whether the handle was usable. /// -/// A refinement of "the session ended", never a substitute — read it only once a plane has -/// returned [`PunktfunkStatus::Closed`] (or the embedder's own end-of-session signal fired), and -/// treat 0 as "ended for some other reason" (user stop, host gone, network loss, idle timeout). -/// It latches, so it is still readable while the connection is being torn down, and a client that -/// never calls it behaves exactly as it did before this existed. +/// Read it once a plane has returned [`PunktfunkStatus::Closed`] (or the embedder's own +/// end-of-session signal fired); before that it reads `NONE`. It latches, so it is still readable +/// while the connection is torn down, and a client that never calls it behaves exactly as it did +/// before this existed. /// -/// The point is that a game ending is a normal finish, not a failure: a launcher client can send -/// the player back to the host's library — one tap from the next title — rather than reporting an -/// error and dropping to host selection for something the player just did on purpose. +/// **Most endings are not failures.** Before this, a client had no way to tell a player quitting +/// their game from a host falling off the network, so every client wrote one message for all of +/// them and every client chose an error. Use `LOCAL`/`GAME_EXITED`/`HOST_ENDED` to stay quiet (and +/// `GAME_EXITED` to return to the library the title was launched from), and keep the alarming copy +/// for `HOST_ERROR` and `LOST`. +/// +/// Treat an unrecognized value as `NONE` — this crosses an ABI and the core may be newer than you. /// /// # Safety /// `c` is a valid connection handle; `out` is NULL or writable for one `u8`. #[cfg(feature = "quic")] #[no_mangle] -pub unsafe extern "C" fn punktfunk_connection_game_exited( +pub unsafe extern "C" fn punktfunk_connection_end_reason( c: *mut PunktfunkConnection, out: *mut u8, ) -> PunktfunkStatus { @@ -2303,7 +2306,7 @@ pub unsafe extern "C" fn punktfunk_connection_game_exited( }; if !out.is_null() { // SAFETY: `out` is non-null and the caller guarantees it is writable for one `u8`. - unsafe { *out = u8::from(c.inner.ended_because_game_exited()) }; + unsafe { *out = c.inner.end_reason() as u8 }; } PunktfunkStatus::Ok }) diff --git a/crates/punktfunk-core/src/client/mod.rs b/crates/punktfunk-core/src/client/mod.rs index 1db75644..865f6b3f 100644 --- a/crates/punktfunk-core/src/client/mod.rs +++ b/crates/punktfunk-core/src/client/mod.rs @@ -110,6 +110,91 @@ pub struct MicUplinkStats { /// the control task is wedged, which callers treat as a closed session. const CTRL_QUEUE: usize = 32; +/// Why a session ended — [`NativeClient::end_reason`], and `punktfunk_connection_end_reason` on the +/// C surface. +/// +/// The distinction that matters to a UI is **normal vs alarming**, and it is not a spectrum: a +/// player quitting their game and a host falling off the network both arrive as "the session +/// ended", and a client with no way to separate them has to word all of them the same. Every client +/// worded them as failures. +/// +/// Ordered loosely from "the user did this on purpose" to "something went wrong". Values are part +/// of the C ABI: append only, never renumber. +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PunktfunkEndReason { + /// Not ended (or ended before a reason could be observed). Also what an unknown future value + /// decodes to, so an older client reading a newer core degrades to "no opinion". + None = 0, + /// **This client** closed the session — the user pressed stop, or the handle was dropped. + /// Nothing to report: the UI already knows, it initiated it. + Local = 1, + /// The host's launched game exited ([`crate::quic::APP_EXITED_CLOSE_CODE`]). A normal finish, + /// and the one reason a launcher client can act on: go back to the library the title was + /// launched from rather than all the way out to host selection. + GameExited = 2, + /// The host ended the session cleanly and deliberately — an operator "End" in the console, or + /// the session simply finishing. Normal; say so plainly or say nothing. + HostEnded = 3, + /// The host closed reporting a failure of its own. Worth showing, and the host's log has the + /// detail. + HostError = 4, + /// The connection died rather than being closed: idle timeout, reset, the network going away. + /// This — and only this — is the "the host may be asleep, wake it" case. + Lost = 5, +} + +impl PunktfunkEndReason { + /// Decode the wire/ABI byte. Unknown values become [`Self::None`] rather than panicking: this + /// crosses an ABI where the writer may be newer than the reader. + pub fn from_u8(v: u8) -> Self { + match v { + 1 => Self::Local, + 2 => Self::GameExited, + 3 => Self::HostEnded, + 4 => Self::HostError, + 5 => Self::Lost, + _ => Self::None, + } + } + + /// Whether this ending is an ordinary outcome rather than something to alarm the user about. + /// + /// The single question nearly every client actually asks. `Local`, `GameExited` and `HostEnded` + /// are all things that were *meant* to happen; only a host-side failure or a dead connection + /// are not. [`Self::None`] counts as normal — no evidence of trouble is not evidence of it. + pub fn is_normal(self) -> bool { + !matches!(self, Self::HostError | Self::Lost) + } +} + +#[cfg(feature = "quic")] +impl From<&quinn::ConnectionError> for PunktfunkEndReason { + /// Classify the QUIC close. + /// + /// Only two application codes ever arrive from a host at session end: `APP_EXITED` when the + /// game it launched quit, and the teardown's own `0` (clean) / `1` (the session returned an + /// error) from `native.rs`. Anything else with an application code is a deliberate host-side + /// close we do not have a name for, which is still closer to "the host ended it" than to a + /// dead link — but a code we have never issued is more likely a fault than a courtesy, so it + /// lands in `HostError` where it will at least be visible. + fn from(e: &quinn::ConnectionError) -> Self { + match e { + quinn::ConnectionError::LocallyClosed => Self::Local, + quinn::ConnectionError::ApplicationClosed(ac) => { + match u32::try_from(u64::from(ac.error_code)) { + Ok(crate::quic::APP_EXITED_CLOSE_CODE) => Self::GameExited, + Ok(0) => Self::HostEnded, + _ => Self::HostError, + } + } + // TimedOut, Reset, VersionMismatch, TransportError, CidsExhausted, and the peer's + // transport-level close: the link failed, nobody said goodbye. + _ => Self::Lost, + } + } +} + pub struct NativeClient { // Each plane's receiver sits behind its own mutex so `NativeClient` is `Sync` and Rust // embedders can share one `Arc` across their plane threads (the same @@ -180,9 +265,9 @@ pub struct NativeClient { /// Speed-test accumulator, shared with the data-plane pump + control task. probe: Arc>, shutdown: Arc, - /// Set with `shutdown` when the host's close carried [`crate::quic::APP_EXITED_CLOSE_CODE`] — - /// see [`NativeClient::ended_because_game_exited`]. - game_exited: Arc, + /// A [`PunktfunkEndReason`] as `u8`, latched with `shutdown` — see + /// [`NativeClient::end_reason`]. + end_reason: Arc, /// Deliberate-quit flag: [`NativeClient::disconnect_quit`] sets it, so the worker closes the QUIC /// connection with [`crate::quic::QUIT_CLOSE_CODE`] (a user "stop") instead of code 0 — telling the /// host to skip the keep-alive linger. A plain drop leaves it false → an unwanted-disconnect close. @@ -451,7 +536,7 @@ impl NativeClient { std::sync::mpsc::sync_channel::(CURSOR_STATE_QUEUE); let (ready_tx, ready_rx) = std::sync::mpsc::channel::>(); let shutdown = Arc::new(AtomicBool::new(false)); - let game_exited = Arc::new(AtomicBool::new(false)); + let end_reason = Arc::new(AtomicU8::new(PunktfunkEndReason::None as u8)); let quit = Arc::new(AtomicBool::new(false)); let mode_slot = Arc::new(std::sync::Mutex::new(mode)); let probe = Arc::new(Mutex::new(ProbeState::default())); @@ -467,7 +552,7 @@ impl NativeClient { let host = host.to_string(); let frame_chan_w = frame_chan.clone(); let shutdown_w = shutdown.clone(); - let game_exited_w = game_exited.clone(); + let end_reason_w = end_reason.clone(); let quit_w = quit.clone(); let mode_slot_w = mode_slot.clone(); let probe_w = probe.clone(); @@ -543,7 +628,7 @@ impl NativeClient { clip_cmd_rx, ready_tx, shutdown: shutdown_w, - game_exited: game_exited_w, + end_reason: end_reason_w, quit: quit_w, mode_slot: mode_slot_w, probe: probe_w, @@ -597,7 +682,7 @@ impl NativeClient { host_caps: negotiated.host_caps, probe, shutdown, - game_exited, + end_reason, quit, worker: Some(worker), frames_dropped, @@ -816,22 +901,27 @@ impl NativeClient { self.shutdown.load(Ordering::SeqCst) } - /// Whether the session ended because **the game the host launched for it exited** — the host - /// closed with [`crate::quic::APP_EXITED_CLOSE_CODE`] rather than dropping out. + /// WHY the session ended — see [`PunktfunkEndReason`]. /// - /// A refinement of [`is_session_ended`](Self::is_session_ended), never a substitute: it is only - /// ever true once that is, and false covers every other ending (user stop, host gone, network - /// loss, idle timeout) — so a client that ignores it behaves exactly as before. + /// A refinement of [`is_session_ended`](Self::is_session_ended), never a substitute: it stays + /// [`PunktfunkEndReason::None`] until that is true, and every client that ignores it behaves + /// exactly as it did before this existed. /// - /// What it is FOR: a game ending is a normal, expected finish, not a failure. A launcher client - /// can read this and go back to the host's library — where the player is one tap from the next - /// title — instead of showing "session ended by " and dropping to host selection, which - /// reads as an error for something the player just did on purpose. + /// What it is FOR: **most endings are not failures.** A client that cannot tell them apart has + /// to pick one wording for all of them, and every such client picked an error — "Session ended + /// by ", "Connection lost — the host may be asleep" — including when the player quit the + /// game themselves. This is the discriminator that lets each client stay quiet for a normal + /// finish, return to its library when a launched game exits, and reserve the alarming copy for + /// an ending that actually deserves it. /// - /// Poll it after the session ends (a `Closed` on any plane, or `is_session_ended`); it latches, - /// so it is still readable while the connection is being torn down. + /// Latches, so it is still readable while the connection is being torn down. + pub fn end_reason(&self) -> PunktfunkEndReason { + PunktfunkEndReason::from_u8(self.end_reason.load(Ordering::SeqCst)) + } + + /// Shorthand for the single most actionable reason: the host's launched game exited. pub fn ended_because_game_exited(&self) -> bool { - self.game_exited.load(Ordering::SeqCst) + self.end_reason() == PunktfunkEndReason::GameExited } /// Register the calling thread as latency-critical so a later diff --git a/crates/punktfunk-core/src/client/pump.rs b/crates/punktfunk-core/src/client/pump.rs index 08694306..8308f2e2 100644 --- a/crates/punktfunk-core/src/client/pump.rs +++ b/crates/punktfunk-core/src/client/pump.rs @@ -65,7 +65,7 @@ pub(super) async fn run_pump(args: WorkerArgs) { clip_cmd_rx, ready_tx, shutdown, - game_exited, + end_reason, quit, mode_slot, probe, @@ -195,22 +195,17 @@ pub(super) async fn run_pump(args: WorkerArgs) { clip_cmd_rx, )); - // Watch for connection close → stop the pump, and record WHY if the host said so. + // Watch for connection close → stop the pump, and classify WHY. { let shutdown = shutdown.clone(); - let game_exited = game_exited.clone(); + let end_reason = end_reason.clone(); let conn = conn.clone(); tokio::spawn(async move { let why = conn.closed().await; - // The host closes with APP_EXITED when the game it launched for this session exited. - // Latch that before `shutdown`, so any client that reacts to the shutdown flag can - // already read the reason — the two are observed by different threads. - if let quinn::ConnectionError::ApplicationClosed(ac) = &why { - if u32::try_from(u64::from(ac.error_code)) == Ok(crate::quic::APP_EXITED_CLOSE_CODE) - { - game_exited.store(true, Ordering::SeqCst); - } - } + // Latch the reason BEFORE `shutdown`: the two are observed by different threads, and a + // client that reacts to the shutdown flag must never find the reason still unset. + let reason = crate::client::PunktfunkEndReason::from(&why); + end_reason.store(reason as u8, Ordering::SeqCst); shutdown.store(true, Ordering::SeqCst); }); } diff --git a/crates/punktfunk-core/src/client/worker.rs b/crates/punktfunk-core/src/client/worker.rs index d8029e75..0b8e0fa5 100644 --- a/crates/punktfunk-core/src/client/worker.rs +++ b/crates/punktfunk-core/src/client/worker.rs @@ -68,10 +68,9 @@ pub(crate) struct WorkerArgs { pub(crate) clip_cmd_rx: tokio::sync::mpsc::UnboundedReceiver, pub(crate) ready_tx: std::sync::mpsc::Sender>, pub(crate) shutdown: Arc, - /// Set alongside `shutdown` when the HOST's close carried - /// [`crate::quic::APP_EXITED_CLOSE_CODE`] — the launched game exited (see - /// [`NativeClient::ended_because_game_exited`]). - pub(crate) game_exited: Arc, + /// A [`crate::client::PunktfunkEndReason`] as `u8`, classified from the connection's close and + /// latched alongside `shutdown` (see [`NativeClient::end_reason`]). + pub(crate) end_reason: Arc, /// Deliberate-quit flag (see [`NativeClient::quit`]): the worker closes with the quit code if set. pub(crate) quit: Arc, pub(crate) mode_slot: Arc>, diff --git a/crates/punktfunk-core/src/lib.rs b/crates/punktfunk-core/src/lib.rs index 1c8530b9..60b559dd 100644 --- a/crates/punktfunk-core/src/lib.rs +++ b/crates/punktfunk-core/src/lib.rs @@ -138,12 +138,13 @@ pub use stats::Stats; /// capability-gated end to end: the wire grows a new datagram tag (0xD1) an old client never /// receives (double-gated caps), a new 0xCD kind (0x06, dropped as unknown by old clients) and /// arrival flag bits 8/9 sent only toward a capable host, so [`WIRE_VERSION`] is unchanged. -/// v17: added `punktfunk_connection_game_exited` — asks, once a session has ended, whether it -/// ended because the game the host launched for it EXITED (the host's close carried -/// [`quic::APP_EXITED_CLOSE_CODE`], which it has sent since long before this bump; nothing -/// consumed it). Purely a read of state the core already had: no new call is required of an -/// embedder, a client that never calls it is unchanged, and the host sends exactly the same bytes -/// either way, so [`WIRE_VERSION`] is unchanged. +/// v17: added `punktfunk_connection_end_reason` + the `PUNKTFUNK_END_REASON_*` vocabulary — asks, +/// once a session has ended, WHY: this client closed it, the host's launched game exited (its close +/// carried [`quic::APP_EXITED_CLOSE_CODE`], which the host has sent since long before this bump +/// with nothing consuming it), the host ended it cleanly, the host reported a failure, or the +/// connection was simply lost. Purely a read of state the core already had: no new call is required +/// of an embedder, a client that never calls it is unchanged, and the host sends exactly the same +/// bytes either way, so [`WIRE_VERSION`] is unchanged. pub const ABI_VERSION: u32 = 17; /// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check. diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index ce197656..9bf9d924 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -76,12 +76,13 @@ // capability-gated end to end: the wire grows a new datagram tag (0xD1) an old client never // receives (double-gated caps), a new 0xCD kind (0x06, dropped as unknown by old clients) and // arrival flag bits 8/9 sent only toward a capable host, so [`WIRE_VERSION`] is unchanged. -// v17: added `punktfunk_connection_game_exited` — asks, once a session has ended, whether it -// ended because the game the host launched for it EXITED (the host's close carried -// [`quic::APP_EXITED_CLOSE_CODE`], which it has sent since long before this bump; nothing -// consumed it). Purely a read of state the core already had: no new call is required of an -// embedder, a client that never calls it is unchanged, and the host sends exactly the same bytes -// either way, so [`WIRE_VERSION`] is unchanged. +// v17: added `punktfunk_connection_end_reason` + the `PUNKTFUNK_END_REASON_*` vocabulary — asks, +// once a session has ended, WHY: this client closed it, the host's launched game exited (its close +// carried [`quic::APP_EXITED_CLOSE_CODE`], which the host has sent since long before this bump +// with nothing consuming it), the host ended it cleanly, the host reported a failure, or the +// connection was simply lost. Purely a read of state the core already had: no new call is required +// of an embedder, a client that never calls it is unchanged, and the host sends exactly the same +// bytes either way, so [`WIRE_VERSION`] is unchanged. #define PUNKTFUNK_ABI_VERSION 17 // The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check. @@ -1622,6 +1623,63 @@ typedef uint8_t PunktfunkInputKind; #endif // __STDC_VERSION__ >= 202311L #endif // __cplusplus +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Why a session ended — [`NativeClient::end_reason`], and `punktfunk_connection_end_reason` on the +// C surface. +// +// The distinction that matters to a UI is **normal vs alarming**, and it is not a spectrum: a +// player quitting their game and a host falling off the network both arrive as "the session +// ended", and a client with no way to separate them has to word all of them the same. Every client +// worded them as failures. +// +// Ordered loosely from "the user did this on purpose" to "something went wrong". Values are part +// of the C ABI: append only, never renumber. +enum PunktfunkEndReason +#if defined(__cplusplus) || __STDC_VERSION__ >= 202311L + : uint8_t +#endif // defined(__cplusplus) || __STDC_VERSION__ >= 202311L + { +#if defined(PUNKTFUNK_FEATURE_QUIC) + // Not ended (or ended before a reason could be observed). Also what an unknown future value + // decodes to, so an older client reading a newer core degrades to "no opinion". + PUNKTFUNK_END_REASON_NONE = 0, +#endif +#if defined(PUNKTFUNK_FEATURE_QUIC) + // **This client** closed the session — the user pressed stop, or the handle was dropped. + // Nothing to report: the UI already knows, it initiated it. + PUNKTFUNK_END_REASON_LOCAL = 1, +#endif +#if defined(PUNKTFUNK_FEATURE_QUIC) + // The host's launched game exited ([`crate::quic::APP_EXITED_CLOSE_CODE`]). A normal finish, + // and the one reason a launcher client can act on: go back to the library the title was + // launched from rather than all the way out to host selection. + PUNKTFUNK_END_REASON_GAME_EXITED = 2, +#endif +#if defined(PUNKTFUNK_FEATURE_QUIC) + // The host ended the session cleanly and deliberately — an operator "End" in the console, or + // the session simply finishing. Normal; say so plainly or say nothing. + PUNKTFUNK_END_REASON_HOST_ENDED = 3, +#endif +#if defined(PUNKTFUNK_FEATURE_QUIC) + // The host closed reporting a failure of its own. Worth showing, and the host's log has the + // detail. + PUNKTFUNK_END_REASON_HOST_ERROR = 4, +#endif +#if defined(PUNKTFUNK_FEATURE_QUIC) + // The connection died rather than being closed: idle timeout, reset, the network going away. + // This — and only this — is the "the host may be asleep, wake it" case. + PUNKTFUNK_END_REASON_LOST = 5, +#endif +}; +#ifndef __cplusplus +#if __STDC_VERSION__ >= 202311L +typedef enum PunktfunkEndReason PunktfunkEndReason; +#else +typedef uint8_t PunktfunkEndReason; +#endif // __STDC_VERSION__ >= 202311L +#endif // __cplusplus +#endif + #if defined(PUNKTFUNK_FEATURE_QUIC) // Per-session colour signalling (CICP / ITU-T H.273 code points) the host resolved for the // encoded video, carried on [`Welcome`]. A client configures its decoder/presenter from these @@ -2505,23 +2563,25 @@ PunktfunkStatus punktfunk_connection_audio_channels(PunktfunkConnection *c, uint #endif #if defined(PUNKTFUNK_FEATURE_QUIC) -// Did this session end because **the game the host launched for it exited**? `*out` is set to 1 -// when it did and 0 otherwise; the return status reports only whether the handle was usable. +// WHY this session ended: `*out` receives a [`PunktfunkEndReason`] byte +// (`PUNKTFUNK_END_REASON_*`). The return status reports only whether the handle was usable. // -// A refinement of "the session ended", never a substitute — read it only once a plane has -// returned [`PunktfunkStatus::Closed`] (or the embedder's own end-of-session signal fired), and -// treat 0 as "ended for some other reason" (user stop, host gone, network loss, idle timeout). -// It latches, so it is still readable while the connection is being torn down, and a client that -// never calls it behaves exactly as it did before this existed. +// Read it once a plane has returned [`PunktfunkStatus::Closed`] (or the embedder's own +// end-of-session signal fired); before that it reads `NONE`. It latches, so it is still readable +// while the connection is torn down, and a client that never calls it behaves exactly as it did +// before this existed. // -// The point is that a game ending is a normal finish, not a failure: a launcher client can send -// the player back to the host's library — one tap from the next title — rather than reporting an -// error and dropping to host selection for something the player just did on purpose. +// **Most endings are not failures.** Before this, a client had no way to tell a player quitting +// their game from a host falling off the network, so every client wrote one message for all of +// them and every client chose an error. Use `LOCAL`/`GAME_EXITED`/`HOST_ENDED` to stay quiet (and +// `GAME_EXITED` to return to the library the title was launched from), and keep the alarming copy +// for `HOST_ERROR` and `LOST`. +// +// Treat an unrecognized value as `NONE` — this crosses an ABI and the core may be newer than you. // // # Safety // `c` is a valid connection handle; `out` is NULL or writable for one `u8`. -PunktfunkStatus punktfunk_connection_game_exited(PunktfunkConnection *c, - uint8_t *out); +PunktfunkStatus punktfunk_connection_end_reason(PunktfunkConnection *c, uint8_t *out); #endif #if defined(PUNKTFUNK_FEATURE_QUIC) From 81b4f76c4d2130b43b89e0c2fb8c716b67caec6f Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 6 Aug 2026 14:30:46 +0200 Subject: [PATCH 17/18] fix(client): a session ending on purpose stops reading as a failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop clients turned every host-side close into "Host ended the session", and a reason string means "abnormal" to everything downstream: the GTK and Windows shells raised a banner, the console overlay drew a status strip. Quitting a game you launched yourself produced all of that. Now only a host error or a lost connection carries a message; the deliberate endings return the silence those shells already give a clean exit, which is also what puts the console back in its library with nothing in the way. The Apple client gains the same distinction. It had one line for every ending — "Session ended by ." — which is fine for an operator stopping the session and wrong for a link that died, so each now says what happened. A game exiting stays silent and returns to the library it was launched from. Both read the reason while the connection is still up, because tearing it down is what makes it unreadable, and both fall back to their previous wording when there is no verdict — an older core, or a close that raced the read — rather than inventing a new one for a case they cannot see. --- .../Session/SessionModel.swift | 21 +++++-- .../Connection/PunktfunkConnection.swift | 55 ++++++++++++++----- crates/pf-client-core/src/session.rs | 22 +++++++- 3 files changed, 77 insertions(+), 21 deletions(-) diff --git a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift index 26c51431..86dbb4cc 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift @@ -640,20 +640,31 @@ final class SessionModel: ObservableObject { guard let conn = connection else { return } let name = activeHost?.displayName ?? "host" // WHY it ended, asked while the connection is still up — `disconnect` tears it down. - // The host closes with APP_EXITED when the game it launched for this session quit, which is - // a normal finish the player just performed, not a failure to report. - let gameExited = conn.endedBecauseGameExited + let reason = conn.sessionEndReason // Where a game exit sends us: back into the library this title was launched from, so the // next one is a tap away. Only for a launch that CAME from the library — a game exiting in // a plain desktop session has no library to return to. let host = activeHost let cameFromLibrary = launchedTitleID != nil disconnect(deliberate: false) // host/network ended it — keep the linger for a reconnect - if gameExited { + switch reason { + case .gameExited: + // The player quit their own game. Not a failure, and they are probably after the next + // title — so no banner, and back to the library it came from. if cameFromLibrary, let host { returnToLibrary = host } - } else { + case .hostEnded, .local: + // Someone asked for this: an operator "End" on the host, or our own close racing in. + // Say it plainly, without the error framing. + errorMessage = "\(name) ended the session." + case .hostError: + errorMessage = "\(name) ended the session with an error." + case .lost: + errorMessage = "Lost the connection to \(name)." + case .none: + // No verdict (an older core, or the close raced the read): keep the wording this path + // has always used rather than inventing one. errorMessage = "Session ended by \(name)." } } diff --git a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift index 3cf6b5be..350b44f6 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift @@ -1430,24 +1430,49 @@ public final class PunktfunkConnection { } } - /// Did this session end because **the game the host launched for it exited**, rather than the - /// stream dropping out? + /// Why a stream session ended — the Swift mirror of `PunktfunkEndReason` (ABI v17). /// - /// Only meaningful once the session HAS ended (a plane threw `.closed`, or `onSessionEnd` - /// fired) — before that it is simply false. False also covers every other ending: a user stop, - /// the host going away, network loss, an idle timeout. Read it before tearing the connection - /// down; once `close()` has been requested this reports false like any other ending, which is - /// the safe direction (the caller falls back to its normal end-of-session handling). - /// - /// A game ending is a normal finish, not a failure — that is the whole point of asking. See - /// `punktfunk_connection_game_exited` (ABI v17). - public var endedBecauseGameExited: Bool { - guard let h = liveHandle() else { return false } - var out: UInt8 = 0 - guard punktfunk_connection_game_exited(h, &out) == statusOK else { return false } - return out != 0 + /// The distinction that matters to a UI is normal vs alarming, and it is not a spectrum: a + /// player quitting their game and a host falling off the network both arrive as "the session + /// ended". Without this every client wrote one message for all of them, and every client chose + /// an error. + public enum SessionEndReason: UInt8, Sendable { + /// Not ended, or ended before a reason could be observed. Also the fallback for an + /// unrecognized value — the core may be newer than this code. + case none = 0 + /// This client closed the session. Nothing to report: the UI initiated it. + case local = 1 + /// The host's launched game exited. A normal finish, and the one reason worth acting on: + /// go back to the library the title was launched from. + case gameExited = 2 + /// The host ended the session deliberately (an operator "End", or it simply finished). + case hostEnded = 3 + /// The host closed reporting a failure of its own. + case hostError = 4 + /// The connection died rather than being closed: idle timeout, reset, network gone. This — + /// and only this — is the "the host may be asleep" case. + case lost = 5 + + /// Is this an ordinary outcome rather than something to alarm the user about? `.none` + /// counts as normal: no evidence of trouble is not evidence of it. + public var isNormal: Bool { self != .hostError && self != .lost } } + /// Why this session ended. Only meaningful once it HAS ended (a plane threw `.closed`, or + /// `onSessionEnd` fired) — before that it is `.none`. + /// + /// Read it before tearing the connection down: once `close()` has been requested this reports + /// `.none`, which is the safe direction (the caller falls back to its normal handling). + public var sessionEndReason: SessionEndReason { + guard let h = liveHandle() else { return .none } + var out: UInt8 = 0 + guard punktfunk_connection_end_reason(h, &out) == statusOK else { return .none } + return SessionEndReason(rawValue: out) ?? .none + } + + /// Shorthand for the single most actionable reason: the host's launched game exited. + public var endedBecauseGameExited: Bool { sessionEndReason == .gameExited } + deinit { close() } /// Snapshot the handle unless close is pending (callers hold their plane lock). diff --git a/crates/pf-client-core/src/session.rs b/crates/pf-client-core/src/session.rs index c0949c02..11449db1 100644 --- a/crates/pf-client-core/src/session.rs +++ b/crates/pf-client-core/src/session.rs @@ -908,7 +908,27 @@ fn pump( } } Err(PunktfunkError::NoFrame) => {} - Err(PunktfunkError::Closed) => break Some("Host ended the session".to_string()), + // The session ended. `None` here means "normal finish" to every embedder — the browse + // console returns to the library with no status strip, the one-shot binary exits 0 + // quietly — so only an ending that actually went wrong should carry a message. + // Previously EVERY close reported "Host ended the session", which put an error-shaped + // line in front of the player for quitting their own game. + Err(PunktfunkError::Closed) => { + use punktfunk_core::client::PunktfunkEndReason as End; + break match connector.end_reason() { + // The player quit the game the host launched. Nothing to report; a launcher + // embedder returns to its library, which is where they were headed anyway. + End::GameExited => None, + // We closed it, or the host closed cleanly (an operator "End", or the session + // simply finishing). Both were asked for. + End::Local | End::HostEnded => None, + End::HostError => Some("The host ended the session with an error".to_string()), + End::Lost => Some("Connection lost".to_string()), + // No verdict (an older core, or the close raced the read): keep the wording + // this arm has always used rather than inventing a new one. + End::None => Some("Host ended the session".to_string()), + }; + } Err(e) => break Some(format!("session: {e:?}")), } From 72777119fd39a38eafd0d0fff4df3785404bd552 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 6 Aug 2026 14:30:59 +0200 Subject: [PATCH 18/18] fix(client/android): stop reporting every disconnect as a lost connection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stream watchdog polled a bare "has the session ended" boolean, so it had exactly one thing it could say and said it every time: "Connection lost — the host may be asleep. Wake it to reconnect." That ran when the player quit their game, when an operator ended the session from the console, and when they pressed Back themselves — telling them to go wake a host that was never asleep. It now reads the end reason. Only a connection that actually died gets that line, a host-side failure gets its own, and the three deliberate endings say nothing at all: leaving the stream is already the feedback, and a toast on top of it is just noise. A game launched from a library also returns to that library instead of host selection, which needs the intent hoisted out of the console shell: the stream replaces that shell in the composition, discarding the `remember`s holding its screen and host, so by the time the session ends there is nothing left to navigate back with. The parent holds it across the gap and the shell consumes it on the way in. The touch UI has no library — only the console shell does — so there it is the toast fix alone. --- .../src/main/kotlin/io/unom/punktfunk/App.kt | 44 ++++++++++++++- .../kotlin/io/unom/punktfunk/LibraryScreen.kt | 9 ++- .../kotlin/io/unom/punktfunk/StreamScreen.kt | 43 ++++++++++---- .../io/unom/punktfunk/models/UiModels.kt | 10 ++++ .../io/unom/punktfunk/kit/NativeBridge.kt | 12 ++++ .../io/unom/punktfunk/kit/SessionEndReason.kt | 56 +++++++++++++++++++ clients/android/native/src/session/connect.rs | 25 +++++++++ 7 files changed, 186 insertions(+), 13 deletions(-) create mode 100644 clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/SessionEndReason.kt diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt index 43a66b76..54c2f02e 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt @@ -47,6 +47,7 @@ import android.widget.Toast import io.unom.punktfunk.kit.link.DeepLinkResult import io.unom.punktfunk.kit.link.DeepLinks import io.unom.punktfunk.kit.link.HostResolution +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 @@ -61,6 +62,11 @@ fun App(forceGamepadUi: Boolean = false) { // so the stream screen never re-reads the store behind its own connect's back. var session by remember { mutableStateOf(null) } var tab by remember { mutableStateOf(Tab.Connect) } + // Set when a session ends because its game exited and it began as a library launch: the host + // whose library the console shell should come back to. Held HERE because the shell's own + // navigation state does not outlive the stream. Cleared once the shell has consumed it, so a + // later manual Back out of the library is not undone by a stale value. + var reopenLibraryHostId by remember { mutableStateOf(null) } // Console (gamepad) mode mirrors the Apple client: the setting AND (a pad is attached OR this is // a TV OR the dev force flag). Flips live as controllers connect/disconnect. @@ -107,7 +113,20 @@ fun App(forceGamepadUi: Boolean = false) { ) { active -> if (active != null) { // Immersive: the stream takes the whole screen, no bottom bar. - StreamScreen(active, onDisconnect = { session = null }) + StreamScreen(active) { reason -> + // A game launched from a library exiting is a normal finish, and the player is + // almost certainly after the next title — so send them back to that library rather + // than all the way out to host selection. The console shell's own screen state does + // not survive the stream (StreamScreen replaces it in the composition, discarding + // its `remember`s), so the intent is hoisted here and handed back on the way in. + reopenLibraryHostId = + if (reason == SessionEndReason.GAME_EXITED && active.launchedFromLibrary) { + active.hostId + } else { + null + } + session = null + } } else if (gamepadUi) { GamepadShell( settings = settings, @@ -115,6 +134,8 @@ fun App(forceGamepadUi: Boolean = false) { onConnected = { session = it }, deepLink = pendingLink, onDeepLinkHandled = { activity?.pendingDeepLink = null }, + reopenLibraryHostId = reopenLibraryHostId, + onReopenLibraryHandled = { reopenLibraryHostId = null }, ) } else { // Adaptive nav: a bottom bar on phones; on tablets / large windows a side NavigationRail @@ -218,11 +239,32 @@ fun GamepadShell( onConnected: (ActiveSession) -> Unit, deepLink: String? = null, onDeepLinkHandled: () -> Unit = {}, + /** + * Open this saved host's library instead of Home on the way in — set when a game launched from + * it has just exited. Null (the default) starts on Home exactly as before. + */ + reopenLibraryHostId: String? = null, + onReopenLibraryHandled: () -> Unit = {}, ) { val context = LocalContext.current var screen by remember { mutableStateOf(GamepadScreen.Home) } var libraryHost by remember { mutableStateOf(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. + // A host that has since been forgotten simply leaves us on Home rather than failing. + LaunchedEffect(reopenLibraryHostId) { + val id = reopenLibraryHostId ?: return@LaunchedEffect + // Navigate BEFORE acknowledging: acknowledging clears the parent's state, which re-keys + // this effect and cancels the coroutine running it. Nothing suspends in between today, so + // either order happens to work — but this one cannot be broken by a later edit that adds a + // suspending call. A host that has since been forgotten just leaves us on Home. + KnownHostStore(context).all() + .firstOrNull { it.id == id } + ?.let { libraryHost = it; screen = GamepadScreen.Library } + onReopenLibraryHandled() + } + // On a TV, shrink the 10-foot UI so its elements aren't oversized. Density-aware: expand the // effective dp footprint to at least CONSOLE_TV_MIN_WIDTH_DP (→ smaller elements) ONLY when the // panel reports fewer dp than that; a low-density TV that's already spacious, and every phone / diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt index 93939b23..f54de80f 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt @@ -145,7 +145,14 @@ fun LibraryScreen( launching = false if (handle != 0L) { onLaunched( - ActiveSession(handle, settings, host.clipboardSync), + ActiveSession( + handle, + settings, + host.clipboardSync, + hostId = host.id, + // Where to come back to when this game exits. + launchedFromLibrary = true, + ), ) } else Toast.makeText( diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt index a1abd4ed..600804b7 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt @@ -73,6 +73,7 @@ import io.unom.punktfunk.kit.GamepadRouter import io.unom.punktfunk.kit.deviceBodyVibrator import io.unom.punktfunk.kit.NativeBridge import io.unom.punktfunk.kit.Sc2Capture +import io.unom.punktfunk.kit.SessionEndReason import io.unom.punktfunk.kit.VideoDecoders import io.unom.punktfunk.models.ActiveSession import java.util.concurrent.atomic.AtomicBoolean @@ -86,7 +87,7 @@ import kotlinx.coroutines.delay * the connect that produced this handle. */ @Composable -fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) { +fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> Unit) { val handle = session.handle val initialSettings = session.settings val micEnabled = initialSettings.micEnabled @@ -200,12 +201,32 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) { while (true) { delay(1000) if (NativeBridge.nativeSessionEnded(handle)) { - Toast.makeText( - context, - "Connection lost — the host may be asleep. Wake it to reconnect.", - Toast.LENGTH_LONG, - ).show() - onDisconnect() + // WHY it ended decides what the user is told. This used to show the "host may be + // asleep" line for EVERY ending — including a game the player had just quit and a + // session the host ended on purpose — which reads as a failure report for + // something nobody did wrong. Only a connection that actually died says that now. + val reason = SessionEndReason.fromNative(NativeBridge.nativeEndReason(handle)) + when (reason) { + SessionEndReason.LOST -> + Toast.makeText( + context, + "Connection lost — the host may be asleep. Wake it to reconnect.", + Toast.LENGTH_LONG, + ).show() + SessionEndReason.HOST_ERROR -> + Toast.makeText( + context, + "The host ended the session with an error.", + Toast.LENGTH_LONG, + ).show() + // Deliberate endings — the player quit the game, the host was stopped, or we + // closed it. Leaving the stream IS the feedback; a toast would only add noise. + SessionEndReason.GAME_EXITED, + SessionEndReason.HOST_ENDED, + SessionEndReason.LOCAL, + SessionEndReason.NONE -> {} + } + onSessionEnded(reason) return@LaunchedEffect } } @@ -330,7 +351,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) { // 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 // the same way the Back gesture does. - activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() } + activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onSessionEnded(SessionEndReason.LOCAL) } router.onExitChord = { activity?.requestStreamExit?.invoke() } // Show a "hold to quit" hint the moment the chord completes (the router debounces the actual // exit); it clears when the buttons release early or the hold elapses. Runs on the main thread. @@ -617,7 +638,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) { } // Back gesture = a deliberate exit → signal the quit so the host tears down now (no linger). - BackHandler { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() } + BackHandler { NativeBridge.nativeDisconnectQuit(handle); onSessionEnded(SessionEndReason.LOCAL) } // Leaving the app (Home, task switch, screen off) MUST end the session. Android does not // suspend a process for going to background, so without this the native worker kept running and @@ -625,14 +646,14 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) { // host still saw a live client and held the session (and its display + encoder) open until the // OS eventually reclaimed the process, which on a TV box is effectively never. // - // Route it through `onDisconnect()` so the composable's `onDispose` above runs the one real + // Route it through `onSessionEnded()` so the composable's `onDispose` above runs the one real // teardown path. Deliberately NOT a `nativeDisconnectQuit`: backgrounding isn't a user "quit", // so the host should linger the display and make coming straight back a fast reconnect. DisposableEffect(handle) { val lifecycle = (context as? LifecycleOwner)?.lifecycle val obs = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_STOP) { - onDisconnect() + onSessionEnded(SessionEndReason.LOCAL) } } lifecycle?.addObserver(obs) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/models/UiModels.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/models/UiModels.kt index b33f4ac2..7fe3d279 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/models/UiModels.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/models/UiModels.kt @@ -61,6 +61,16 @@ data class ActiveSession( * from "a different host" (a notice; a URL may never preempt a live session). */ val hostId: String? = null, + /** + * This session was started by launching a title from [hostId]'s library, rather than by + * connecting to the host's desktop. + * + * Decides where the client goes when the session ENDS: a title launched out of a library + * belongs back in that library when its game exits — one press from the next one — not on the + * host-selection screen. Only meaningful together with a + * [io.unom.punktfunk.kit.SessionEndReason.GAME_EXITED] ending. + */ + val launchedFromLibrary: Boolean = false, ) /** Trust state of a host, shown as a colored pill on its card. */ diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt index e8a93e60..77513e1f 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt @@ -87,6 +87,18 @@ object NativeBridge { */ external fun nativeSessionEnded(handle: Long): Boolean + /** + * WHY the session ended, as a [SessionEndReason] ordinal — decode with + * [SessionEndReason.fromNative]. `0` (NONE) before it ends, or on a `0` handle. + * + * The companion to [nativeSessionEnded], which only says THAT it ended. Both are needed: the + * flag to leave a dead stream, this to decide what to tell the user. A player quitting their + * game and a host falling off the network both end the session, and with no way to separate + * them the watchdog said "the host may be asleep" for all of them — wrong for every deliberate + * ending. Cheap (one atomic load); UI-safe. + */ + external fun nativeEndReason(handle: Long): Int + /** * Run the SPAKE2 PIN ceremony, presenting [certPem]/[keyPem]. Returns the host's verified * fingerprint (64-hex) to persist + pin, or `""` on failure (wrong PIN / MITM / unreachable). diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/SessionEndReason.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/SessionEndReason.kt new file mode 100644 index 00000000..f918cb53 --- /dev/null +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/SessionEndReason.kt @@ -0,0 +1,56 @@ +package io.unom.punktfunk.kit + +/** + * Why a stream session ended — the Kotlin mirror of `punktfunk_core::client::PunktfunkEndReason`, + * read via [NativeBridge.nativeEndReason]. + * + * The distinction that matters to a UI is **normal vs alarming**, and it is not a spectrum: a + * player quitting their game and a host falling off the network both arrive as "the session + * ended". With no way to tell them apart this client showed one message for all of them — and it + * was the alarming one ("Connection lost — the host may be asleep"), in front of players who had + * just quit their own game. + * + * Ordinals are an ABI contract with the Rust side: append only, never renumber. + */ +enum class SessionEndReason { + /** Not ended, or ended before a reason could be observed. Also the fallback for an unknown value. */ + NONE, + + /** This client closed the session — the user pressed back or stop. Nothing to report. */ + LOCAL, + + /** + * The host's launched game exited. A normal finish, and the one reason worth acting on: go back + * to the library the title was launched from, so the next one is a tap away. + */ + GAME_EXITED, + + /** The host ended the session deliberately (an operator "End", or it simply finished). Normal. */ + HOST_ENDED, + + /** The host closed reporting a failure of its own. Worth showing; the host's log has the detail. */ + HOST_ERROR, + + /** + * The connection died rather than being closed: idle timeout, reset, the network going away. + * This — and only this — is the "the host may be asleep, wake it" case. + */ + LOST; + + /** + * Is this an ordinary outcome rather than something to alarm the user about? + * + * The question nearly every caller actually asks. [LOCAL], [GAME_EXITED] and [HOST_ENDED] were + * all meant to happen. [NONE] counts as normal — no evidence of trouble is not evidence of it. + */ + val isNormal: Boolean + get() = this != HOST_ERROR && this != LOST + + companion object { + /** + * Decode the JNI byte. An unrecognized value becomes [NONE] rather than throwing: this + * crosses an ABI where the native side may be newer than this code. + */ + fun fromNative(v: Int): SessionEndReason = entries.getOrNull(v) ?: NONE + } +} diff --git a/clients/android/native/src/session/connect.rs b/clients/android/native/src/session/connect.rs index 982400f7..98480361 100644 --- a/clients/android/native/src/session/connect.rs +++ b/clients/android/native/src/session/connect.rs @@ -404,6 +404,31 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSessionEnde }) } +/// `NativeBridge.nativeEndReason(handle): Int` — WHY the session ended, as a +/// `punktfunk_core::client::PunktfunkEndReason` byte (Kotlin mirrors it in `SessionEndReason`). +/// +/// Companion to `nativeSessionEnded`, which only says THAT it ended. Kotlin's watchdog needs both: +/// the flag to leave a dead stream, and this to decide what — if anything — to tell the user. A +/// player quitting their game and a host dropping off the network both end the session, and until +/// this existed the watchdog worded them identically ("the host may be asleep"), which is wrong for +/// every deliberate ending. `0` (NONE) on a `0` handle or before the session ends. Cheap (one +/// atomic load); safe on the UI thread. +#[no_mangle] +pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeEndReason( + _env: JNIEnv, + _this: JObject, + handle: jlong, +) -> jint { + jni_guard(0, || { + if handle == 0 { + return 0; + } + // SAFETY: live handle per the nativeConnect/nativeClose contract. + let h = unsafe { &*(handle as *const SessionHandle) }; + h.client.end_reason() as jint + }) +} + /// `NativeBridge.nativePair(host, port, certPem, keyPem, pin, name): String` — run the SPAKE2 PIN /// ceremony, presenting our persistent identity. On success returns the host's verified fingerprint /// (64-hex) to persist + pin; on any failure (wrong PIN / MITM / host reject / unreachable) returns