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/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/.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: 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..f0c78e66 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 @@ -47,6 +48,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 +63,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. @@ -98,6 +105,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 = { @@ -107,7 +121,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 +142,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 @@ -201,8 +230,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 } @@ -218,11 +255,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/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/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/LibraryScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt index c4d18801..34fd3a9b 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/Settings.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt index 6ebca6f3..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 @@ -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 @@ -424,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 @@ -458,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) } @@ -517,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/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/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/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/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/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 b012a453..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 @@ -355,9 +356,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( @@ -404,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/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/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/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 diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index c28d6add..75bf05b6 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. // @@ -335,6 +349,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/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/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/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/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/PunktfunkClient/Session/SessionModel.swift b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift index 8cdf017f..86dbb4cc 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,36 @@ 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. + 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 - errorMessage = "Session ended by \(name)." + 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 + } + 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)." + } } /// Resize overlay START (main actor — from the Match-window follower's `onResizeTarget`): the 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/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/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/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift index 93d319f0..350b44f6 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift @@ -1430,6 +1430,49 @@ public final class PunktfunkConnection { } } + /// Why a stream session ended — the Swift mirror of `PunktfunkEndReason` (ABI v17). + /// + /// 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/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 { 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/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/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/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/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) + } +} 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..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, @@ -387,7 +406,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
, 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; } 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 907ff9db..7bd78a70 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/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/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 { 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:?}")), } diff --git a/crates/pf-client-core/src/trust.rs b/crates/pf-client-core/src/trust.rs index c05bc820..1aeaecd2 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()) } } @@ -1002,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 @@ -1086,6 +1204,10 @@ fn default_true() -> bool { true } +fn default_ui_palette() -> String { + "violet".into() +} + fn default_pad_speaker() -> String { "pad".into() } @@ -1194,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(), @@ -1940,6 +2063,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 +2077,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 diff --git a/crates/pf-console-ui/src/library.rs b/crates/pf-console-ui/src/library.rs index d6c43e0a..0e7f6cf1 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\ @@ -541,10 +654,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 1e458742..da58255f 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 { 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 bc1a9772..686b6c38 100644 --- a/crates/punktfunk-core/src/abi.rs +++ b/crates/punktfunk-core/src/abi.rs @@ -2273,6 +2273,45 @@ pub unsafe extern "C" fn punktfunk_connection_audio_channels( }) } +/// WHY this session ended: `*out` receives a [`PunktfunkEndReason`] byte +/// (`PUNKTFUNK_END_REASON_*`). The return status reports only whether the handle was usable. +/// +/// 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. +/// +/// **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_end_reason( + 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 = c.inner.end_reason() as u8 }; + } + 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..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,6 +265,9 @@ pub struct NativeClient { /// Speed-test accumulator, shared with the data-plane pump + control task. probe: Arc>, shutdown: 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. @@ -448,6 +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 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())); @@ -463,6 +552,7 @@ impl NativeClient { let host = host.to_string(); let frame_chan_w = frame_chan.clone(); let shutdown_w = shutdown.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(); @@ -538,6 +628,7 @@ impl NativeClient { clip_cmd_rx, ready_tx, shutdown: shutdown_w, + end_reason: end_reason_w, quit: quit_w, mode_slot: mode_slot_w, probe: probe_w, @@ -591,6 +682,7 @@ impl NativeClient { host_caps: negotiated.host_caps, probe, shutdown, + end_reason, quit, worker: Some(worker), frames_dropped, @@ -809,6 +901,29 @@ impl NativeClient { self.shutdown.load(Ordering::SeqCst) } + /// WHY the session ended — see [`PunktfunkEndReason`]. + /// + /// 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: **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. + /// + /// 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.end_reason() == PunktfunkEndReason::GameExited + } + /// 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..8308f2e2 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, + end_reason, quit, mode_slot, probe, @@ -194,12 +195,17 @@ 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 classify WHY. { let shutdown = shutdown.clone(); + let end_reason = end_reason.clone(); let conn = conn.clone(); tokio::spawn(async move { - conn.closed().await; + let why = conn.closed().await; + // 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 35685135..0b8e0fa5 100644 --- a/crates/punktfunk-core/src/client/worker.rs +++ b/crates/punktfunk-core/src/client/worker.rs @@ -68,6 +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, + /// 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 122486f3..60b559dd 100644 --- a/crates/punktfunk-core/src/lib.rs +++ b/crates/punktfunk-core/src/lib.rs @@ -138,7 +138,14 @@ 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_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. /// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface** 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. 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 diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index 847b97ee..9bf9d924 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -76,7 +76,14 @@ // 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_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. // Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface** @@ -1616,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 @@ -2498,6 +2562,28 @@ PunktfunkStatus punktfunk_connection_next_audio(PunktfunkConnection *c, PunktfunkStatus punktfunk_connection_audio_channels(PunktfunkConnection *c, uint8_t *out); #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// WHY this session ended: `*out` receives a [`PunktfunkEndReason`] byte +// (`PUNKTFUNK_END_REASON_*`). The return status reports only whether the handle was usable. +// +// 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. +// +// **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_end_reason(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 diff --git a/packaging/flatpak/io.unom.Punktfunk.yml b/packaging/flatpak/io.unom.Punktfunk.yml index 3d5b75ed..b1d6a017 100644 --- a/packaging/flatpak/io.unom.Punktfunk.yml +++ b/packaging/flatpak/io.unom.Punktfunk.yml @@ -85,28 +85,43 @@ 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 - # 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. - - --env=VK_ADD_IMPLICIT_LAYER_PATH=/usr/lib/extensions/vulkan/gamescope/share/vulkan/implicit_layer.d + # 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) 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 + # --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=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: @@ -180,6 +195,91 @@ 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 + # 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 + 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