Compare commits

..
Author SHA1 Message Date
enricobuehler da3c0308c5 docs(windows): LGPL notice still named n7.1 after the 8.1 bump
The notice backs the written offer of corresponding source in the signed
installer and the MSIX, so the release it names has to match the DLLs the
provisioning script actually ships.
2026-08-05 10:31:16 +02:00
enricobuehler abc6d790bd feat(client): make concealed decodes visible instead of silently erasing them
libavcodec reports reference damage by LOGGING and then concealing: HEVC's `Error
constructing the frame RPS`, `First slice in a frame missing`, `Previous slice
segment missing` (hevcdec.c) and H.264's reference-list equivalents all emit at
AV_LOG_ERROR and then hand back a frame and a success code. Every one of them means
the picture on screen was built from references the decoder could not resolve.

We threw all of it away. `quiet_ffmpeg_log()` set libavcodec's level to fatal-only,
which silences its stderr sink, and we installed no callback — so the messages went
nowhere. Worse, `decode_frame`'s Ok arm then RESET the failure streak, so a decoder
concealing every other frame looked healthier than one erroring occasionally: it
never asked for an IDR, and under the infinite GOP nothing else would, so the damage
stayed for the life of the session.

Now: a real av_log callback routes libavcodec into tracing (these lines are decode
evidence and belong in the log a field report ships us) and counts ERROR-and-worse.
`decode_frame` brackets each AU; a backend that returns a frame while that counter
moved decoded something libavcodec itself called broken, and we ask for a keyframe.

Concealment gets its OWN counter, deliberately not the hardware-demotion streak. An
ordinary packet loss conceals every AU until the requested IDR lands — at 120 fps a
100-300 ms round trip is 12-36 frames, well past VAAPI_DEMOTE_AFTER and past
HW_DEMOTE_MIN_STREAK too if that IDR is itself lost. Feeding it there would demote a
healthy decoder for surviving a lossy second.

Scope, stated plainly: this catches the class libavcodec KNOWS about. It does not
catch a driver that returns wrong pixels without complaint, which is what the
Windows FFmpeg-Vulkan reports look like — and that class has no in-band signal at
all today. Verified against FFmpeg n8.1 source: vulkan_decode.c calls
ff_vk_exec_pool_init(..., nb_queries=0, ...), so VK_QUERY_TYPE_RESULT_STATUS_ONLY_KHR
— the only channel a Vulkan driver has to report a failed decode — is never read;
and neither h264dec.c nor hevcdec.c ever sets AV_FRAME_FLAG_CORRUPT. Closing that
needs an upstream patch, not a client change.

Verified on BOTH platforms, because the callback's va_list parameter is the one part
whose ABI differs and a wrong one faults inside libavcodec at call time rather than
failing to build: Linux 117/117 (linux/amd64 container) + clippy --all-targets
-D warnings clean; Windows 109/109 against FFmpeg n8.1.2 on the CI runner, where
`installing_the_log_callback_is_safe_and_idempotent` drives a real av_log through
libavcodec's dispatcher into our callback.
2026-08-05 09:03:07 +02:00
enricobuehler d026e50a4b build(ci): FFmpeg 7.1 -> 8.1 for the Windows host + client trees
FFmpeg's Vulkan Video hwaccel is the youngest code in our decode chain (merged
around 6.1/7.0) and 7.1 is a stabilisation branch that does not receive its ongoing
fixes. Both field reports of silent inter-frame corruption on Windows — Intel B580
(2026-07) and AMD Xbox Ally X (2026-08) — sit on that hwaccel, while the mature
d3d11va one streams clean on the same boxes. Getting onto current FFmpeg is the
cheapest thing that can move that, and it keeps Vulkan Video as the default rather
than demoting a whole vendor to DXVA.

Not new ground for our API usage: Ubuntu 26.04 already ships avcodec 62 (FFmpeg
8.0.1) and pf-client-core clippies clean there today. ffmpeg-sys-next 8.1.0 accepts
avcodec 56..63, so the binding needs no change.

The presence check is now version-stamped. It used to test only for
`lib\avcodec.lib`, which meant editing the pin here would have done NOTHING on every
already-provisioned runner — CI would have gone on building against 7.1 while this
file said 8.1, and the bump would have looked applied without being applied. A
`.punktfunk-ffmpeg-version` marker, written only after a successful extract, makes
runners re-provision themselves when the pin moves.

Still lgpl-shared (the licensing posture is unchanged) and still SHA-256 pinned
fail-closed; both pins re-captured 2026-08-05 from the current n8.1 assets.

Verified on the CI runner against a SEPARATE tree (C:\temp\ff81 — the canonical
C:\Users\Public\ffmpeg is shared with every other branch's jobs and was left alone):
ffmpeg n8.1.2, `cargo check -p pf-client-core --all-targets` and
`cargo check -p pf-encode --features amf-qsv,qsv --all-targets` both clean.
2026-08-05 08:38:19 +02:00
enricobuehler d6b9462092 fix(client): pad_audio reached for a module name Windows doesn't have
`audio_wasapi.rs` is mounted as `crate::audio` (lib.rs, `#[cfg(windows)]
#[path = "audio_wasapi.rs"] pub mod audio;`), so there is no `crate::audio_wasapi`
path to reach it by. `pad_audio.rs` used one anyway, which means pf-client-core has
not compiled for Windows since 35285afa landed in the pad-audio merge (8983ec04) —
E0433, so every Windows client build on main is red, not just this call site.

Found while validating the FFmpeg bump on the CI runner; unrelated to it, hence its
own commit.
2026-08-05 08:38:05 +02:00
237 changed files with 2294 additions and 15557 deletions
-68
View File
@@ -1,68 +0,0 @@
#!/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 <image> <content-key>}"
KEY="${2:?usage: reconcile-latest.sh <image> <content-key>}"
: "${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:-<no :latest tag>}"
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"
+2 -19
View File
@@ -41,23 +41,9 @@ jobs:
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
UPDATE_MANIFEST_KEY: ${{ secrets.UPDATE_MANIFEST_KEY }}
# Through the ENVIRONMENT, never interpolated into the script body. A `${{ }}` expansion
# is a raw textual substitution performed BEFORE the shell sees the line, so a
# workflow_dispatch input containing shell syntax executes as this step — and this is the
# step holding UPDATE_MANIFEST_KEY, the Ed25519 key every host pins to decide whether an
# update is real (2026-08-05 review H-6). As `$INPUT_TAG` it is only ever data.
INPUT_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
TAG="$INPUT_TAG"
# Shape-check before the value reaches a URL or a filename: tags are `vX.Y.Z[-suffix]`.
case "$TAG" in
v[0-9]*) ;;
*) echo "refusing to publish for a tag that is not vX.Y.Z: $TAG" >&2; exit 1 ;;
esac
case "$TAG" in
*[!A-Za-z0-9.+_-]*) echo "tag has characters no release tag has: $TAG" >&2; exit 1 ;;
esac
TAG="${{ inputs.tag }}"
case "$TAG" in
*-*) echo "pre-release tag $TAG — not publishing to the stable update feed"; exit 0 ;;
esac
@@ -81,7 +67,4 @@ jobs:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
DISCORD_RELEASE_WEBHOOK: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
ALLOW_PRERELEASE: ${{ inputs.allow_prerelease }}
# Same reasoning as the publish step above: the input is data in the environment, never
# text spliced into the command line.
INPUT_TAG: ${{ inputs.tag }}
run: bash scripts/ci/discord-announce.sh "$INPUT_TAG"
run: bash scripts/ci/discord-announce.sh "${{ inputs.tag }}"
+1 -6
View File
@@ -29,9 +29,4 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Tier-3 GPU stream benchmark
# Through the environment, not interpolated into the command line: a `${{ }}` expansion is
# substituted before the shell parses the line, so an input carrying shell syntax would run
# as this step (2026-08-05 review H-6).
env:
BENCH_MODE: ${{ inputs.mode || '1920x1080x120' }}
run: bash scripts/bench/gpu-stream.sh "$BENCH_MODE" 12
run: bash scripts/bench/gpu-stream.sh "${{ inputs.mode || '1920x1080x120' }}" 12
-28
View File
@@ -275,31 +275,3 @@ jobs:
run: bun run build
- name: Typecheck
run: bun run lint
# web/bun.nix and sdk/bun.nix are GENERATED from their bun.lock (bun2nix) and committed; the Nix
# build fetches node_modules from nothing else. They regenerate only on a local `bun install` that
# runs lifecycle scripts — never under CI's `--ignore-scripts`, and never on a merge or rebase,
# which happily carries a lockfile change past a bun.nix generated before it. That is not
# theoretical: web/bun.nix sat stale on main for 553 commits (2026-07-27 → 2026-08-05) with
# `nix build .#punktfunk-web` broken, and was repaired only by accident when an advisory bump
# happened to rerun a real `bun install`.
#
# Deliberately UNFILTERED and in ci.yml rather than nix.yml: it needs no Nix, takes well under a
# minute, and the whole point is that the drift arrives through commits that look unrelated to
# Nix. The Nix-toolchain gates (flake eval + building the bun packages) live in nix.yml.
bun-nix:
runs-on: ubuntu-24.04
container:
image: oven/bun:1
timeout-minutes: 15
steps:
# oven/bun ships neither git nor a real node, and the slim base has no CA bundle —
# actions/checkout needs all three (see the web job).
- name: Install git + node + CA certs
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
- uses: actions/checkout@v4
# Regenerates each bun.nix from its committed bun.lock and diffs, and checks that the
# bun2nix version pin agrees across flake.nix and both package.json files (bun.nix has no
# schema stability across bun2nix releases). Fix with: scripts/ci/check-bun-nix.sh --fix
- name: bun.nix drift gate
run: sh scripts/ci/check-bun-nix.sh
+1 -4
View File
@@ -46,10 +46,7 @@ env:
REGISTRY: git.unom.io
OWNER: unom
PACKAGE: punktfunk-decky # generic-registry package name
# 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
PLUGIN: punktfunk # plugin.json "name" == zip top-level dir
jobs:
build-publish:
+21 -107
View File
@@ -3,18 +3,13 @@
# 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 — 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.
# 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.
#
# 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
@@ -22,38 +17,8 @@
#
# 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).
# 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.
#
# --- 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 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.
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (app images only —
# the LAN registry is unauthenticated inside the LAN).
#
# Bootstrap note: consuming workflows pull <LAN>/punktfunk-rust-ci:latest, so the LAN
# registry must hold a seeded :latest once (done 2026-07-29 from the last Gitea-registry
@@ -77,10 +42,7 @@ 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:
@@ -136,40 +98,21 @@ 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_PUSH/${{ matrix.image }}:$KEY" \
-t "$CI_REGISTRY_PUSH/${{ matrix.image }}:latest" \
-t "$CI_REGISTRY/${{ matrix.image }}:$KEY" \
-t "$CI_REGISTRY/${{ 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_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 }}
docker push "$CI_REGISTRY/${{ matrix.image }}:$KEY"
docker push "$CI_REGISTRY/${{ matrix.image }}:latest"
# 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).
@@ -181,19 +124,8 @@ 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 -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
curl -sf -X PUT -H "Content-Type: $MT" --data-binary @/tmp/manifest.json \
"http://$CI_REGISTRY/v2/${{ matrix.image }}/manifests/$GITHUB_REF_NAME"
# 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
@@ -232,26 +164,15 @@ jobs:
run: |
docker build --pull \
-f ci/rust-ci-arm64cross.Dockerfile \
-t "$CI_REGISTRY_PUSH/$IMAGE:$KEY" \
-t "$CI_REGISTRY_PUSH/$IMAGE:latest" \
-t "$CI_REGISTRY/$IMAGE:$KEY" \
-t "$CI_REGISTRY/$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_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 }}
docker push "$CI_REGISTRY/$IMAGE:$KEY"
docker push "$CI_REGISTRY/$IMAGE:latest"
- name: Tag for release
if: startsWith(github.ref, 'refs/tags/v')
@@ -261,15 +182,8 @@ 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 -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
curl -sf -X PUT -H "Content-Type: $MT" --data-binary @/tmp/manifest.json \
"http://$CI_REGISTRY/v2/$IMAGE/manifests/$GITHUB_REF_NAME"
# Deployable app images — unchanged flow, Gitea registry, per-SHA + release tags.
apps:
-167
View File
@@ -1,167 +0,0 @@
# Nix packaging gate. Until this existed, NOTHING in CI ever evaluated flake.nix: the word "nix"
# appeared in exactly one workflow file, and only in a comment about bun2nix breaking a Windows
# step. Every Nix regression therefore reached main invisibly and was found by hand on a Nix box —
# `nix build .#punktfunk-web` was broken for 553 commits before anyone noticed (see the bun-nix job
# in ci.yml for that story).
#
# Two tiers, because a full `nix flake check` builds the whole Rust workspace with crane and would
# run for an hour on every push:
#
# * eval — `nix flake check --no-build`: instantiates every package, app, check, devShell and
# the NixOS module without building them. Catches the failures that actually happen to
# this flake — a renamed file, a callPackage argument that no longer exists, a syntax
# error, a package attribute dropped from packages.nix.
# * bun — actually BUILDS punktfunk-web + punktfunk-scripting. These are the two derivations
# whose inputs churn constantly (every dependency bump moves a lockfile) and they cost
# minutes, not hours, because neither compiles Rust. This is the end-to-end proof that
# the generated bun.nix really does materialise a working node_modules offline — it
# covers what the ci.yml drift gate cannot, e.g. a tarball the registry no longer
# serves, or the codegen going quietly message-less (see packages.nix's inlang note).
#
# The Rust packages (punktfunk-host, punktfunk-client) and punktfunk-gamescope are NOT built here.
# They are the expensive ones and their inputs are already gated by the `rust` job in ci.yml; build
# them by hand on a Nix box, or with the `build-rust` dispatch input below.
#
# ⚠ pull_request is deliberately present. flatpak.yml shipped with push-only triggers and manifest
# breakage reached main invisibly for weeks — do not "simplify" this workflow by dropping it.
# ⚠ The two path lists are duplicated on purpose: a YAML anchor would be tidier, but Gitea's
# workflow parser is not a place to bet on anchor support. Keep them in step by hand.
name: nix
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: [main]
paths:
- "flake.nix"
- "flake.lock"
- "packaging/nix/**"
- "**/bun.lock"
- "**/bun.nix"
- "**/package.json"
- "Cargo.lock"
- "Cargo.toml"
- "rust-toolchain.toml"
- ".gitea/workflows/nix.yml"
- "scripts/ci/check-bun-nix.sh"
pull_request:
paths:
- "flake.nix"
- "flake.lock"
- "packaging/nix/**"
- "**/bun.lock"
- "**/bun.nix"
- "**/package.json"
- "Cargo.lock"
- "Cargo.toml"
- "rust-toolchain.toml"
- ".gitea/workflows/nix.yml"
- "scripts/ci/check-bun-nix.sh"
workflow_dispatch:
inputs:
build-rust:
description: "Also build punktfunk-host + punktfunk-client (slow: full Rust workspace)"
type: boolean
default: false
jobs:
flake:
runs-on: ubuntu-24.04
container:
# NOT nixos/nix. That image contains nix and essentially nothing else — in particular no
# /bin/sleep, and Gitea's act_runner starts every job container with
# `entrypoint=["/bin/sleep","10800"]`. The container therefore never starts:
# failed to create shim task: OCI runtime create failed: unable to start container
# process: exec: "/bin/sleep": stat /bin/sleep: no such file or directory
# and — the part that makes this expensive to debug — every step is then reported as
# `cancelled` rather than failed, which reads exactly like a superseded run.
#
# node:22-bookworm instead: a full Debian with coreutils (so the entrypoint exists) and a
# real node (so actions/checkout works with no pre-checkout install dance), and audit.yml
# already pulls it on this fleet, so it is proven to resolve here. Nix is installed below.
image: node:22-bookworm
timeout-minutes: 90
env:
# The flake needs both experimental features. Also baked into the installer's --extra-conf
# below; this covers any step that shells out before that config is read.
NIX_CONFIG: "experimental-features = nix-command flakes"
# Absolute path rather than $GITHUB_PATH: one less runner behaviour to assume.
NIX: /nix/var/nix/profiles/default/bin/nix
# `--init none` installs Nix with NO daemon running, but the installer still writes a profile
# script that exports NIX_REMOTE=daemon. Anything that sources it (any `-l` login shell) then
# dies on `cannot connect to socket at '/nix/var/nix/daemon-socket/socket'` — which is exactly
# how the installer's own self-test fails during this step, harmlessly, and would be a
# confusing first thing to read in the log. The steps below never source that profile, but pin
# the empty value so a future step cannot reintroduce it. Empty = talk to the local store
# directly, which works because the job runs as root (MEASURED: "Store URL: local, Trusted: 1",
# and a real `nix build` of a trivial derivation succeeds).
NIX_REMOTE: ""
steps:
- uses: actions/checkout@v4
# The Determinate installer needs curl + xz; git so nix can read the flake from the checkout.
# (node:22-bookworm is the full image and already has all three — this is belt-and-braces
# against a future slim-image swap, and costs one cached apt call.)
- name: Installer prerequisites
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates curl xz-utils git
# `--init none` is the container mode: no systemd, no daemon. Running as root, nix then talks
# to the store directly. Determinate Nix is also what the Nix box (.21) runs, so CI and the
# hand-verification box stay on the same distribution.
- name: Install Nix
run: |
curl -fsSL https://install.determinate.systems/nix -o /tmp/nix-installer.sh
sh /tmp/nix-installer.sh install linux --init none --no-confirm \
--extra-conf "experimental-features = nix-command flakes"
"$NIX" --version
# Nix reads the flake through libgit2 and refuses a checkout owned by another uid
# ("detected dubious ownership"), which is the normal case for a container job.
- name: Trust the checkout
run: git config --global --add safe.directory "$PWD"
# Diagnostics. This fleet ran a runner out of disk on 2026-08-06 (the ci.yml `web` job died
# with "no space left on device" mid-`bun install`), and a Nix build is the heaviest thing
# here — so record the headroom, or a future failure is a guess.
- name: Environment
run: df -h / /nix /tmp || true
# Evaluates + instantiates every flake output without building any of it.
- name: nix flake check (eval only)
run: |
"$NIX" flake check --no-build --show-trace
# The bun packages, built for real. This is the leg that would have caught the stale
# web/bun.nix end to end: the derivation's offline `bun install` runs against a store cache
# built strictly from bun.nix, so a lockfile that cache does not cover fails here.
# Path-filtered, so it runs only when the packaging or a lockfile actually moves. If it ever
# starts going red on runner disk rather than on real defects, demote it to the dispatch
# opt-in below rather than leaving an infra-red gate on the board.
- name: Build the bun packages
run: |
"$NIX" build --print-build-logs .#punktfunk-web .#punktfunk-scripting
# Both launchers exec pkgs.bun from the store; confirm they were produced and are real entry
# points rather than dangling wrappers.
- name: Smoke the built launchers
run: |
set -eu
web=$("$NIX" path-info .#punktfunk-web)
scripting=$("$NIX" path-info .#punktfunk-scripting)
test -x "$web/bin/punktfunk-web-server" || { echo "no punktfunk-web-server in $web" >&2; exit 1; }
test -x "$scripting/bin/punktfunk-scripting" || { echo "no punktfunk-scripting in $scripting" >&2; exit 1; }
# The console must be the bun bundle, not a node one — the same assertion packages.nix
# makes at build time, re-checked on the installed output.
grep -q 'Bun\.serve' "$web/share/punktfunk-web/.output/server/index.mjs" \
|| { echo "installed console is not a bun bundle" >&2; exit 1; }
echo "bun packages OK: $web $scripting"
# Opt-in only: the full Rust workspace through crane, which is the hour-long leg.
# `github.event.inputs.*` (string) rather than `inputs.*` — the portable spelling.
- name: Build the Rust packages (dispatch opt-in)
if: ${{ github.event.inputs.build-rust == 'true' }}
run: |
"$NIX" build --print-build-logs .#punktfunk-host .#punktfunk-client
+2 -10
View File
@@ -38,18 +38,10 @@ jobs:
with:
fetch-depth: 0
# Pinned syft (keep in sync with the version validated against this repo; bump deliberately).
#
# The BINARY version was pinned; the INSTALLER was not — it was fetched from `main` and piped
# into a shell, so whatever that branch happened to say at job time ran here, with the job's
# environment (2026-08-05 review H-6). Pinning the script to the same tag as the binary makes
# the whole step reproducible: bump the tag in both places together.
- name: Install syft
env:
SYFT_VERSION: v1.49.0
run: |
set -euo pipefail
curl -sSfL "https://raw.githubusercontent.com/anchore/syft/${SYFT_VERSION}/install.sh" \
| sh -s -- -b /usr/local/bin "$SYFT_VERSION"
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh \
| sh -s -- -b /usr/local/bin v1.49.0
- name: Generate SBOM
run: |
git config --global --add safe.directory "$PWD"
+4 -2
View File
@@ -15,8 +15,10 @@
# target with host tools, so no ARM64 runner is needed — the cc/cmake crates pick the ARM64
# compiler from the target triple (SDL3 + libopus build-from-source cross-compile fine). The one
# arch-specific external dep is FFmpeg's import libs: the runner keeps an x64 tree at
# C:\Users\Public\ffmpeg and an ARM64 tree at C:\Users\Public\ffmpeg-arm64 (both FFmpeg 7.x /
# avcodec-61); the matrix points FFMPEG_DIR at the right one. aarch64 can't *run* on the x64 host,
# C:\Users\Public\ffmpeg and an ARM64 tree at C:\Users\Public\ffmpeg-arm64 (both FFmpeg 8.1 /
# avcodec-62 — version pinned in scripts/ci/provision-windows-punktfunk-extras.ps1, which
# re-provisions a runner automatically when that pin moves); the matrix points FFMPEG_DIR at the
# right one. aarch64 can't *run* on the x64 host,
# so fmt + test run only for x64.
#
# The MSVC/WinUI/FFmpeg toolchain (cargo/rustup on ASCII paths, NASM, CMake, LLVM, the x64 FFmpeg,
+9 -157
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.24.0"
"version": "0.23.0"
},
"paths": {
"/api/v1/clients": {
@@ -1052,7 +1052,7 @@
"library"
],
"summary": "Fetch one cover-art image for a library entry",
"description": "Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams\nthe image bytes. Any id stored in the host's catalog (manual entries, provider-synced entries,\nand a library plugin's claimed-store entries) serves its local art file. A Steam title falls back\nto the in-host scanner's resolver: the host's own local Steam cache first (exact — it's what the\nuser's Steam client already shows for it), the public Steam CDN's flat URL convention second\n(newer titles' CDN assets can live at a per-asset-hash path the host can't predict, in which case\nthis 404s and the client falls through to its next art candidate).",
"description": "Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams\nthe image bytes. For a Steam title, the host's own local Steam cache is tried first (exact —\nit's what the user's Steam client already shows for it), the public Steam CDN's flat URL\nconvention as a fallback (newer titles' CDN assets can live at a per-asset-hash path the host\ncan't predict, in which case this 404s and the client falls through to its next art candidate).\nOnly Steam ids are backed today; any other store 404s.",
"operationId": "getLibraryArt",
"parameters": [
{
@@ -1307,7 +1307,7 @@
"library"
],
"summary": "Replace a provider's library entries (declarative reconcile)",
"description": "Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the\nprovider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each\nsurviving title's host id stable across reconciles, drops orphans, and never touches manual\nentries or other providers'. An empty array removes everything the provider owns. Emits\n`library.changed` with the provider as `source`.\n\n`?store=` additionally **claims** that store for the provider: its entries then surface with\ndeterministic `<store>:<external_id>` ids and the store's own badge, instead of opaque\n`custom:<id>` ones — which is what lets a library plugin reproduce the entries an in-host scanner\nused to produce, right down to the GameStream app ids and client-side art caches. One provider\nper store; a second claimant gets 409. While a claim is held the matching built-in scanner is\nsuppressed, so the two never double-list. The claim is released by `DELETE`, not by an empty\nreconcile (a store can legitimately have zero installed titles).",
"description": "Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the\nprovider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each\nsurviving title's host id stable across reconciles, drops orphans, and never touches manual\nentries or other providers'. An empty array removes everything the provider owns. Emits\n`library.changed` with the provider as `source`.",
"operationId": "reconcileProviderEntries",
"parameters": [
{
@@ -1318,15 +1318,6 @@
"schema": {
"type": "string"
}
},
{
"name": "store",
"in": "query",
"description": "Claim this store for the provider ([a-z0-9_-], `custom`/`manual` reserved)",
"required": false,
"schema": {
"type": "string"
}
}
],
"requestBody": {
@@ -1357,7 +1348,7 @@
}
},
"400": {
"description": "Invalid provider id, store id, or payload",
"description": "Invalid provider id or payload",
"content": {
"application/json": {
"schema": {
@@ -1376,16 +1367,6 @@
}
}
},
"409": {
"description": "That store is already claimed by another provider",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"500": {
"description": "Could not persist the catalog",
"content": {
@@ -4178,8 +4159,7 @@
"tier",
"platforms",
"compatible",
"update_available",
"categories"
"update_available"
],
"properties": {
"author": {
@@ -4192,13 +4172,6 @@
],
"description": "A revocation covering the catalogued version — do not offer this without shouting."
},
"categories": {
"type": "array",
"items": {
"type": "string"
},
"description": "What kind of plugin this is — the console filters Browse by these, and the Game sources\nsurface's \"Add a source\" rail shows exactly the `library` ones (design D5/D6)."
},
"compatible": {
"type": "boolean",
"description": "Can this host install it?"
@@ -4206,13 +4179,6 @@
"description": {
"type": "string"
},
"detected": {
"type": [
"boolean",
"null"
],
"description": "Whether the launcher this plugin scans looks **installed on this host** (design D8), from the\nindex's own existence probes. `null` = the entry declares no probes for this platform, which\nthe console renders as \"unknown\" rather than \"not installed\"."
},
"homepage": {
"type": [
"string",
@@ -4399,17 +4365,6 @@
],
"description": "The external provider owning this entry (RFC §8), set ONLY by the provider reconcile\nAPI — `None` = a manual entry, which no provider operation ever touches, and which the\nmanual CRUD alone may edit (the converse holds too: manual CRUD refuses provider-owned\nentries, so ownership is never ambiguous)."
},
"role": {
"$ref": "#/components/schemas/GameRole",
"description": "Whether this entry is a game or the launcher itself — see [`GameRole`]."
},
"store": {
"type": [
"string",
"null"
],
"description": "The **store this entry was claimed under** (D2), stamped by a `?store=`-qualified reconcile.\n`None` = an unclaimed provider entry or a manual one, both of which surface as `custom`.\n\nMaterialized onto the entry rather than looked up in [`Catalog::claims`] on every read so an\nentry is self-describing: its id and its `store` badge derive from the entry alone, and stay\ncorrect even while the claim map is being rewritten."
},
"title": {
"type": "string"
}
@@ -4454,10 +4409,6 @@
},
"description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config."
},
"role": {
"$ref": "#/components/schemas/GameRole",
"description": "Whether this entry is a game or the launcher itself — see [`GameRole`]. A hand-added launcher\nentry is legal (an operator may want a \"Steam\" tile without installing the steam plugin)."
},
"title": {
"type": "string"
}
@@ -4516,17 +4467,6 @@
"type": "object",
"description": "What an operator (or a provider plugin) can tell the host about recognizing a title — the wire\nhalf of [`DetectSpec`], and the only part of it that is ever accepted from outside.\n\nDeliberately a **subset**: the store-derived signals (a Steam appid, a launcher's environment\nmarker) are things the host discovers for itself and would be meaningless — or dangerous — to take\non someone's word. What is left is what a provider genuinely knows and the host cannot guess: where\nthe title is installed, which executable is the game, what the process is called. All three are\noptional; supplying none is the same as supplying no hint at all.\n\nNever returned by the catalog API — see the module docs on why detect data does not cross the wire\noutbound.",
"properties": {
"env_marker": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/EnvMarker",
"description": "A launcher-stamped environment marker (D3) — see [`EnvMarker`]."
}
]
},
"exe": {
"type": [
"string",
@@ -4547,15 +4487,6 @@
"null"
],
"description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]."
},
"steam_appid": {
"type": [
"integer",
"null"
],
"format": "int32",
"description": "The Steam appid, for a title Steam itself installed (D3). On Linux this is the **sharpest**\nsignal that exists — Steam wraps every launch, native or Proton, in\n`reaper SteamLaunch AppId=<appid>`, whose lifetime is exactly the game's — so without it a\nsteam plugin's lease tracking would degrade from reaper-exact to install-dir prefix matching.",
"minimum": 0
}
}
},
@@ -4784,27 +4715,6 @@
}
}
},
"EnvMarker": {
"type": "object",
"description": "An environment variable a launcher stamps onto the game's process, identifying it.\n\nSerializable because it is now half of the inbound [`DetectHint`] too (D3) — a library plugin\nthat knows its launcher's marker (Heroic's `HEROIC_APP_NAME`, load-bearing under Proton) has to\nbe able to say so, since after extraction the host no longer reads that launcher's files itself.",
"required": [
"key"
],
"properties": {
"key": {
"type": "string",
"description": "The variable name (e.g. `HEROIC_GAME_ID`).",
"example": "HEROIC_APP_NAME"
},
"value": {
"type": [
"string",
"null"
],
"description": "The exact value to require, when the launcher's value identifies *this* title. `None` matches\nthe key's mere presence — only safe for launchers that run one game at a time."
}
}
},
"EventKind": {
"oneOf": [
{
@@ -5255,10 +5165,6 @@
],
"description": "The external provider owning this entry (custom-store entries synced by a provider\nplugin, RFC §8) — `None` for installed-store titles and manual custom entries. The\nconsole uses it for attribution; `GET /library?provider=` filters on it."
},
"role": {
"$ref": "#/components/schemas/GameRole",
"description": "Whether this entry is a game or the launcher itself — see [`GameRole`]."
},
"store": {
"type": "string",
"description": "Which store surfaced it: `\"steam\"` or `\"custom\"`.",
@@ -5390,14 +5296,6 @@
}
}
},
"GameRole": {
"type": "string",
"description": "What a library entry *is* — an ordinary title, or the launcher application itself (Steam Big\nPicture, Heroic, Playnite fullscreen). Purely a presentation hint: a launcher entry launches,\nleases and lists exactly like a game (design D4), and clients that don't know the field render it\nas a plain tile. Serde-default `game` and skip-serialized when default, so the wire is unchanged\nfor every entry that doesn't opt in.",
"enum": [
"game",
"launcher"
]
},
"GameSession": {
"type": "string",
"description": "How a session that **launches a game** (a library id on the Hello / apps.json / Decky pin) is\nserved (`design/gamemode-and-dedicated-sessions.md` §5.2). Orthogonal to the preset/lifecycle axes\n— a top-level [`DisplayPolicy`] field, NOT part of [`EffectivePolicy`], so a preset never clobbers\nit. Linux-only in effect (a launching Windows session opens into the one desktop).",
@@ -6436,13 +6334,6 @@
"title"
],
"properties": {
"category": {
"type": [
"string",
"null"
],
"description": "What KIND of plugin this is (`^[a-z][a-z0-9-]{0,31}$`), top-level rather than under `ui`\nbecause it describes the plugin, not its surface. The console knows one value today —\n`library` — which it filters **out of the nav**: six installed scanner plugins would otherwise\nflood the sidebar, and their real entry point is the Game sources surface (design D5). A\nlibrary plugin that genuinely wants its own page (rom-manager, which is much more than a\nscanner) simply omits the category."
},
"title": {
"type": "string",
"description": "Human-readable title for the console nav entry (164 chars; control chars stripped)."
@@ -6475,13 +6366,6 @@
"title"
],
"properties": {
"category": {
"type": [
"string",
"null"
],
"description": "The plugin's kind — see [`PluginRegistration::category`]."
},
"id": {
"type": "string"
},
@@ -6720,10 +6604,6 @@
},
"description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config."
},
"role": {
"$ref": "#/components/schemas/GameRole",
"description": "Whether this entry is a game or the launcher itself — see [`GameRole`]. A library plugin\nemits its `launchers(cfg)` entries with `role: \"launcher\"`."
},
"title": {
"type": "string"
}
@@ -6900,46 +6780,26 @@
},
"ScannerInfo": {
"type": "object",
"description": "One **game source** on this host, with its enable state — the unit the console renders a toggle\nfor. A source is either a scanner compiled into this build or a plugin that reconciles entries in\n(WP2.6); the console treats them identically, which is what makes the extraction invisible.",
"description": "One installed-store scanner this host build supports, with its enable state — the unit the\nconsole renders a toggle for. The list is platform-gated at compile time (the scanners are),\nso the console never shows a toggle that cannot do anything on this host.",
"required": [
"id",
"label",
"enabled",
"origin"
"enabled"
],
"properties": {
"enabled": {
"type": "boolean",
"description": "Whether this host runs the source (default true)."
},
"entries": {
"type": [
"integer",
"null"
],
"description": "How many entries this source currently contributes. `None` for a built-in scanner, whose\ncount would mean walking every launcher's files just to render a toggle.",
"minimum": 0
"description": "Whether this host runs the scanner (default true)."
},
"id": {
"type": "string",
"description": "Stable source id — the same string this source's entries carry in their `store` field. For a\nplugin source it is also its provider id and its store claim: one string, by construction, so\na user's disabled state survives a built-in scanner being replaced by its plugin.",
"description": "Stable scanner id — the same string the scanner's entries carry in their `store` field.",
"example": "steam"
},
"label": {
"type": "string",
"description": "Human-facing name for the console toggle.",
"example": "Steam"
},
"origin": {
"$ref": "#/components/schemas/SourceOrigin",
"description": "Where the source comes from: `builtin` (a scanner in this host build) or `plugin`."
},
"provider": {
"type": [
"string",
"null"
],
"description": "The provider id backing a `plugin` source — absent for a built-in scanner."
}
}
},
@@ -7102,14 +6962,6 @@
}
}
},
"SourceOrigin": {
"type": "string",
"description": "Where a [`ScannerInfo`] comes from.",
"enum": [
"builtin",
"plugin"
]
},
"SourceView": {
"type": "object",
"description": "A configured catalog source and how its last refresh went.",
@@ -31,7 +31,6 @@ 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
@@ -48,7 +47,6 @@ 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
@@ -63,11 +61,6 @@ 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<ActiveSession?>(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<String?>(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.
@@ -105,15 +98,6 @@ 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.
val palette = GamepadPalette.named(settings.uiPalette)
CompositionLocalProvider(
LocalGamepadPalette provides palette,
LocalGamepadInk provides GamepadInk.of(palette),
) {
AnimatedContent(
targetState = session,
transitionSpec = {
@@ -123,20 +107,7 @@ fun App(forceGamepadUi: Boolean = false) {
) { active ->
if (active != null) {
// Immersive: the stream takes the whole screen, no bottom bar.
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
}
StreamScreen(active, onDisconnect = { session = null })
} else if (gamepadUi) {
GamepadShell(
settings = settings,
@@ -144,8 +115,6 @@ 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
@@ -232,16 +201,8 @@ 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 }
@@ -257,32 +218,11 @@ 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<io.unom.punktfunk.kit.security.KnownHost?>(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 /
@@ -191,7 +191,6 @@ internal fun ConnectTakeover(
onCancel: () -> Unit,
onRetry: () -> Unit,
) {
val ink = LocalGamepadInk.current
val copy = connectCopy(phase)
val timedOut = phase is ConnectPhase.WakeTimedOut
@@ -213,7 +212,7 @@ internal fun ConnectTakeover(
Icon(
Icons.Filled.Bedtime,
contentDescription = null,
tint = ink.fg(0.9f),
tint = Color.White.copy(alpha = 0.9f),
modifier = Modifier.size(46.dp),
)
}
@@ -222,14 +221,14 @@ internal fun ConnectTakeover(
}
Text(
copy.title,
color = ink.fg,
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 24.sp,
textAlign = TextAlign.Center,
)
Text(
copy.subtitle,
color = ink.fg(0.65f),
color = Color.White.copy(alpha = 0.65f),
fontSize = 14.sp,
textAlign = TextAlign.Center,
fontFamily = if (copy.monoSubtitle) FontFamily.Monospace else FontFamily.Default,
@@ -250,7 +249,6 @@ internal fun ConnectTakeover(
*/
@Composable
private fun PulsingSpinner() {
val ink = LocalGamepadInk.current
val transition = rememberInfiniteTransition(label = "connectPulse")
val pulse by transition.animateFloat(
initialValue = 0f,
@@ -264,14 +262,14 @@ private fun PulsingSpinner() {
for (i in 0..1) {
val p = (pulse + i * 0.5f) % 1f
drawCircle(
color = ink.accent.copy(alpha = (1f - p) * 0.35f),
color = Color(0xFF8678F5).copy(alpha = (1f - p) * 0.35f),
radius = maxR * (0.42f + p * 0.58f),
style = Stroke(width = 2.dp.toPx()),
)
}
}
CircularProgressIndicator(
color = ink.fg,
color = Color.White,
strokeWidth = 3.dp,
modifier = Modifier.size(54.dp),
)
@@ -168,7 +168,8 @@ 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.restart()
discovery.stop()
discovery.start()
} else {
lnpPrompt = true // rationale + "Open settings" (a permanently-denied request returns instantly)
}
@@ -190,27 +191,12 @@ 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 ->
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 -> {}
if (event == Lifecycle.Event.ON_RESUME && !lnpGranted && hasLocalNetworkPermission(context)) {
lnpGranted = true
lnpPrompt = false
discovery.stop()
discovery.start()
}
}
lifecycle?.addObserver(obs)
@@ -1023,28 +1009,20 @@ 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.
// 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) {
if (lnpGranted && !connecting && discovered.isEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
if (discovered.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Spacer(Modifier.width(8.dp))
Text(
"Searching the local network…",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(8.dp))
}
TextButton(onClick = { discovery.restart() }) { Text("Scan again") }
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,
)
}
}
}
@@ -79,7 +79,6 @@ fun GamepadAddHostScreen(
suggestedMacs: List<String> = emptyList(),
onSave: ((KnownHost) -> Unit)? = null,
) {
val ink = LocalGamepadInk.current
val context = LocalContext.current
val isTv = remember { isTvDevice(context) }
val isEdit = editHost != null
@@ -246,7 +245,7 @@ fun GamepadAddHostScreen(
Text(
"Hosts on this network appear automatically — add one by address for everything else.",
style = MaterialTheme.typography.bodyMedium,
color = ink.fg(0.55f),
color = Color.White.copy(alpha = 0.55f),
modifier = Modifier.widthIn(max = 520.dp).padding(bottom = 8.dp),
)
}
@@ -307,7 +306,6 @@ private fun TvAddHostForm(
onAdd: () -> Unit,
onDismiss: () -> Unit,
) {
val ink = LocalGamepadInk.current
BackHandler(onBack = onDismiss)
val firstFocus = remember { FocusRequester() }
Box(Modifier.fillMaxSize()) {
@@ -321,11 +319,11 @@ private fun TvAddHostForm(
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = ink.fg)
Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = Color.White)
Text(
"Hosts on this network appear automatically — add one by address for everything else.",
style = MaterialTheme.typography.bodyMedium,
color = ink.fg(0.55f),
color = Color.White.copy(alpha = 0.55f),
)
OutlinedTextField(
value = name, onValueChange = onName, singleLine = true,
@@ -364,7 +362,6 @@ private fun rowCols(row: Int): Int = if (row < KB_ACTIONS_ROW) KB_CHAR_ROWS[row]
@Composable
private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -> Unit) {
val ink = LocalGamepadInk.current
val visuals = animateConsoleFocus(active = focused || editing, editing = editing)
val shape = RoundedCornerShape(14.dp)
Row(
@@ -378,26 +375,25 @@ private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(f.label, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, color = ink.fg)
Text(f.label, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, color = Color.White)
Spacer(Modifier.weight(1f))
Text(
f.value.ifEmpty { f.placeholder },
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
color = if (f.value.isEmpty()) ink.fg(0.35f) else ink.fg,
color = if (f.value.isEmpty()) Color.White.copy(alpha = 0.35f) else Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (editing) Text(" |", color = ink.accent)
if (editing) Text(" |", color = Color(0xFF8678F5))
}
}
@Composable
private fun AddActionRow(label: String, enabled: Boolean, focused: Boolean, onClick: () -> Unit) {
val ink = LocalGamepadInk.current
val visuals = animateConsoleFocus(active = focused)
val shape = RoundedCornerShape(14.dp)
val labelColor by animateColorAsState(
if (enabled) ink.accent else ink.fg(0.35f),
if (enabled) Color(0xFF8678F5) else Color.White.copy(alpha = 0.35f),
tween(160),
label = "addLabel",
)
@@ -429,7 +425,6 @@ private fun KeyboardGrid(
bottomInset: Dp = 0.dp, // empty frame at the bottom of the glass for the floating legend to sit over
onKey: (Int, Int) -> Unit,
) {
val ink = LocalGamepadInk.current
val shape = RoundedCornerShape(20.dp)
val gap = if (compact) 5.dp else 7.dp
Column(
@@ -438,7 +433,7 @@ private fun KeyboardGrid(
.widthIn(max = 640.dp)
.clip(shape)
.background(Color(0x1FFFFFFF))
.border(1.dp, ink.fg(0.12f), shape)
.border(1.dp, Color.White.copy(alpha = 0.12f), shape)
.padding(start = 12.dp, end = 12.dp, top = if (compact) 8.dp else 12.dp, bottom = 12.dp + bottomInset),
verticalArrangement = Arrangement.spacedBy(gap),
) {
@@ -459,15 +454,14 @@ private fun KeyboardGrid(
@Composable
private fun Keycap(label: String, focused: Boolean, compact: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) {
val ink = LocalGamepadInk.current
// Fast tweens: the keyboard cursor hops many keys per second under hold-to-repeat, so the
// trailing key must have faded before the cursor is two keys away — quick, but no longer a snap.
val bg by animateColorAsState(
if (focused) ink.accent else ink.glass,
if (focused) Color(0xFF8678F5) else Color(0x14FFFFFF),
tween(90),
label = "keyBg",
)
val fg by animateColorAsState(if (focused) Color.Black else ink.fg, tween(90), label = "keyFg")
val fg by animateColorAsState(if (focused) Color.Black else Color.White, tween(90), label = "keyFg")
Box(
modifier = modifier
.height(if (compact) 34.dp else 44.dp)
@@ -14,8 +14,6 @@ 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
@@ -25,9 +23,6 @@ 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
@@ -36,9 +31,7 @@ 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
@@ -72,12 +65,9 @@ import kotlin.math.sin
// connected-controller status chip. One look across every screen is what makes the console UI read
// as a coherent mode rather than a set of themed pages.
/**
* One drifting blob of the aurora field: where it sits, how far it wanders, and how fast. Integer
* [sx]/[sy] keep the loop seamless at wrap. The COLOUR is the palette's, taken from its ramp at
* draw time, so the field always shows several of that palette's tones at once.
*/
/** One drifting colour blob of the aurora field. Integer [sx]/[sy] keep the loop seamless at wrap. */
private class AuroraBlob(
val color: Color,
val baseX: Float,
val baseY: Float,
val driftX: Float,
@@ -90,80 +80,50 @@ private class AuroraBlob(
)
private val auroraBlobs = listOf(
AuroraBlob(0.30f, 0.26f, 0.16f, 0.10f, 1, 1, 0.0f, 0.62f, 0.55f),
AuroraBlob(0.78f, 0.68f, 0.13f, 0.14f, 1, 2, 2.4f, 0.68f, 0.58f),
AuroraBlob(0.16f, 0.82f, 0.12f, 0.09f, 2, 1, 4.1f, 0.52f, 0.42f),
AuroraBlob(0.72f, 0.14f, 0.10f, 0.08f, 1, 3, 1.2f, 0.48f, 0.40f),
AuroraBlob(Color(0xFF877AF5), 0.30f, 0.26f, 0.16f, 0.10f, 1, 1, 0.0f, 0.62f, 0.55f), // brand violet
AuroraBlob(Color(0xFF3E33B8), 0.78f, 0.68f, 0.13f, 0.14f, 1, 2, 2.4f, 0.68f, 0.58f), // deep indigo
AuroraBlob(Color(0xFF9E4CCC), 0.16f, 0.82f, 0.12f, 0.09f, 2, 1, 4.1f, 0.52f, 0.42f), // plum
AuroraBlob(Color(0xFF3862DB), 0.72f, 0.14f, 0.10f, 0.08f, 1, 3, 1.2f, 0.48f, 0.40f), // cool blue
)
/**
* The living console backdrop: soft blobs from the palette's ramp drifting over its ground on
* slow, seamless loops, finished with a centre-pooling vignette and top/bottom legibility scrims.
* A Compose approximation of the Apple client's MeshGradient aurora — same colour families, same
* "ambience, never content" role, and the same [GamepadPalette] setting recolours both.
*
* [calm] is what the FORM screens wear: the pools dim onto the ground so the glass rows keep real
* colour and luminance without the launcher's contrast. Motion is identical either way on purpose
* — only the contrast differs, so moving between screens can't make the field jump.
*
* Honours the system's "remove animations" accessibility setting by freezing at a fixed phase, the
* same courtesy the Apple client pays Reduce Motion.
* 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.
*/
@Composable
fun GamepadAuroraBackground(modifier: Modifier = Modifier, calm: Boolean = false) {
val ink = LocalGamepadInk.current
val palette = LocalGamepadPalette.current
val animated = animationsEnabled()
fun GamepadAuroraBackground(modifier: Modifier = Modifier) {
val transition = rememberInfiniteTransition(label = "aurora")
// A full 0..2π sweep over ~96 s; integer per-blob multipliers make sin/cos continuous at the
// wrap so the field never visibly jumps when the animation restarts.
val swept by transition.animateFloat(
// 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(
initialValue = 0f,
targetValue = (2 * PI).toFloat(),
animationSpec = infiniteRepeatable(tween(96_000, easing = LinearEasing), RepeatMode.Restart),
label = "angle",
)
val angle = if (animated) swept else 0f
val tones = palette.blobColors
val ground = palette.groundColor
// Where the scrims tend, and how hard. Mixing a PALE field toward white at the dark field's
// strength bleaches the chroma straight out of the gradient, so a pale palette gets under
// half — the same scrim strength the desktop console's shader carries.
val scrim = if (palette.light) ink.fg else Color.Black
val strength = if (palette.light) 0.45f else 1f
Canvas(modifier) {
drawRect(ground)
drawRect(Color.Black)
val span = max(size.width, size.height)
for ((i, b) in auroraBlobs.withIndex()) {
for (b in auroraBlobs) {
val cx = (b.baseX + b.driftX * sin(angle * b.sx + b.phase)) * size.width
val cy = (b.baseY + b.driftY * cos(angle * b.sy + b.phase)) * size.height
val r = span * b.radiusFrac
// Calm scales each blob's contribution rather than dimming the whole canvas: the
// ground stays put and only the pools come down to meet it, which is the same "lower
// the contrast, keep the colour" the desktop console's `calm` uniform does.
val alpha = if (calm) b.alpha * 0.62f else b.alpha
drawCircle(
brush = Brush.radialGradient(
colors = listOf(tones[i].copy(alpha = alpha), Color.Transparent),
colors = listOf(b.color.copy(alpha = b.alpha), Color.Transparent),
center = Offset(cx, cy),
radius = r,
),
center = Offset(cx, cy),
radius = r,
// Additive only works over a DARK ground; over a pale one every blob
// saturates to white and the field turns grey. Pale palettes tint instead.
blendMode = if (palette.light) BlendMode.SrcOver else BlendMode.Plus,
blendMode = BlendMode.Plus,
)
}
// Cinematic vignette: pool light centre, settle the corners toward the scrim. Halved under
// calm: a launcher's cards sit in the pooled centre, but a form screen's rows run out
// toward the edges, where crushing them just eats the list.
// Cinematic vignette: pool light centre, sink the corners.
drawRect(
Brush.radialGradient(
colors = listOf(
Color.Transparent,
scrim.copy(alpha = (if (calm) 0.22f else 0.44f) * strength),
),
colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.44f)),
center = Offset(size.width / 2, size.height / 2),
radius = span * 0.92f,
),
@@ -171,108 +131,43 @@ fun GamepadAuroraBackground(modifier: Modifier = Modifier, calm: Boolean = false
// Top/bottom legibility scrim for the pinned title + hint bar.
drawRect(
Brush.verticalGradient(
0.0f to scrim.copy(alpha = 0.40f * strength),
0.30f to scrim.copy(alpha = 0.05f * strength),
0.70f to scrim.copy(alpha = 0.06f * strength),
1.0f to scrim.copy(alpha = 0.42f * strength),
0.0f to Color.Black.copy(alpha = 0.40f),
0.30f to Color.Black.copy(alpha = 0.05f),
0.70f to Color.Black.copy(alpha = 0.06f),
1.0f to Color.Black.copy(alpha = 0.42f),
),
)
}
}
/**
* `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.
* 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.
*/
@Composable
fun GamepadFormBackground(modifier: Modifier = Modifier) {
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<String>,
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 ink = LocalGamepadInk.current
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) ink.accent(0.85f) else ink.glass,
tween(180),
label = "tabBg",
)
// Not `ink` — that name is the palette's, and shadowing it here cost a compile.
val labelColor by animateColorAsState(
if (active) ink.onAccent else ink.fg(0.55f),
tween(180),
label = "tabInk",
)
val ring by animateColorAsState(
ink.fg(if (active && focused) 0.85f else 0f),
tween(180),
label = "tabRing",
)
Text(
title,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = labelColor,
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),
)
}
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,
)
}
}
@@ -281,7 +176,7 @@ fun ConsoleTabStrip(
* 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, end = 24.dp, bottom = 24.dp)
val ConsoleLegendInset = PaddingValues(start = 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
@@ -292,7 +187,6 @@ val ConsoleEdgeInset = 24.dp
*/
@Composable
fun ConsoleHeader(title: String, modifier: Modifier = Modifier, horizontalInset: Boolean = true) {
val ink = LocalGamepadInk.current
// `horizontalInset = false` when the caller's container already pads to ConsoleEdgeInset (e.g. a
// LazyColumn contentPadding) — so the heading lands at the SAME 24dp on every screen either way.
val h = if (horizontalInset) ConsoleEdgeInset else 0.dp
@@ -300,7 +194,7 @@ fun ConsoleHeader(title: String, modifier: Modifier = Modifier, horizontalInset:
title,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = ink.fg,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier.padding(start = h, end = h, top = 18.dp, bottom = 10.dp),
@@ -357,22 +251,21 @@ class ConsoleFocusVisuals(val scale: Float, val background: Color, val border: C
*/
@Composable
fun animateConsoleFocus(active: Boolean, editing: Boolean = false): ConsoleFocusVisuals {
val ink = LocalGamepadInk.current
val scale by animateFloatAsState(
targetValue = if (active) 1f else 0.98f,
animationSpec = spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessMediumLow),
label = "consoleScale",
)
val background by animateColorAsState(
if (active) ink.accent(0.20f) else ink.glass,
if (active) Color(0x336656F2) else Color(0x14FFFFFF),
tween(160),
label = "consoleBg",
)
val border by animateColorAsState(
when {
editing -> ink.accent(0.70f)
active -> ink.fg(0.28f)
else -> ink.fg(0.06f)
editing -> Color(0xB38678F5)
active -> Color.White.copy(alpha = 0.28f)
else -> Color.White.copy(alpha = 0.06f)
},
tween(160),
label = "consoleBorder",
@@ -387,19 +280,18 @@ fun animateConsoleFocus(active: Boolean, editing: Boolean = false): ConsoleFocus
*/
@Composable
fun ConsoleSwitch(on: Boolean, focused: Boolean, modifier: Modifier = Modifier) {
val ink = LocalGamepadInk.current
val travel by animateFloatAsState(
targetValue = if (on) 1f else 0f,
animationSpec = spring(dampingRatio = 0.8f, stiffness = 600f),
label = "switchKnob",
)
val track by animateColorAsState(
if (on) ink.accent else Color(0x26FFFFFF),
if (on) Color(0xFF6656F2) else Color(0x26FFFFFF),
tween(200),
label = "switchTrack",
)
val outline by animateColorAsState(
ink.fg(if (focused) 0.45f else 0.15f),
Color.White.copy(alpha = if (focused) 0.45f else 0.15f),
tween(160),
label = "switchOutline",
)
@@ -421,7 +313,7 @@ fun ConsoleSwitch(on: Boolean, focused: Boolean, modifier: Modifier = Modifier)
.offset { IntOffset(((trackW - knob - pad * 2).toPx() * travel).roundToInt(), 0) }
.size(knob)
.clip(CircleShape)
.background(ink.fg),
.background(Color.White),
)
}
}
@@ -429,7 +321,6 @@ fun ConsoleSwitch(on: Boolean, focused: Boolean, modifier: Modifier = Modifier)
/** A round face-button badge: a coloured disc with the button letter, like a controller's face. */
@Composable
fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit.Dp = 26.dp) {
val ink = LocalGamepadInk.current
Box(
modifier = Modifier
.size(size)
@@ -439,7 +330,7 @@ fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit
) {
Text(
glyph.toString(),
color = ink.fg,
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = (size.value * 0.52f).sp,
textAlign = TextAlign.Center,
@@ -450,12 +341,11 @@ fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit
/** The D-pad-centre "select" button — a green (confirm) disc with a ring; the TV-remote glyph for A. */
@Composable
private fun SelectGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) {
val ink = LocalGamepadInk.current
Box(
modifier = Modifier.size(size).clip(CircleShape).background(PadGlyph.A),
contentAlignment = Alignment.Center,
) {
Box(Modifier.size(size * 0.46f).clip(CircleShape).border(2.dp, ink.fg, CircleShape))
Box(Modifier.size(size * 0.46f).clip(CircleShape).border(2.dp, Color.White, CircleShape))
}
}
@@ -520,7 +410,6 @@ internal fun PsFaceGlyph(glyph: Char, size: androidx.compose.ui.unit.Dp = 26.dp)
*/
@Composable
internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.ui.unit.Dp = 26.dp) {
val ink = LocalGamepadInk.current
Box(
Modifier.size(size).clip(CircleShape).background(PadButtonFace),
contentAlignment = Alignment.Center,
@@ -532,17 +421,17 @@ internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.u
val corner = RoundedCornerShape(2.dp)
Box(
Modifier.size(size * 0.32f).align(Alignment.TopEnd)
.border(1.4.dp, ink.fg(0.9f), corner),
.border(1.4.dp, Color.White.copy(alpha = 0.9f), corner),
)
Box(
Modifier.size(size * 0.32f).align(Alignment.BottomStart)
.clip(corner).background(PadButtonFace)
.border(1.4.dp, ink.fg(0.9f), corner),
.border(1.4.dp, Color.White.copy(alpha = 0.9f), corner),
)
}
Gamepad.PadStyle.NINTENDO -> Text(
"",
color = ink.fg,
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = (size.value * 0.62f).sp,
textAlign = TextAlign.Center,
@@ -551,7 +440,7 @@ internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.u
Modifier
.size(width = size * 0.58f, height = size * 0.30f)
.clip(RoundedCornerShape(50))
.border(1.6.dp, ink.fg(0.9f), RoundedCornerShape(50)),
.border(1.6.dp, Color.White.copy(alpha = 0.9f), RoundedCornerShape(50)),
)
}
}
@@ -563,7 +452,6 @@ internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.u
*/
@Composable
fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, hazeState: HazeState? = null) {
val ink = LocalGamepadInk.current
// On a TV D-pad remote (no A/B/X/Y), auto-swap the two universal pad glyphs every screen uses:
// A (confirm) → the select ring, B (back/cancel) → a back glyph. Screen-specific glyphs like the
// home's Up/Down handle themselves. A real pad instead picks its glyph FAMILY (Xbox letters /
@@ -576,19 +464,14 @@ fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, haze
// With a haze source, blur the content behind the pill (real backdrop blur, API 31+; a translucent
// scrim below) + a light tint; otherwise fall back to a solid frosted fill.
val frosted = if (hazeState != null) {
modifier.clip(shape).hazeEffect(hazeState).background(ink.shade(0.25f))
modifier.clip(shape).hazeEffect(hazeState).background(Color(0x4014122A))
} else {
modifier.clip(shape).background(ink.shade(0.55f))
modifier.clip(shape).background(Color(0x8C14122A))
}
Row(
modifier = frosted
.border(1.dp, ink.fg(0.14f), shape)
.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()),
.border(1.dp, Color.White.copy(alpha = 0.14f), shape)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(11.dp),
) {
@@ -614,7 +497,7 @@ fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, haze
Text(
h.text,
style = MaterialTheme.typography.labelLarge,
color = ink.fg(0.9f),
color = Color.White.copy(alpha = 0.9f),
maxLines = 1,
softWrap = false, // never char-wrap a label when several hints crowd a narrow pill
)
@@ -626,25 +509,24 @@ fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, haze
/** "Which pad is driving this UI" — a quiet chip in the console top bar with the controller's name. */
@Composable
fun ControllerStatusChip(name: String, modifier: Modifier = Modifier) {
val ink = LocalGamepadInk.current
Row(
modifier = modifier
.clip(RoundedCornerShape(50))
.background(ink.fg(0.08f))
.background(Color.White.copy(alpha = 0.08f))
.padding(horizontal = 12.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Filled.SportsEsports,
contentDescription = null,
tint = ink.fg(0.75f),
tint = Color.White.copy(alpha = 0.75f),
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(7.dp))
Text(
name,
style = MaterialTheme.typography.labelMedium,
color = ink.fg(0.75f),
color = Color.White.copy(alpha = 0.75f),
maxLines = 1,
)
}
@@ -85,7 +85,6 @@ fun GamepadDialog(
actions: List<DialogAction>,
body: @Composable ColumnScope.() -> Unit,
) {
val ink = LocalGamepadInk.current
// Focus the primary action; buttons are stacked full-width, navigated up/down (fits long labels
// like "Request access" without the cramped-row wrapping a horizontal layout caused).
var focus by remember { mutableIntStateOf(actions.indexOfFirst { it.primary }.coerceAtLeast(0)) }
@@ -118,11 +117,11 @@ fun GamepadDialog(
.heightIn(max = maxCardHeight)
.clip(RoundedCornerShape(24.dp))
.background(Color(0xF01A1730))
.border(1.dp, ink.fg(0.12f), RoundedCornerShape(24.dp))
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp))
.padding(28.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = ink.fg)
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = Color.White)
Column(
Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
@@ -140,7 +139,6 @@ fun GamepadDialog(
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enabled: Boolean, onClick: () -> Unit) {
val ink = LocalGamepadInk.current
val scale by animateFloatAsState(
if (focused) 1.02f else 1f,
spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessMediumLow),
@@ -154,19 +152,19 @@ private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enab
// Focus sweeps up/down the stack — cross-fade the fills so it glides instead of snapping.
val bg by animateColorAsState(
when {
focused -> ink.accent
primary -> ink.accent(0.20f)
else -> ink.glass
focused -> Color(0xFF6656F2)
primary -> Color(0x336656F2)
else -> Color(0x14FFFFFF)
},
tween(160),
label = "btnBg",
)
val fg by animateColorAsState(
when {
!enabled -> ink.fg(0.35f)
focused -> ink.fg
primary -> ink.accent
else -> ink.fg(0.85f)
!enabled -> Color.White.copy(alpha = 0.35f)
focused -> Color.White
primary -> Color(0xFF8678F5)
else -> Color.White.copy(alpha = 0.85f)
},
tween(160),
label = "btnFg",
@@ -200,8 +198,7 @@ private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enab
/** Body text helper — a dimmed paragraph. */
@Composable
private fun DialogText(text: String) {
val ink = LocalGamepadInk.current
Text(text, style = MaterialTheme.typography.bodyMedium, color = ink.fg(0.7f))
Text(text, style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.7f))
}
/**
@@ -274,7 +271,6 @@ fun GamepadPinHostsDialog(
onToggle: (KnownHost) -> Unit,
onDismiss: () -> Unit,
) {
val ink = LocalGamepadInk.current
// 0..hosts.lastIndex = host rows, hosts.size = the Done button (with no hosts, index 0 IS
// Done, so it starts focused).
var focus by remember { mutableIntStateOf(0) }
@@ -308,7 +304,7 @@ fun GamepadPinHostsDialog(
.heightIn(max = maxCardHeight)
.clip(RoundedCornerShape(24.dp))
.background(Color(0xF01A1730))
.border(1.dp, ink.fg(0.12f), RoundedCornerShape(24.dp))
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp))
.padding(28.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
@@ -316,7 +312,7 @@ fun GamepadPinHostsDialog(
"Pin “$profileName",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = ink.fg,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -354,7 +350,6 @@ fun GamepadPinHostsDialog(
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun PinHostRow(label: String, on: Boolean, focused: Boolean, onClick: () -> Unit) {
val ink = LocalGamepadInk.current
val visuals = animateConsoleFocus(active = focused)
// Inside the dialog's scroll region, like DialogButton: a focused row scrolled out of a short
// landscape window pulls itself into view.
@@ -381,7 +376,7 @@ private fun PinHostRow(label: String, on: Boolean, focused: Boolean, onClick: ()
label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = ink.fg,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -523,7 +518,6 @@ fun GamepadRequestAccessDialog(pt: PendingTrust, onRequestAccess: () -> Unit, on
@Composable
fun GamepadAwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) {
val ink = LocalGamepadInk.current
GamepadDialog(
title = "Waiting for approval",
onDismiss = onCancel,
@@ -531,8 +525,8 @@ fun GamepadAwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) {
) {
val deviceName = Build.MODEL ?: "this device"
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = ink.fg)
Text("Approve this device on $hostLabel.", color = ink.fg)
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = Color.White)
Text("Approve this device on $hostLabel.", color = Color.White)
}
DialogText(
"Open the host's console (or web UI) and approve “$deviceName”. It connects automatically " +
@@ -548,7 +542,6 @@ fun GamepadAwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) {
*/
@Composable
fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired: (String) -> Unit, onDismiss: () -> Unit) {
val ink = LocalGamepadInk.current
val scope = rememberCoroutineScope()
val digits = remember(pt) { mutableStateListOf(0, 0, 0, 0) }
var slot by remember(pt) { mutableIntStateOf(0) } // 0..3 = digit slots, 4 = Pair button
@@ -594,16 +587,16 @@ fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired:
Column(
Modifier.padding(24.dp).widthIn(max = 460.dp).heightIn(max = maxCardHeight)
.clip(RoundedCornerShape(24.dp))
.background(Color(0xF01A1730)).border(1.dp, ink.fg(0.12f), RoundedCornerShape(24.dp))
.background(Color(0xF01A1730)).border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp))
.verticalScroll(rememberScrollState())
.padding(28.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
Text("Pair with PIN", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = ink.fg)
Text("Pair with PIN", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = Color.White)
Text(
"Enter the 4-digit PIN shown on the host — D-pad ↑↓ sets a digit, ←→ moves.",
style = MaterialTheme.typography.bodyMedium, color = ink.fg(0.7f), textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.7f), textAlign = TextAlign.Center,
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
repeat(4) { i -> PinSlot(digits[i], focused = slot == i && !pairing) }
@@ -622,14 +615,13 @@ fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired:
@Composable
private fun PinSlot(value: Int, focused: Boolean) {
val ink = LocalGamepadInk.current
val shape = RoundedCornerShape(12.dp)
Box(
Modifier.size(54.dp, 66.dp).clip(shape)
.background(if (focused) ink.accent(0.20f) else ink.glass)
.border(if (focused) 2.dp else 1.dp, if (focused) ink.accent else ink.fg(0.1f), shape),
.background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF))
.border(if (focused) 2.dp else 1.dp, if (focused) Color(0xFF8678F5) else Color.White.copy(alpha = 0.1f), shape),
contentAlignment = Alignment.Center,
) {
Text(value.toString(), fontSize = 30.sp, fontWeight = FontWeight.Bold, color = ink.fg, fontFamily = FontFamily.Monospace)
Text(value.toString(), fontSize = 30.sp, fontWeight = FontWeight.Bold, color = Color.White, fontFamily = FontFamily.Monospace)
}
}
@@ -247,10 +247,9 @@ fun GamepadHome(
/** One dark-glass landscape console tile — bigger and bolder than the touch grid's HostCard. */
@Composable
private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) {
val ink = LocalGamepadInk.current
val shape = RoundedCornerShape(26.dp)
val wash = if (tile.filled) {
Brush.verticalGradient(listOf(ink.accent(0.20f), Color(0x14100C2A)))
Brush.verticalGradient(listOf(Color(0x336656F2), Color(0x14100C2A)))
} else {
Brush.verticalGradient(listOf(Color(0x1AFFFFFF), Color(0x0DFFFFFF)))
}
@@ -259,7 +258,7 @@ private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) {
.fillMaxWidth()
.clip(shape)
.background(wash)
.border(1.dp, ink.fg(0.16f), shape)
.border(1.dp, Color.White.copy(alpha = 0.16f), shape)
.padding(22.dp),
) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
@@ -270,7 +269,7 @@ private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) {
Icon(
Icons.Filled.Lock,
contentDescription = "Paired",
tint = ink.fg(0.7f),
tint = Color.White.copy(alpha = 0.7f),
modifier = Modifier.padding(end = 6.dp).size(15.dp),
)
}
@@ -287,14 +286,14 @@ private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) {
tile.title,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = ink.fg,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
tile.subtitle,
style = MaterialTheme.typography.bodyMedium,
color = ink.fg(0.55f),
color = Color.White.copy(alpha = 0.55f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -303,10 +302,9 @@ private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) {
@Composable
private fun MonogramBadge(tile: HomeTile) {
val ink = LocalGamepadInk.current
val shape = RoundedCornerShape(15.dp)
val fill = if (tile.filled) {
Brush.verticalGradient(listOf(ink.accent, ink.accent))
Brush.verticalGradient(listOf(Color(0xFF6656F2), Color(0xFF8678F5)))
} else {
Brush.verticalGradient(listOf(Color(0x296656F2), Color(0x296656F2)))
}
@@ -318,18 +316,18 @@ private fun MonogramBadge(tile: HomeTile) {
tile.connecting -> CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
color = ink.fg,
color = Color.White,
)
tile.isAdd -> Icon(
Icons.Filled.Add,
contentDescription = null,
tint = if (tile.filled) ink.fg else ink.accent,
tint = if (tile.filled) Color.White else Color(0xFF8678F5),
)
else -> Text(
tile.title.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = if (tile.filled) ink.fg else ink.accent,
color = if (tile.filled) Color.White else Color(0xFF8678F5),
)
}
}
@@ -1,89 +0,0 @@
package io.unom.punktfunk
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.graphics.Color
// The ink the console (gamepad) UI draws with under the chosen background palette.
//
// The console screens were white-on-dark throughout with the brand violet hardcoded as the accent.
// Both had to become palette-derived at once: a pale field needs dark text or it is unreadable,
// and a violet focus wash on a copper field is exactly the clash this exists to fix.
//
// Published as a CompositionLocal rather than passed down, so a leaf (a row, a hint pill, a card)
// can ask for the right colour without every caller in between knowing about palettes. The Apple
// client uses an environment value and `pf-console-ui` a thread-local for the same reason.
/** Everything about the console's look that follows the chosen palette. */
class GamepadInk(
/** Primary text/glyph colour. */
val fg: Color,
/** Focus wash, selected tab pill, switch track — the palette's own accent. */
val accent: Color,
/** What reads ON the accent (a filled pill's label, a switch knob). */
val onAccent: Color,
/** The base fill every glass surface starts from, at its resting opacity. */
val glass: Color,
/** What a wash laid UNDER text tends toward: black on a dark field, white on a pale one. */
val shade: Color,
/**
* How hard those washes go. A pale field needs far less — mixing toward white at the dark
* field's strength bleaches the chroma straight out of the gradient.
*/
val shadeScale: Float,
/** True when the field is pale, for the few places that branch rather than blend. */
val isLight: Boolean,
) {
/** The foreground at [alpha]. */
fun fg(alpha: Float): Color = fg.copy(alpha = alpha)
/** The accent at [alpha]. */
fun accent(alpha: Float): Color = accent.copy(alpha = alpha)
/** A wash under text: [alpha] is the dark-field strength, scaled for a pale one. */
fun shade(alpha: Float): Color = shade.copy(alpha = alpha * shadeScale)
companion object {
fun of(p: GamepadPalette): GamepadInk {
val accent = p.accentColor
// Chosen by luminance, not by `light`: an accent is picked for contrast against the
// GLASS, not against the field.
val accentLuma =
0.2126 * p.accent.first + 0.7152 * p.accent.second + 0.0722 * p.accent.third
val onAccent = if (accentLuma > 0.55) Color.Black else Color.White
if (!p.light) {
return GamepadInk(
fg = Color.White,
accent = accent,
onAccent = onAccent,
glass = Color.White.copy(alpha = 0.08f),
shade = Color.Black,
shadeScale = 1f,
isLight = false,
)
}
val (gr, gg, gb) = p.ground
return GamepadInk(
// Tinted toward the palette's own ground so it doesn't read as a foreign grey.
fg = Color((gr * 0.16).toFloat(), (gg * 0.14).toFloat(), (gb * 0.20).toFloat()),
accent = accent,
onAccent = onAccent,
// More body than the dark glass carries: white frost over a bright gradient has
// far less separating it from its backdrop than dark glass over a dark one.
glass = Color.White.copy(alpha = 0.55f),
shade = Color.White,
shadeScale = 0.45f,
isLight = true,
)
}
/** The shipped dark look — what a preview or a test composition gets. */
val DARK = of(GamepadPalette.named("violet"))
}
}
/**
* The ink of the palette currently drawing, for everything under [App]. Provided from the live
* settings alongside [LocalGamepadPalette], so a change on the gamepad settings screen re-inks
* every console surface at once.
*/
val LocalGamepadInk = compositionLocalOf { GamepadInk.DARK }
@@ -152,9 +152,8 @@ 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, 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).
* [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).
*/
@Composable
fun GamepadNavEffect2D(
@@ -163,7 +162,6 @@ fun GamepadNavEffect2D(
onActivate: () -> Unit,
onTertiary: () -> Unit = {},
onSecondary: () -> Unit = {},
onShoulder: (Int) -> Unit = {},
) {
val activity = LocalContext.current as? MainActivity ?: return
val state = remember { NavInputState() }
@@ -171,7 +169,6 @@ 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
@@ -199,10 +196,7 @@ 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 }
// 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)
else -> false // B / shoulders → MainActivity (B remaps to BACK → BackHandler)
}
}
if (active) {
@@ -1,218 +0,0 @@
package io.unom.punktfunk
import androidx.compose.ui.graphics.Color
// The console (gamepad) UI's background colour families, and the ink each one calls for.
//
// A palette is a short ordered ramp of DISTINCT hues, not one hue at several brightnesses. The
// field samples that ramp so several tones show at once and pool into each other, the way a real
// gradient poster does. An earlier version rotated ONE field's hue per palette, which is why every
// non-default palette read flat and monotone.
//
// A palette also owns the UI sitting on it: [accent] is the focus wash / selected pill / switch
// colour, and [light] flips the ink so a pale field gets dark text instead of white.
//
// The table, [ramp] and [CELL_RAMP] are mirrored in `pf-console-ui`'s `library.rs` (Rust) and the
// Apple client's `GamepadPalette.swift` under the same ids, so one `ui_palette` value is one look
// on every client. Keep the three copies in step: a palette added here without the others is a
// value the other clients silently render as Violet.
/** One background colour family. */
class GamepadPalette(
/** The stored `ui_palette` value ([Settings.uiPalette]). */
val id: String,
/** What the settings row shows. */
val name: String,
/**
* The colour ramp, dark end first. Empty = the brand default's explicit field, kept
* bit-identical to what every install already sees.
*/
val stops: List<Triple<Double, Double, Double>>,
/** The field's ground — what it settles onto and what the calm mix lifts toward. */
val ground: Triple<Double, Double, Double>,
/** The UI accent: focus wash, selected tab pill, switch track. */
val accent: Triple<Double, Double, Double>,
/** A pale field: the UI flips to dark ink and the legibility scrims go white. */
val light: Boolean,
) {
/** Four drifting blob colours, spread across the ramp so the field shows several hues. */
val blobColors: List<Color> by lazy {
val s = stops.ifEmpty { VIOLET_BLOBS }
(0..3).map { color(ramp(s, 0.15 + 0.25 * it)) }
}
/** The field's ground as a Compose colour. */
val groundColor: Color by lazy { color(ground) }
/** The accent as a Compose colour. */
val accentColor: Color by lazy { color(accent) }
companion object {
/**
* Where each of the 16 mesh cells samples the ramp on the clients that draw a mesh. Kept
* here so the three ports stay one table even though this client approximates the field
* with blobs.
*/
val CELL_RAMP = listOf(
0.10, -0.06, 0.04, -0.12,
-0.08, 0.14, -0.10, 0.06,
0.06, -0.12, 0.16, -0.04,
-0.10, 0.08, -0.06, 0.12,
)
/** The brand default's blob ramp — the colours the pre-palette field used. */
private val VIOLET_BLOBS = listOf(
Triple(0.53, 0.47, 0.96), Triple(0.24, 0.20, 0.72), Triple(0.62, 0.30, 0.80),
Triple(0.22, 0.38, 0.86), Triple(0.53, 0.47, 0.96),
)
/**
* The twelve shipped palettes: the brand default, five more dark fields, then six pale
* ones. Cycling order runs dark → light, so stepping the row walks the range one way.
*/
val ALL = listOf(
// --- dark fields (white ink) ---
GamepadPalette(
"violet", "Violet", emptyList(),
ground = Triple(0.075, 0.060, 0.160),
accent = Triple(0.525, 0.471, 0.961), light = false,
),
GamepadPalette(
// Deep indigo climbing through violet into a hot magenta.
"nebula", "Nebula",
listOf(
Triple(0.07, 0.05, 0.20), Triple(0.26, 0.14, 0.54), Triple(0.52, 0.20, 0.72),
Triple(0.82, 0.26, 0.62), Triple(0.98, 0.46, 0.68),
),
ground = Triple(0.055, 0.040, 0.135),
accent = Triple(0.95, 0.42, 0.72), light = false,
),
GamepadPalette(
// Ink-blue water: teal → cerulean → a violet undertow.
"abyss", "Abyss",
listOf(
Triple(0.02, 0.10, 0.17), Triple(0.04, 0.28, 0.42), Triple(0.07, 0.46, 0.63),
Triple(0.16, 0.38, 0.78), Triple(0.26, 0.22, 0.58),
),
ground = Triple(0.018, 0.070, 0.130),
accent = Triple(0.26, 0.76, 0.92), light = false,
),
GamepadPalette(
// Banked coals: plum embers → crimson → burnt orange → gold.
"ember", "Ember",
listOf(
Triple(0.16, 0.03, 0.10), Triple(0.45, 0.06, 0.12), Triple(0.72, 0.18, 0.06),
Triple(0.90, 0.42, 0.08), Triple(0.95, 0.68, 0.18),
),
ground = Triple(0.090, 0.035, 0.040),
accent = Triple(0.98, 0.62, 0.26), light = false,
),
GamepadPalette(
// Forest floor into moss and a lime break.
"moss", "Moss",
listOf(
Triple(0.03, 0.11, 0.09), Triple(0.06, 0.27, 0.20), Triple(0.09, 0.45, 0.31),
Triple(0.28, 0.61, 0.28), Triple(0.58, 0.77, 0.31),
),
ground = Triple(0.025, 0.085, 0.070),
accent = Triple(0.48, 0.86, 0.46), light = false,
),
GamepadPalette(
// Neutral, but never flat: barely-there saturation that still travels from a cool
// charcoal to a warm stone.
"graphite", "Graphite",
listOf(
Triple(0.06, 0.07, 0.11), Triple(0.15, 0.18, 0.25), Triple(0.30, 0.31, 0.35),
Triple(0.45, 0.42, 0.38), Triple(0.60, 0.56, 0.49),
),
ground = Triple(0.055, 0.055, 0.070),
accent = Triple(0.78, 0.80, 0.86), light = false,
),
// --- pale fields (dark ink) ---
GamepadPalette(
// The holographic foil: rose → lilac → periwinkle → aqua, with a white bloom.
"holo", "Holo",
listOf(
Triple(0.99, 0.72, 0.90), Triple(0.80, 0.60, 0.98), Triple(0.58, 0.62, 0.99),
Triple(0.55, 0.86, 0.98), Triple(0.94, 0.98, 1.00),
),
ground = Triple(0.96, 0.92, 0.99),
accent = Triple(0.42, 0.28, 0.86), light = true,
),
GamepadPalette(
// The poster sunset: periwinkle → magenta → scarlet → tangerine → gold.
"sunset", "Sunset",
listOf(
Triple(0.55, 0.45, 0.92), Triple(0.86, 0.31, 0.66), Triple(0.97, 0.26, 0.34),
Triple(0.99, 0.51, 0.18), Triple(1.00, 0.80, 0.22),
),
ground = Triple(0.98, 0.74, 0.34),
accent = Triple(0.64, 0.13, 0.44), light = true,
),
GamepadPalette(
// Peach into blush and lilac — the softest of the set.
"bloom", "Bloom",
listOf(
Triple(1.00, 0.86, 0.72), Triple(0.99, 0.73, 0.79), Triple(0.95, 0.65, 0.89),
Triple(0.82, 0.68, 0.96), Triple(0.73, 0.79, 0.99),
),
ground = Triple(0.99, 0.90, 0.89),
accent = Triple(0.72, 0.24, 0.55), light = true,
),
GamepadPalette(
// First light: pale gold → coral → lilac.
"dawn", "Dawn",
listOf(
Triple(1.00, 0.92, 0.70), Triple(1.00, 0.80, 0.62), Triple(0.99, 0.66, 0.62),
Triple(0.90, 0.62, 0.78), Triple(0.77, 0.69, 0.95),
),
ground = Triple(1.00, 0.93, 0.82),
accent = Triple(0.82, 0.33, 0.28), light = true,
),
GamepadPalette(
// Sea glass: mint → aqua → a pale sky.
"mint", "Mint",
listOf(
Triple(0.82, 0.98, 0.90), Triple(0.62, 0.94, 0.88), Triple(0.55, 0.88, 0.95),
Triple(0.63, 0.82, 0.99), Triple(0.82, 0.87, 1.00),
),
ground = Triple(0.90, 0.98, 0.96),
accent = Triple(0.04, 0.42, 0.40), light = true,
),
GamepadPalette(
// Near-white, but iridescent rather than flat — rose, sky, mint and cream in turn.
"opal", "Opal",
listOf(
Triple(0.98, 0.92, 0.96), Triple(0.87, 0.93, 0.99), Triple(0.91, 0.99, 0.95),
Triple(0.99, 0.96, 0.88), Triple(0.94, 0.90, 0.99),
),
ground = Triple(0.97, 0.96, 0.99),
accent = Triple(0.36, 0.32, 0.44), light = true,
),
)
/**
* 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]
/** Sample an ordered colour ramp at [t] ∈ [0, 1] (linear between neighbouring stops). */
fun ramp(
stops: List<Triple<Double, Double, Double>>,
t: Double,
): Triple<Double, Double, Double> {
if (stops.isEmpty()) return Triple(0.0, 0.0, 0.0)
if (stops.size == 1) return stops[0]
val x = t.coerceIn(0.0, 1.0) * (stops.size - 1)
val i = x.toInt().coerceAtMost(stops.size - 2)
val f = x - i
val (ar, ag, ab) = stops[i]
val (br, bg, bb) = stops[i + 1]
return Triple(ar + (br - ar) * f, ag + (bg - ag) * f, ab + (bb - ab) * f)
}
fun color(c: Triple<Double, Double, Double>): Color =
Color(c.first.toFloat(), c.second.toFloat(), c.third.toFloat())
}
}
@@ -39,7 +39,6 @@ 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
@@ -64,35 +63,10 @@ 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,
// 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"),
}
// B closes. Both write the same SharedPreferences, so values round-trip with the touch settings.
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,
@@ -159,34 +133,10 @@ fun GamepadSettingsScreen(
// path there is this screen's own Controller-optimized UI toggle, which swaps in the standard
// interface remote-navigably. The strings branch on it.
val tv = remember { isTvDevice(context) }
val allRows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update) +
val rows = 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<GpTab, Int>() }
val rows = allRows.filter { it.tab == tab }
var focus by remember { mutableIntStateOf(0) }
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])
}
if (focus > rows.lastIndex) focus = rows.lastIndex
// 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) }
@@ -201,28 +151,20 @@ fun GamepadSettingsScreen(
active = navActive && pinProfile == null,
onDirection = { dir ->
when (dir) {
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) }
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) }
}
},
// 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) },
onActivate = { adjustDir = 1; liveRow(rows, focus)?.activate() },
)
// Keep the focused row on screen, but only SCROLL when it's actually off-screen — so entering the
// screen (focus on the first row) leaves the "Settings" heading visible instead of jumping past it.
// +1 accounts for the heading being item 0.
LaunchedEffect(focus, tab) {
LaunchedEffect(focus) {
runCatching {
val itemIndex = focus + 1
val info = listState.layoutInfo
@@ -241,21 +183,9 @@ 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(),
modifier = Modifier.fillMaxSize().systemBarsPadding(),
contentPadding = PaddingValues(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 104.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
@@ -266,19 +196,12 @@ fun GamepadSettingsScreen(
ConsoleHeader("Default settings", horizontalInset = false)
}
itemsIndexed(rows, key = { _, r -> r.id }) { index, row ->
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() }
},
)
}
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() }
})
}
}
}
@@ -295,23 +218,8 @@ 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(
if (tabFocused) listOf(
GamepadHint('↔', Color(0xFF9A93C7), "Section"),
PadGlyph.hint('A', "Open") { tabFocused = false },
PadGlyph.hint('B', "Done", onClick = onBack),
) else sections + when {
when {
focused != null && !focused.enabled -> listOf(
PadGlyph.hint('B', "Done", onClick = onBack),
)
@@ -346,7 +254,6 @@ fun GamepadSettingsScreen(
@Composable
private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick: () -> Unit) {
val ink = LocalGamepadInk.current
val visuals = animateConsoleFocus(active = focused)
val shape = RoundedCornerShape(14.dp)
// The chevrons keep their layout slot and only fade, so the value never jumps sideways when
@@ -358,7 +265,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
label = "chevrons",
)
val valueColor by animateColorAsState(
ink.fg(if (focused) 1f else 0.6f),
Color.White.copy(alpha = if (focused) 1f else 0.6f),
tween(160),
label = "valueColor",
)
@@ -367,7 +274,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
Text(
row.header.uppercase(),
style = MaterialTheme.typography.labelMedium,
color = ink.fg(0.45f),
color = Color.White.copy(alpha = 0.45f),
letterSpacing = 1.4.sp,
modifier = Modifier.padding(start = 16.dp, top = 14.dp, bottom = 4.dp),
)
@@ -393,7 +300,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
fontWeight = FontWeight.SemiBold,
// A disabled row (the "No profiles yet" placeholder) dims but stays focusable,
// so its detail line can still explain what would go here.
color = ink.fg(if (row.enabled) 1f else 0.45f),
color = Color.White.copy(alpha = if (row.enabled) 1f else 0.45f),
maxLines = 1,
)
Spacer(Modifier.weight(1f))
@@ -401,7 +308,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
// A toggle is a switch, not text — the sliding knob + tinting track IS the value.
ConsoleSwitch(on = row.toggled, focused = focused)
} else {
Text(" ", color = ink.fg, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
Text(" ", color = Color.White, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
// The value slides in the direction it was stepped and its width animates, so
// cycling a choice reads as motion through a list rather than a text swap.
AnimatedContent(
@@ -422,7 +329,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
overflow = TextOverflow.Ellipsis,
)
}
Text(" ", color = ink.fg, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
Text(" ", color = Color.White, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
}
}
// The focused row carries its own one-line description — no dedicated (space-eating)
@@ -435,7 +342,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
Text(
row.detail,
style = MaterialTheme.typography.bodySmall,
color = ink.fg(0.6f),
color = Color.White.copy(alpha = 0.6f),
maxLines = 2,
modifier = Modifier.padding(top = 6.dp),
)
@@ -446,8 +353,7 @@ 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`). Every row declares its [GpTab]; the screen shows one
* tab at a time. */
* AV1 codec entry (see `codecOptionsFor`). */
internal fun buildSettingsRows(
s: Settings,
hasBodyVibrator: Boolean,
@@ -455,12 +361,12 @@ internal fun buildSettingsRows(
update: (Settings) -> Unit,
): List<GpRow> {
fun <T> choice(
id: String, tab: GpTab, header: String?, label: String, detail: String,
id: String, header: String?, label: String, detail: String,
options: List<Pair<T, String>>, current: T, enabled: Boolean = true, write: (T) -> Unit,
): GpRow {
val idx = options.indexOfFirst { it.first == current }
return GpRow(
id, tab, header, label,
id, header, label,
value = options.getOrNull(idx)?.second ?: "",
detail = detail,
enabled = enabled,
@@ -479,10 +385,10 @@ internal fun buildSettingsRows(
)
}
fun toggle(
id: String, tab: GpTab, header: String?, label: String, detail: String,
id: String, header: String?, label: String, detail: String,
value: Boolean, enabled: Boolean = true, write: (Boolean) -> Unit,
): GpRow = GpRow(
id, tab, header, label,
id, header, label,
value = if (value) "On" else "Off",
detail = detail,
enabled = enabled,
@@ -491,13 +397,36 @@ internal fun buildSettingsRows(
toggled = value,
)
// 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.
// 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.
return listOf(
choice(
"resolution", GpTab.STREAM, null, "Resolution",
"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",
"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
@@ -511,56 +440,55 @@ internal fun buildSettingsRows(
s.width to s.height,
) { (w, h) -> update(s.copy(width = w, height = h)) },
choice(
"refresh", GpTab.STREAM, null, "Refresh rate",
"Frame rate the host renders and streams at.",
"refresh", null, "Refresh rate", "Frame rate the host renders and streams at.",
REFRESH_OPTIONS, s.hz,
) { update(s.copy(hz = it)) },
choice(
"bitrate", GpTab.STREAM, null, "Bitrate",
"bitrate", "Display · Quality", "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(
"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(
"codec", GpTab.VIDEO, null, "Video codec",
"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", GpTab.VIDEO, null, "10-bit HDR",
"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", GpTab.VIDEO, "Decoding", "Low-latency mode",
"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(
"audio", GpTab.AUDIO, null, "Audio channels",
"The speaker layout requested from the host.",
"compositor", "Display · 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.",
AUDIO_CHANNEL_OPTIONS, s.audioChannels,
) { update(s.copy(audioChannels = it)) },
toggle(
"mic", GpTab.AUDIO, null, "Microphone",
"Send this device's microphone to the host's virtual mic.",
"mic", null, "Microphone", "Send this device's microphone to the host's virtual mic.",
s.micEnabled,
) { update(s.copy(micEnabled = it)) },
toggle(
"echoCancel", GpTab.AUDIO, null, "Echo cancellation",
"echoCancel", 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", GpTab.CONTROLLER, null, "Forward controllers",
"padForward", "Controllers", "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.",
@@ -571,18 +499,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", GpTab.CONTROLLER, null, "Controller type",
"padType", 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", GpTab.CONTROLLER, null, "Guide button",
"systemButtons", 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", GpTab.CONTROLLER, null, "Hold Select for guide",
"guideGesture", 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,
@@ -590,7 +518,7 @@ internal fun buildSettingsRows(
) + listOfNotNull(
if (hasBodyVibrator) {
toggle(
"phoneRumble", GpTab.CONTROLLER, null, "Rumble on this phone",
"phoneRumble", 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,
@@ -602,7 +530,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", GpTab.CONTROLLER, "Passthrough", "Steam Controller 2 passthrough",
"sc2", null, "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,
@@ -612,53 +540,20 @@ 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", GpTab.CONTROLLER, null, "DualSense / DualShock passthrough (USB)",
"dsCapture", 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 tab. On a TV that phrasing changes: "touch interface" points
* instead of a dead-looking empty header. 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).
@@ -679,8 +574,7 @@ private fun buildProfileRows(
return listOf(
GpRow(
id = "noProfiles",
tab = GpTab.PROFILES,
header = null,
header = "Profiles",
label = "No profiles yet",
value = "",
detail = "Profiles bundle stream settings for different uses — pinned ones become " +
@@ -692,13 +586,12 @@ private fun buildProfileRows(
),
)
}
return profiles.map { p ->
return profiles.mapIndexed { i, 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}",
tab = GpTab.PROFILES,
header = null,
header = if (i == 0) "Profiles" else null,
label = p.name,
value = when (pins) {
0 -> "Not pinned"
@@ -90,7 +90,6 @@ fun LibraryScreen(
onBack: () -> Unit,
navActive: Boolean = true,
) {
val ink = LocalGamepadInk.current
BackHandler(onBack = onBack)
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -146,14 +145,7 @@ fun LibraryScreen(
launching = false
if (handle != 0L) {
onLaunched(
ActiveSession(
handle,
settings,
host.clipboardSync,
hostId = host.id,
// Where to come back to when this game exits.
launchedFromLibrary = true,
),
ActiveSession(handle, settings, host.clipboardSync),
)
}
else Toast.makeText(
@@ -178,8 +170,8 @@ fun LibraryScreen(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
CircularProgressIndicator(color = ink.fg)
Text("Launching…", color = ink.fg, style = MaterialTheme.typography.bodyLarge)
CircularProgressIndicator(color = Color.White)
Text("Launching…", color = Color.White, style = MaterialTheme.typography.bodyLarge)
}
}
}
@@ -203,19 +195,17 @@ fun LibraryScreen(
@Composable
private fun LoadingState() {
val ink = LocalGamepadInk.current
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(14.dp)) {
CircularProgressIndicator(color = ink.fg)
Text("Loading library…", color = ink.fg(0.7f), style = MaterialTheme.typography.bodyLarge)
CircularProgressIndicator(color = Color.White)
Text("Loading library…", color = Color.White.copy(alpha = 0.7f), style = MaterialTheme.typography.bodyLarge)
}
}
@Composable
private fun MessageState(text: String) {
val ink = LocalGamepadInk.current
Text(
text,
color = ink.fg(0.75f),
color = Color.White.copy(alpha = 0.75f),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 24.dp),
@@ -229,7 +219,6 @@ private fun Coverflow(
navActive: Boolean,
onLaunch: (GameEntry) -> Unit,
) {
val ink = LocalGamepadInk.current
BoxWithConstraints(Modifier.fillMaxSize()) {
// Fit a 2:3 poster into the height the detail line leaves; clamp so it never dwarfs the screen.
val coverHeight = (maxHeight * 0.72f).coerceAtMost(360.dp)
@@ -252,22 +241,7 @@ private fun Coverflow(
onActivate = { games.getOrNull(navTarget)?.let(onLaunch) },
)
// Design D4: the launcher entries lead the strip (the client groups them at parse time).
// A coverflow is one-dimensional, so instead of a second focus rail the heading names the
// group the cursor is in and changes as it crosses the boundary. Only drawn when the
// library actually has both groups — otherwise the screen is exactly what it was.
val bothGroups = games.any { it.isLauncher } && games.any { !it.isLauncher }
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center) {
if (bothGroups) {
Text(
if (current?.isLauncher == true) "LAUNCHERS" else "GAMES",
style = MaterialTheme.typography.labelSmall,
color = Color.White.copy(alpha = 0.45f),
letterSpacing = 2.sp,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
)
}
HorizontalPager(
state = pagerState,
pageSize = PageSize.Fixed(coverWidth),
@@ -325,16 +299,15 @@ private fun Coverflow(
current?.title ?: " ",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = ink.fg,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (current != null) {
Text(
if (current.isLauncher) "${current.storeLabel.uppercase()} \u00B7 LAUNCHER"
else current.storeLabel.uppercase(),
if (current.isCustom) "CUSTOM" else "STEAM",
style = MaterialTheme.typography.labelMedium,
color = ink.fg(0.5f),
color = Color.White.copy(alpha = 0.5f),
letterSpacing = 2.sp,
)
}
@@ -346,7 +319,6 @@ private fun Coverflow(
/** One cover: walks the art candidates (portrait → header → hero) then a text placeholder. */
@Composable
private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Modifier) {
val ink = LocalGamepadInk.current
val candidates = game.art.posterCandidates
var idx by remember(game.id) { mutableStateOf(0) }
val shape = RoundedCornerShape(16.dp)
@@ -354,7 +326,7 @@ private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Mo
modifier = modifier
.clip(shape)
.background(Color(0xFF241F3D))
.border(1.dp, ink.fg(0.12f), shape),
.border(1.dp, Color.White.copy(alpha = 0.12f), shape),
contentAlignment = Alignment.Center,
) {
if (idx < candidates.size) {
@@ -367,29 +339,24 @@ private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Mo
onError = { idx++ }, // this candidate failed — try the next, or fall to the placeholder
)
} else {
// A launcher rarely has poster art. Naming the launcher says "opens Steam"; the title
// would read as "a game whose cover failed to load".
Text(
if (game.isLauncher) game.storeLabel else game.title,
game.title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = ink.fg(0.75f),
color = Color.White.copy(alpha = 0.75f),
textAlign = TextAlign.Center,
modifier = Modifier.padding(12.dp),
)
}
// Store badge, top-start — brand-filled for a launcher entry (design D4).
// Store badge, top-start.
Box(Modifier.fillMaxSize().padding(8.dp), contentAlignment = Alignment.TopStart) {
Text(
game.storeLabel,
if (game.isCustom) "Custom" else "Steam",
style = MaterialTheme.typography.labelSmall,
color = ink.fg,
color = Color.White,
modifier = Modifier
.clip(RoundedCornerShape(50))
.background(
if (game.isLauncher) MaterialTheme.colorScheme.primary
else Color.Black.copy(alpha = 0.5f),
)
.background(Color.Black.copy(alpha = 0.5f))
.padding(horizontal = 8.dp, vertical = 3.dp),
)
}
@@ -105,16 +105,6 @@ 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
@@ -294,7 +284,6 @@ 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),
@@ -334,7 +323,6 @@ 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)
@@ -373,7 +361,6 @@ 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
@@ -437,96 +424,6 @@ fun nativeDisplayMode(context: Context): Triple<Int, Int, Int> {
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<Int, Int, Int> {
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
@@ -561,21 +458,12 @@ fun displaySupportsHdr(context: Context): Boolean {
return supported
}
/**
* 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.
*/
/** Resolve [Settings] (with its 0=native placeholders) to the concrete mode to request. */
fun Settings.effectiveMode(context: Context): Triple<Int, Int, Int> {
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
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
return Triple(w, h, hz)
}
@@ -629,10 +517,9 @@ 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; [SAFE_AREA_MODE] = native minus the cutout. */
/** (width, height, label). `(0,0)` = native display. */
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"),
@@ -603,10 +603,6 @@ 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.
@@ -615,13 +611,7 @@ 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 when (w) {
0 -> "$lbl ($nw × $nh)"
SAFE_AREA_MODE -> "$lbl ($sw × $sh)"
else -> lbl
}
} +
options = RESOLUTION_OPTIONS.map { (w, h, lbl) -> (w to h) to (if (w == 0) "$lbl ($nw × $nh)" 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…"),
@@ -630,10 +620,7 @@ 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) ->
// 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) {
if (w < 0) {
// Seed from the current *effective* size so the fields start from something
// sensible (the resolved native mode, not the 0 × 0 placeholder).
customPicked = true
@@ -26,25 +26,13 @@ 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 (1821) when nonzero.
* - [StatsVerbosity.DETAILED] also the decoder label, the video-feed descriptor (1013), 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.DETAILED] also the decoder label, the video-feed descriptor (1013), and the
* stage equation (14/15, split into `host + network` when the Phase-2 terms at 16/17 are nonzero).
* [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).
*/
@@ -107,15 +95,9 @@ 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(shave(s[24], floorMs), shave(s[25], floorMs), "capture→displayed")
Triple(s[24], s[25], "capture→displayed")
} else {
Triple(s[2], s[3], "capture→decoded")
}
@@ -138,11 +120,6 @@ 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])})"
@@ -166,14 +143,16 @@ internal fun StatsOverlay(
"= $hostTerms + $decodeTerm$displayTerm$presents",
Color.White,
)
// 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) {
// 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
statLine(
"os present +${"%.1f".format(floorMs)} excluded (display pipeline minimum)",
Color(0xFF9AA6B8),
"≈ 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),
)
}
}
@@ -188,37 +167,6 @@ 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
@@ -226,9 +174,8 @@ private fun shave(ms: Double, floorMs: Double): Double = (ms - floorMs).coerceAt
* 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,
// 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]
// 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]
val parts = buildList {
add("${s[0].roundToInt()} fps")
if (latValid) add("${"%.1f".format(e2eP50)} ms")
@@ -73,7 +73,6 @@ 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
@@ -87,7 +86,7 @@ import kotlinx.coroutines.delay
* the connect that produced this handle.
*/
@Composable
fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> Unit) {
fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
val handle = session.handle
val initialSettings = session.settings
val micEnabled = initialSettings.micEnabled
@@ -201,32 +200,12 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
while (true) {
delay(1000)
if (NativeBridge.nativeSessionEnded(handle)) {
// 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 purposewhich 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)
Toast.makeText(
context,
"Connection lostthe host may be asleep. Wake it to reconnect.",
Toast.LENGTH_LONG,
).show()
onDisconnect()
return@LaunchedEffect
}
}
@@ -351,7 +330,7 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
// 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); onSessionEnded(SessionEndReason.LOCAL) }
activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
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.
@@ -638,7 +617,7 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
}
// Back gesture = a deliberate exit → signal the quit so the host tears down now (no linger).
BackHandler { NativeBridge.nativeDisconnectQuit(handle); onSessionEnded(SessionEndReason.LOCAL) }
BackHandler { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
// 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
@@ -646,14 +625,14 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
// 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 `onSessionEnded()` so the composable's `onDispose` above runs the one real
// Route it through `onDisconnect()` 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) {
onSessionEnded(SessionEndReason.LOCAL)
onDisconnect()
}
}
lifecycle?.addObserver(obs)
@@ -61,16 +61,6 @@ 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. */
@@ -1,158 +0,0 @@
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`) and Swift (`GamepadPalette.swift`) ports reproduce — the same ids in
// the same order, the same light/dark split, the same ramp — so one `ui_palette` value is one look
// on every client.
class GamepadPaletteTest {
private fun luma(c: Triple<Double, Double, Double>) =
0.2126 * c.first + 0.7152 * c.second + 0.0722 * c.third
/** Hue angle in degrees, or null for something too grey to have one. */
private fun hue(c: Triple<Double, Double, Double>): Double? {
val (r, g, b) = c
val max = maxOf(r, g, b)
val min = minOf(r, g, b)
val d = max - min
if (d < 0.04) return null
val h = when (max) {
r -> 60.0 * (((g - b) / d) % 6.0)
g -> 60.0 * ((b - r) / d + 2.0)
else -> 60.0 * ((r - g) / d + 4.0)
}
return (h + 360.0) % 360.0
}
/** Ids, order and the light/dark split are the cross-client contract. */
@Test
fun tableMatchesTheOtherClients() {
assertEquals(
listOf(
"violet", "nebula", "abyss", "ember", "moss", "graphite",
"holo", "sunset", "bloom", "dawn", "mint", "opal",
),
GamepadPalette.ALL.map { it.id },
)
// Dark fields lead, pale ones follow, so stepping the row walks one direction.
val firstLight = GamepadPalette.ALL.indexOfFirst { it.light }
assertEquals(6, firstLight)
assertTrue(GamepadPalette.ALL.drop(firstLight).all { it.light })
// An unknown name is a newer client's palette, not an error.
assertEquals("violet", GamepadPalette.named("chartreuse").id)
assertEquals("violet", GamepadPalette.named("").id)
// The brand default keeps the shipped field rather than a generated ramp.
assertTrue(GamepadPalette.named("violet").stops.isEmpty())
}
/**
* A palette must read as SEVERAL hues, not one hue at several brightnesses that was exactly
* the complaint about the hue-rotation model this replaced.
*/
@Test
fun everyPaletteIsMultiTone() {
for (p in GamepadPalette.ALL) {
val stops = p.stops.ifEmpty { continue }
val hues = stops.mapNotNull { hue(it) }
assertTrue("${p.id}: too few coloured stops", hues.size >= 3)
var spread = 0.0
for (a in hues) {
for (b in hues) {
val d = Math.abs(a - b) % 360.0
spread = maxOf(spread, minOf(d, 360.0 - d))
}
}
// Graphite and Opal are deliberately near-neutral; the rest must travel.
val floor = if (p.id == "graphite" || p.id == "opal") 20.0 else 45.0
assertTrue("${p.id} spans only $spread° of hue", spread >= floor)
}
}
/** A pale palette really is pale — its ink flips, so a mislabelled one is unreadable. */
@Test
fun palettesAreHonestAboutLightness() {
for (p in GamepadPalette.ALL) {
if (p.light) {
assertTrue("${p.id}'s ground is dark", luma(p.ground) > 0.6)
assertTrue("${p.id}'s accent is too pale", luma(p.accent) < 0.45)
} else {
assertTrue("${p.id}'s ground is light", luma(p.ground) < 0.2)
assertTrue("${p.id}'s accent is too dark", luma(p.accent) > 0.25)
}
}
}
/** The ramp is the shared sampling rule the Rust and Swift ports reproduce. */
@Test
fun rampInterpolatesBetweenStops() {
val stops = listOf(
Triple(0.0, 0.0, 0.0), Triple(1.0, 0.0, 0.0), Triple(1.0, 1.0, 1.0),
)
assertEquals(Triple(0.0, 0.0, 0.0), GamepadPalette.ramp(stops, 0.0))
assertEquals(Triple(1.0, 1.0, 1.0), GamepadPalette.ramp(stops, 1.0))
assertEquals(Triple(1.0, 0.0, 0.0), GamepadPalette.ramp(stops, 0.5))
assertEquals(0.5, GamepadPalette.ramp(stops, 0.25).first, 1e-9)
// Out of range clamps rather than throwing.
assertEquals(Triple(0.0, 0.0, 0.0), GamepadPalette.ramp(stops, -3.0))
assertEquals(Triple(1.0, 1.0, 1.0), GamepadPalette.ramp(stops, 9.0))
assertEquals(Triple(0.0, 0.0, 0.0), GamepadPalette.ramp(emptyList(), 0.5))
}
/** The ink a palette calls for: white on a dark field, near-black on a pale one. */
@Test
fun inkFollowsTheField() {
val dark = GamepadInk.of(GamepadPalette.named("violet"))
assertTrue(!dark.isLight)
assertEquals(1f, dark.fg.red, 1e-6f)
assertEquals(1f, dark.shadeScale, 1e-6f)
val light = GamepadInk.of(GamepadPalette.named("holo"))
assertTrue(light.isLight)
assertTrue("pale fields need dark ink", light.fg.red < 0.3f)
// A pale field's scrims must pull far less, or they bleach the gradient.
assertTrue(light.shadeScale < 0.5f)
}
/**
* 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)
}
}
@@ -1,48 +0,0 @@
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 })
}
}
@@ -106,14 +106,6 @@ class ScreenshotTest {
@Test
fun connectingConsole() = shootRoot("connecting-console") { ConnectConsoleScene() }
@Test
fun consoleSettings() = shootRoot("console-settings") { ConsoleSettingsScene() }
/** A PALE palette: the whole UI flips to dark ink on white frost, which only a shot proves. */
@Test
fun consoleSettingsLight() =
shootRoot("console-settings-light") { ConsoleSettingsScene(paletteId = "holo") }
@Test
fun trust() = shootScreen("trust") {
HostsScene()
@@ -31,12 +31,6 @@ import io.unom.punktfunk.BrandDark
import io.unom.punktfunk.ConnectModal
import io.unom.punktfunk.ConnectPhase
import io.unom.punktfunk.ConnectTakeover
import androidx.compose.runtime.CompositionLocalProvider
import io.unom.punktfunk.GamepadInk
import io.unom.punktfunk.GamepadPalette
import io.unom.punktfunk.GamepadSettingsScreen
import io.unom.punktfunk.LocalGamepadInk
import io.unom.punktfunk.LocalGamepadPalette
import io.unom.punktfunk.Settings
import io.unom.punktfunk.TouchMode
import io.unom.punktfunk.SettingsCategory
@@ -361,11 +355,9 @@ 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, 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
// 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
// (lost 2 · skipped 1 · FEC 5 of 238) so the reliability line (NORMAL/DETAILED) and the
// compact loss flag both render.
StatsOverlay(
@@ -412,25 +404,3 @@ 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(paletteId: String = "violet") {
// The scene calls the screen directly, so it has to publish the palette locals `App` would
// normally provide — without them a light palette would render with the default DARK ink and
// the shot would silently prove nothing.
val palette = GamepadPalette.named(paletteId)
CompositionLocalProvider(
LocalGamepadPalette provides palette,
LocalGamepadInk provides GamepadInk.of(palette),
) {
GamepadSettingsScreen(
initial = SHOT_SETTINGS.copy(uiPalette = paletteId), onChange = {}, onBack = {},
)
}
}
@@ -87,18 +87,6 @@ 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).
@@ -1,56 +0,0 @@
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
}
}
@@ -132,27 +132,6 @@ 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
@@ -37,51 +37,9 @@ data class Artwork(val portrait: String?, val header: String?, val hero: String?
val posterCandidates: List<String> get() = listOfNotNull(portrait, header, hero)
}
/**
* One title in the unified library. [id] is store-qualified (`steam:<appid>` / `custom:<id>`).
*
* [role] is `"game"` (the default, and what an older host omits) or `"launcher"` an entry that
* opens the launcher itself (Steam Big Picture, Heroic) rather than a title. Kept a plain nullable
* String on purpose: the host owns the vocabulary, and an unknown future value must degrade to a
* game rather than break the decode (design D4).
*/
data class GameEntry(
val id: String,
val store: String,
val title: String,
val art: Artwork,
val role: String? = null,
) {
/** One title in the unified library. [id] is store-qualified (`steam:<appid>` / `custom:<id>`). */
data class GameEntry(val id: String, val store: String, val title: String, val art: Artwork) {
val isCustom: Boolean get() = store == "custom"
/** Whether this entry opens a launcher rather than a game. */
val isLauncher: Boolean get() = role == "launcher"
/**
* Display name for the store badge the same table the other clients use
* (`pf-console-ui::library::store_label`). Before this the UI said "Steam" for every non-custom
* entry, which a Lutris or GOG title made a lie.
*/
val storeLabel: String get() = when (store) {
"steam" -> "Steam"
"custom" -> "Custom"
"heroic" -> "Heroic"
"lutris" -> "Lutris"
"epic" -> "Epic"
"gog" -> "GOG"
"xbox" -> "Xbox"
else -> "Game"
}
}
/**
* Design D4: launcher entries lead the shelf, keeping the host's title order within each group.
* Applied once where the library is fetched, so no screen has to remember the rule and a library
* without launcher entries comes back untouched.
*/
fun List<GameEntry>.launchersFirst(): List<GameEntry> {
val launchers = filter { it.isLauncher }
return if (launchers.isEmpty()) this else launchers + filterNot { it.isLauncher }
}
/** Fetch outcome — three states so the UI can guide setup (the common case is "not paired yet"). */
@@ -150,11 +108,10 @@ object LibraryClient {
header = resolveArt(str(art, "header"), base),
hero = resolveArt(str(art, "hero"), base),
),
role = str(o, "role"),
),
)
}
return out.launchersFirst()
return out
}
/** A present, non-null, non-blank JSON string field, else null. */
@@ -170,14 +127,8 @@ object LibraryClient {
* An OkHttpClient that presents the paired client cert and pins the host's self-signed cert by
* SHA-256(DER) reused for BOTH the library fetch and the cover-art loads (so a paired client
* reaches the host's own art proxy). The pinning trust manager trusts the host by fingerprint and
* defers to normal public trust for any other origin (an external CDN URL).
*
* The two checks are only sound TOGETHER, and the composition is the point: the trust manager
* cannot fail closed on its own (it has no hostname, so it must let a CDN chain through), so the
* hostname verifier is what makes the pinned host pin-only. Loosen either and a publicly-trusted
* certificate for any name is accepted for the host which is exactly what 2026-08-05 review M-2
* found. The host's own cert is self-signed with no matching SAN, so it can never satisfy the
* default verifier; the pin is its only credential, on purpose.
* defers to normal public trust for any other origin (an external CDN URL); the hostname verifier
* accepts the pinned host (whose self-signed cert has no matching SAN) and defers otherwise.
*/
fun mtlsHttpClient(certPem: String, keyPem: String, host: String, fpHex: String): OkHttpClient {
val clientCert = CertificateFactory.getInstance("X.509")
@@ -211,26 +162,7 @@ fun mtlsHttpClient(certPem: String, keyPem: String, host: String, fpHex: String)
val defaultVerifier = HttpsURLConnection.getDefaultHostnameVerifier()
val verifier = HostnameVerifier { hostname, session ->
if (hostname == host) {
// The PINNED host fails closed: only the pinned leaf is acceptable for this name.
//
// This used to be a bare `hostname == host`, which composed with the trust manager's
// system-CA fall-through into "any publicly-trusted certificate, for any name, is
// accepted for the pinned host" — the pin was decorative (2026-08-05 review M-2). A
// MITM with any free CA-issued cert intercepted the connection, received the client's
// mTLS IDENTITY certificate, and served attacker-chosen library JSON and art URLs.
// The Rust (`pf-client-core`) and Apple (`ClientTLS`) paths already fail closed here;
// only Android did not.
try {
sha256Hex((session.peerCertificates.firstOrNull() as? X509Certificate)?.encoded ?: return@HostnameVerifier false) == pinned
} catch (_: Exception) {
false
}
} else {
// Any other origin (an external CDN art URL) is ordinary public trust: the system
// trust manager validated the chain, and this checks the name against it.
defaultVerifier.verify(hostname, session)
}
hostname == host || defaultVerifier.verify(hostname, session)
}
return OkHttpClient.Builder()
@@ -404,31 +404,6 @@ 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
@@ -206,20 +206,6 @@ 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.
//
@@ -349,16 +335,6 @@ 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).
@@ -536,9 +512,6 @@ struct ContentView: View {
waker: waker,
gamepadUI: gamepadUIActive,
onCancelConnect: { model.disconnect() })
// The takeover mounts OUTSIDE the gamepad screens (it covers the whole home), so
// it publishes the palette's ink itself rather than inheriting it.
.gamepadPaletteInk()
}
}
@@ -47,16 +47,12 @@ struct ConnectOverlay: View {
return nil
}
@Environment(\.gamepadInk) private var ink
var body: some View {
if let phase {
ZStack {
if gamepadUI {
// Console: an opaque, living aurora over everything, in the chosen palette.
// The takeover's own text rides `ink`, so a pale palette flips it here too
// without that this is the one console screen that stays white-on-white.
ink.isLight ? Color.white.ignoresSafeArea() : Color.black.ignoresSafeArea()
// Console: an opaque, living aurora over everything.
Color.black.ignoresSafeArea()
GamepadScreenBackground().ignoresSafeArea()
Color.clear.contentShape(Rectangle()).onTapGesture {}
content(phase).padding(40).frame(maxWidth: 460)
@@ -74,8 +70,7 @@ struct ConnectOverlay: View {
.padding(40)
}
}
// The console takeover follows the palette; the default UI's modal stays dark.
.environment(\.colorScheme, gamepadUI && ink.isLight ? .light : .dark)
.environment(\.colorScheme, .dark)
.transition(.opacity)
#if os(iOS) || os(macOS)
.background { ConnectControllerInput(waker: waker, onCancelConnect: onCancelConnect) }
@@ -12,7 +12,6 @@ import SwiftUI
#if os(iOS) || os(macOS) || os(tvOS)
struct GamepadAddHostView: View {
@Environment(\.gamepadInk) private var ink
@Environment(\.dismiss) private var dismiss
let onAdd: (StoredHost) -> Void
@@ -48,12 +47,12 @@ struct GamepadAddHostView: View {
VStack(spacing: 4) {
Text("Add Host")
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(ink.fg)
.foregroundStyle(.white)
if !compact {
Text("Hosts on this network appear automatically — add one by address "
+ "for everything else.")
.font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption))
.foregroundStyle(ink.fg(0.55))
.foregroundStyle(.white.opacity(0.55))
.multilineTextAlignment(.center)
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth * 0.72)
}
@@ -74,9 +73,6 @@ struct GamepadAddHostView: View {
}
// No aurora the same clean Liquid-Glass-over-dark base as the gamepad settings screen.
.background { GamepadFormBackground() }
// Publish the palette's ink to this screen (text, glass, accent, scrims) a
// pale palette flips all of them, and no leaf should have to read the setting.
.gamepadPaletteInk()
// A port can't exceed 5 digits cap while typing so the row can't grow absurd.
.onChange(of: port) { _, value in
if value.count > 5 { port = String(value.prefix(5)) }
@@ -147,7 +143,7 @@ struct GamepadAddHostView: View {
Button { dismiss() } label: {
Image(systemName: "xmark")
.font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold))
.foregroundStyle(ink.fg)
.foregroundStyle(.white)
.frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide)
.glassBackground(Circle(), interactive: true)
.contentShape(Circle())
@@ -184,22 +180,22 @@ struct GamepadAddHostView: View {
if row.isAction {
Label("Add Host", systemImage: "plus.circle.fill")
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
.foregroundStyle(canAdd ? ink.accent : ink.fg(0.35))
.foregroundStyle(canAdd ? Color.brand : .white.opacity(0.35))
.frame(maxWidth: .infinity)
} else {
Text(row.label)
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
.foregroundStyle(ink.fg)
.foregroundStyle(.white)
Spacer(minLength: 12)
Text(row.value.isEmpty ? row.placeholder : row.value)
.font(.geistFixed(m.valueFont, .medium))
.foregroundStyle(row.value.isEmpty ? ink.fg(0.35) : ink.fg)
.foregroundStyle(row.value.isEmpty ? .white.opacity(0.35) : .white)
.lineLimit(1)
.truncationMode(.head) // keep the end of a long address visible while typing
if editing == row.id {
// The live-edit caret: this row is what the keyboard tray is typing into.
Rectangle()
.fill(ink.accent)
.fill(Color.brand)
.frame(width: 2, height: m.labelFont + 2)
}
}
@@ -210,12 +206,12 @@ struct GamepadAddHostView: View {
// takes the brand wash, and the edited row keeps its brand caret border.
.consoleGlass(
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous),
tint: (focused || editing == row.id) ? ink.accent(0.30) : nil,
tint: (focused || editing == row.id) ? Color.brand.opacity(0.30) : nil,
interactive: focused)
.overlay {
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous)
.strokeBorder(
editing == row.id ? ink.accent(0.7) : ink.fg(focused ? 0.28 : 0.06),
editing == row.id ? Color.brand.opacity(0.7) : .white.opacity(focused ? 0.28 : 0.06),
lineWidth: 1)
}
.scaleEffect(focused ? 1.0 : 0.98)
@@ -89,7 +89,6 @@ struct GamepadHint: Identifiable {
/// worn as a self-contained Liquid Glass pill (like the top-bar controller chip) so it floats over
/// the backdrop instead of dissolving into it.
struct GamepadHintBar: View {
@Environment(\.gamepadInk) private var ink
let hints: [GamepadHint]
// 10-foot legend on tvOS, in-hand sizes elsewhere.
@@ -109,35 +108,26 @@ struct GamepadHintBar: View {
HStack(spacing: 7) {
Image(systemName: hint.glyph)
.font(.system(size: Self.glyphFont))
.foregroundStyle(ink.fg)
.foregroundStyle(.white)
Text(hint.text)
}
.fixedSize() // keep glyph + label together; never truncate a hint mid-word
}
}
.font(.geist(Self.textFont, .semibold, relativeTo: .subheadline))
.foregroundStyle(ink.fg(0.85))
.foregroundStyle(.white.opacity(0.85))
.padding(Self.pad)
.consoleGlass(Capsule())
.overlay(Capsule().strokeBorder(ink.fg(0.12), lineWidth: 1))
.overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1))
}
}
/// 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.
/// 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.
///
/// 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
@@ -146,90 +136,77 @@ 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 {
@Environment(\.gamepadInk) private var ink
/// 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, palette: palette)
composite(at: 0)
} 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, palette: palette)
composite(at: context.date.timeIntervalSinceReferenceDate)
}
}
}
.ignoresSafeArea()
}
/// 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 {
// Where the scrims tend, and how hard. Mixing a PALE field toward white at the dark
// field's strength bleaches the chroma straight out of the gradient, so a pale palette
// gets under half the same `u_scrim.a` the console shader carries.
let scrim: Color = palette.light ? ink.fg : .black
let strength = palette.light ? 0.45 : 1.0
return ZStack {
Self.color(palette.ground)
colorField(at: t, palette: palette)
/// 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 {
ZStack {
Color.black
colorField(at: t)
// ±8° over ~5 min the whole field very slowly warms and cools.
.hueRotation(.degrees(sin(t * 0.021) * 8))
// Calm = col·0.6 + ground·0.4: over the ground, `.opacity` IS the multiply
.opacity(calm ? 0.6 : 1)
if calm {
// and a plusLighter wash of the palette's own ground IS the add. Chosen so the
// ground lands exactly where it was and the bright pools come down to meet it.
Self.color(palette.ground)
.opacity(0.4)
.blendMode(.plusLighter)
}
// Cinematic vignette: the edges settle toward the scrim so the cards sit in the
// pooled light. Soft (extends past the frame) so the corners deepen rather than
// crush. Halved under calm: a launcher's cards sit in the pooled centre, but a form
// screen's rows run out toward the edges, where crushing them just eats the list.
// 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.
EllipticalGradient(
colors: [.clear, scrim.opacity((calm ? 0.21 : 0.42) * strength)],
colors: [.clear, .black.opacity(0.42)],
center: .center, startRadiusFraction: 0.25, endRadiusFraction: 1.15)
// Legibility grounding for the pinned title (top) and hint pill (bottom). This one
// works on the field itself (it's the backdrop's bottom layer nothing behind it to
// blur), so it stays a gradient, just a light one.
// darkens the aurora itself (it's the backdrop's bottom layer nothing behind it to
// blur), so it stays a gradient, just a light one now.
LinearGradient(
stops: [
.init(color: scrim.opacity(0.38 * strength), location: 0),
.init(color: scrim.opacity(0.06 * strength), location: 0.32),
.init(color: scrim.opacity(0.08 * strength), location: 0.68),
.init(color: scrim.opacity(0.40 * strength), location: 1),
.init(color: .black.opacity(0.38), location: 0),
.init(color: .black.opacity(0.06), location: 0.32),
.init(color: .black.opacity(0.08), location: 0.68),
.init(color: .black.opacity(0.40), location: 1),
],
startPoint: .top, endPoint: .bottom)
}
}
@ViewBuilder private func colorField(at t: TimeInterval, palette: GamepadPalette) -> some View {
@ViewBuilder private func colorField(at t: TimeInterval) -> some View {
if #available(iOS 18, macOS 15, tvOS 18, *) {
MeshGradient(
width: 4, height: 4,
points: Self.meshPoints(at: t),
colors: palette.meshColors.map(Self.color),
colors: Self.meshColors,
smoothsColors: true)
} else {
LegacyBlobField(t: t, palette: palette)
LegacyBlobField(t: t)
}
}
// MARK: - MeshGradient aurora (iOS 18 / macOS 15+)
static func color(_ c: SIMD3<Double>) -> Color {
Color(red: c.x, green: c.y, blue: c.z)
}
/// 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 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
@@ -256,18 +233,15 @@ 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. 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.
/// 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+).
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 3090 s), and a radius that slowly breathes. The COLOUR comes from the
/// palette's ramp at draw time (see `blobColors`), so an older OS honours the setting too.
/// (rad/s periods of 3090 s), and a radius that slowly breathes.
private struct Blob {
let color: Color
let center: CGPoint
let drift: CGSize
let speed: (x: Double, y: Double)
@@ -278,16 +252,20 @@ private struct LegacyBlobField: View {
}
private static let blobs: [Blob] = [
Blob(center: CGPoint(x: 0.30, y: 0.24), drift: CGSize(width: 0.16, height: 0.10),
Blob(color: Color(red: 0.53, green: 0.47, blue: 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(center: CGPoint(x: 0.78, y: 0.66), drift: CGSize(width: 0.13, height: 0.14),
Blob(color: Color(red: 0.24, green: 0.20, blue: 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(center: CGPoint(x: 0.16, y: 0.82), drift: CGSize(width: 0.12, height: 0.09),
Blob(color: Color(red: 0.62, green: 0.30, blue: 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(center: CGPoint(x: 0.70, y: 0.12), drift: CGSize(width: 0.10, height: 0.08),
Blob(color: Color(red: 0.22, green: 0.38, blue: 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),
]
@@ -297,31 +275,26 @@ private struct LegacyBlobField: View {
let side = max(geo.size.width, geo.size.height)
ZStack {
ForEach(Self.blobs.indices, id: \.self) { i in
blobView(Self.blobs[i], tone: palette.blobColors[i], in: geo.size, side: side)
blobView(Self.blobs[i], in: geo.size, side: side)
}
}
.drawingGroup()
}
}
private func blobView(
_ blob: Blob, tone: SIMD3<Double>, in size: CGSize, side: CGFloat
) -> some View {
private func blobView(_ blob: Blob, in size: CGSize, side: CGFloat) -> some View {
let x = blob.center.x + blob.drift.width * CGFloat(sin(t * blob.speed.x + blob.phase.x))
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(tone)
return Circle()
.fill(RadialGradient(
colors: [color, color.opacity(0)],
colors: [blob.color, blob.color.opacity(0)],
center: .center, startRadius: 0, endRadius: r / 2))
.frame(width: r, height: r)
.position(x: x * size.width, y: y * size.height)
.opacity(blob.opacity)
// Additive only works over a DARK ground; over a pale one every blob saturates to
// white and the field turns grey. Pale palettes tint instead.
.blendMode(palette.light ? .normal : .plusLighter)
.blendMode(.plusLighter)
}
}
@@ -331,17 +304,15 @@ private struct LegacyBlobField: View {
/// the tray's text sits on a softly blurred backdrop that dissolves into the rows.
struct GamepadTrayScrim: View {
let edge: VerticalEdge
@Environment(\.gamepadInk) private var ink
var body: some View {
let fromEdge: UnitPoint = edge == .top ? .top : .bottom
let toContent: UnitPoint = edge == .top ? .bottom : .top
Rectangle()
.fill(.ultraThinMaterial)
// Force the frost to match the PALETTE, not the system appearance: the tray exists
// to keep the pinned title legible, so it has to frost dark under white ink and
// light under dark ink.
.environment(\.colorScheme, ink.isLight ? .light : .dark)
// These trays always sit on the dark console UI; force dark so the material frosts dark
// (white text stays legible) regardless of the system appearance.
.environment(\.colorScheme, .dark)
// Fade the whole blur out toward the content so it dissolves rather than ending on a line.
.mask {
LinearGradient(
@@ -359,16 +330,27 @@ struct GamepadTrayScrim: View {
}
}
/// 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`.
/// 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.
struct GamepadFormBackground: View {
var body: some View {
GamepadScreenBackground(calm: true)
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()
}
}
@@ -391,7 +373,6 @@ struct ConsoleBareButtonStyle: ButtonStyle {
/// chip in the launcher's top bar. Callers observe GamepadManager already, so this re-renders
/// when the pad or its battery state changes.
struct ControllerStatusChip: View {
@Environment(\.gamepadInk) private var ink
let controller: GamepadManager.DiscoveredController
// Legible from the couch on tvOS, quiet in hand elsewhere.
@@ -416,15 +397,15 @@ struct ControllerStatusChip: View {
Image(systemName: batterySymbol(level))
.font(.system(size: Self.font))
.foregroundStyle(level <= 0.2 && !controller.isCharging
? AnyShapeStyle(.red) : AnyShapeStyle(ink.fg(0.7)))
? AnyShapeStyle(.red) : AnyShapeStyle(.white.opacity(0.7)))
}
}
.font(.geist(Self.font, .medium, relativeTo: .caption))
.foregroundStyle(ink.fg(0.7))
.foregroundStyle(.white.opacity(0.7))
.padding(.horizontal, Self.hPad)
.padding(.vertical, Self.vPad)
.background(Capsule().fill(ink.fg(0.08)))
.overlay(Capsule().strokeBorder(ink.fg(0.12), lineWidth: 1))
.background(Capsule().fill(.white.opacity(0.08)))
.overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1))
}
private func batterySymbol(_ level: Float) -> String {
@@ -23,15 +23,14 @@ import SwiftUI
#if os(iOS) || os(macOS) || os(tvOS)
import GameController
/// 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.
/// 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.
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
@@ -64,7 +63,6 @@ private struct HomeTile: Identifiable {
}
struct GamepadHomeView: View {
@Environment(\.gamepadInk) private var ink
@ObservedObject var store: HostStore
@ObservedObject var model: SessionModel
@ObservedObject var discovery: HostDiscovery
@@ -116,9 +114,6 @@ struct GamepadHomeView: View {
.padding(.top, compact ? 4 : 8)
}
.background { GamepadScreenBackground() }
// Publish the palette's ink to this screen (text, glass, accent, scrims) a
// pale palette flips all of them, and no leaf should have to read the setting.
.gamepadPaletteInk()
.onAppear { discovery.start() }
.onDisappear { discovery.stop() }
// Reachability sweep (mDNS-independent) so routed/VPN hosts that never advertise still show
@@ -190,7 +185,7 @@ struct GamepadHomeView: View {
statusChip(hidden: true)
Text("Select a Host")
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(ink.fg)
.foregroundStyle(.white)
.lineLimit(1)
.minimumScaleFactor(0.75)
.frame(maxWidth: .infinity)
@@ -267,14 +262,10 @@ 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: action ?? (selected?.canWake == true ? "Wake & Connect" : "Connect"))]
text: selected?.id == .addHost ? "Add Host"
: (selected?.canWake == true ? "Wake & Connect" : "Connect"))]
if libraryEnabled, selected?.hasLibrary == true {
hints.append(.init(glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library"))
}
@@ -334,15 +325,7 @@ struct GamepadHomeView: View {
subtitle: "Register a host by address",
icon: "plus",
activate: { showAddHost = true })
// 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]
return saved + discovered + [add]
}
/// Only saved hosts have a library matches the touch grid, where "Browse Library" is a
@@ -359,7 +342,6 @@ struct GamepadHomeView: View {
/// touch grid's `HostCardView`. Renders only its base look; the centered-tile pop is layered on by
/// the caller's `.scrollTransition` so it always tracks the real scroll position.
private struct GamepadHostTile: View {
@Environment(\.gamepadInk) private var ink
let tile: HomeTile
let size: CGSize
@@ -399,7 +381,7 @@ private struct GamepadHostTile: View {
if tile.isPaired {
Image(systemName: "lock.fill")
.font(.system(size: Self.statusFont, weight: .semibold))
.foregroundStyle(ink.fg(0.5))
.foregroundStyle(.white.opacity(0.5))
}
if tile.isOnline {
Circle()
@@ -412,7 +394,7 @@ private struct GamepadHostTile: View {
Spacer(minLength: 0)
Text(tile.title)
.font(.geist(Self.titleFont, .bold, relativeTo: .title2))
.foregroundStyle(ink.fg)
.foregroundStyle(.white)
.lineLimit(1)
.minimumScaleFactor(0.7)
if let profile = tile.profile {
@@ -422,7 +404,7 @@ private struct GamepadHostTile: View {
}
Text(tile.subtitle)
.font(.geist(Self.subtitleFont, relativeTo: .caption))
.foregroundStyle(ink.fg(0.55))
.foregroundStyle(.white.opacity(0.55))
.lineLimit(1)
.padding(.top, 2)
}
@@ -432,12 +414,12 @@ private struct GamepadHostTile: View {
// Add-Host tiles stay neutral glass with a dashed edge. Glass clips to the shape itself.
.consoleGlass(
RoundedRectangle(cornerRadius: Self.corner, style: .continuous),
tint: tile.filled ? ink.accent(0.20) : nil)
tint: tile.filled ? Color.brand.opacity(0.20) : nil)
.overlay {
RoundedRectangle(cornerRadius: Self.corner, style: .continuous)
.strokeBorder(
LinearGradient(
colors: [ink.fg(0.22), ink.fg(0.04)],
colors: [.white.opacity(0.22), .white.opacity(0.04)],
startPoint: .top, endPoint: .bottom),
style: StrokeStyle(lineWidth: 1, dash: tile.filled ? [] : [6, 5]))
}
@@ -449,15 +431,15 @@ private struct GamepadHostTile: View {
return ZStack {
shape.fill(tile.filled
? AnyShapeStyle(LinearGradient(
colors: [ink.accent, ink.accent(0.68)],
colors: [Color.brand, Color.brand.opacity(0.68)],
startPoint: .top, endPoint: .bottom))
: AnyShapeStyle(ink.accent(0.16)))
: AnyShapeStyle(Color.brand.opacity(0.16)))
if tile.isConnecting {
ProgressView().tint(ink.fg)
ProgressView().tint(.white)
} else if let icon = tile.icon {
Image(systemName: icon)
.font(.system(size: Self.iconFont, weight: .semibold))
.foregroundStyle(ink.accent)
.foregroundStyle(Color.brand)
} else if let mark = osIconImage(for: tile.osChain) {
// The OS mark stands in for the initial (template asset tints like the text it
// replaces), and carries the label, since nothing else on the tile names the OS.
@@ -465,18 +447,18 @@ private struct GamepadHostTile: View {
.resizable()
.scaledToFit()
.frame(width: Self.monogramFont, height: Self.monogramFont)
.foregroundStyle(tile.filled ? ink.fg : ink.accent)
.foregroundStyle(tile.filled ? .white : Color.brand)
.accessibilityLabel(tile.osChain ?? "")
} else {
Text(monogram(tile.title))
.font(.geistFixed(Self.monogramFont, .bold))
.foregroundStyle(tile.filled ? ink.fg : ink.accent)
.foregroundStyle(tile.filled ? .white : Color.brand)
}
}
.frame(width: Self.badgeSide, height: Self.badgeSide)
.overlay {
if !tile.filled {
shape.strokeBorder(ink.accent(0.5), lineWidth: 1)
shape.strokeBorder(Color.brand.opacity(0.5), lineWidth: 1)
}
}
}
@@ -1,91 +0,0 @@
// The ink the gamepad UI draws with under the chosen background palette.
//
// The console screens were white-on-dark throughout with the brand violet hardcoded as the
// accent. Both had to become palette-derived at once: a pale field needs dark text or it is
// unreadable, and a violet focus wash on a copper field is exactly the clash this exists to fix.
//
// Handed down the view tree as an environment value rather than passed to each screen, so a
// leaf (a row, a hint pill, a card) can ask for the right colour without every caller in between
// knowing about palettes. `pf-console-ui` does the same thing with a thread-local `Ink`.
import PunktfunkShared
import SwiftUI
#if os(iOS) || os(macOS) || os(tvOS)
struct GamepadInk: Equatable, Sendable {
/// Primary text/glyph colour.
let fg: Color
/// Focus wash, selected tab pill, switch track, caret the palette's own accent.
let accent: Color
/// What reads ON the accent (a filled pill's label).
let onAccent: Color
/// The base fill every glass surface starts from.
let glass: Color
/// What a wash laid UNDER text tends toward: black on a dark field, white on a pale one.
let shade: Color
/// How hard those washes go. A pale field needs far less mixing toward white at the dark
/// field's strength bleaches the chroma straight out of the gradient.
let shadeScale: Double
/// True when the field is pale, for the few places that need to branch rather than blend
/// (a material's `colorScheme`, a shadow's presence).
let isLight: Bool
/// The foreground at `alpha`.
func fg(_ alpha: Double) -> Color { fg.opacity(alpha) }
/// The accent at `alpha`.
func accent(_ alpha: Double) -> Color { accent.opacity(alpha) }
/// A wash under text: `alpha` is the dark-field strength, scaled for a pale one.
func shade(_ alpha: Double) -> Color { shade.opacity(alpha * shadeScale) }
static func of(_ p: GamepadPalette) -> GamepadInk {
let accent = Color(red: p.accent.x, green: p.accent.y, blue: p.accent.z)
let accentLuma = 0.2126 * p.accent.x + 0.7152 * p.accent.y + 0.0722 * p.accent.z
// Chosen by luminance, not by `light`: an accent is picked for contrast against the
// GLASS, not against the field.
let onAccent: Color = accentLuma > 0.55 ? .black : .white
guard p.light else {
return GamepadInk(
fg: .white, accent: accent, onAccent: onAccent,
glass: Color(red: 0.086, green: 0.086, blue: 0.125),
shade: .black, shadeScale: 1, isLight: false)
}
return GamepadInk(
// Tinted toward the palette's own ground so it doesn't read as a foreign grey.
fg: Color(red: p.ground.x * 0.16, green: p.ground.y * 0.14, blue: p.ground.z * 0.20),
accent: accent, onAccent: onAccent,
glass: .white,
shade: .white, shadeScale: 0.45, isLight: true)
}
/// The shipped dark look what a preview or a test composition gets.
static let dark = GamepadInk.of(GamepadPalette.named("violet"))
}
private struct GamepadInkKey: EnvironmentKey {
static let defaultValue = GamepadInk.dark
}
extension EnvironmentValues {
/// The ink of the palette currently drawing. Set once, high up (see `GamepadInkModifier`).
var gamepadInk: GamepadInk {
get { self[GamepadInkKey.self] }
set { self[GamepadInkKey.self] = newValue }
}
}
extension View {
/// Resolve the stored `ui_palette` and publish its ink to everything below. Applied by the
/// gamepad screens' common root so no individual view has to read the setting.
func gamepadPaletteInk() -> some View { modifier(GamepadInkModifier()) }
}
private struct GamepadInkModifier: ViewModifier {
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
func body(content: Content) -> some View {
content.environment(\.gamepadInk, GamepadInk.of(GamepadPalette.named(paletteID)))
}
}
#endif
@@ -14,7 +14,6 @@ import SwiftUI
#if os(iOS) || os(macOS)
struct GamepadKeyboard: View {
@Environment(\.gamepadInk) private var ink
@Binding var text: String
/// Restricts typed characters (e.g. digits for a port field); backspace always works.
var allowed: CharacterSet?
@@ -80,7 +79,7 @@ struct GamepadKeyboard: View {
}
.overlay {
RoundedRectangle(cornerRadius: 22, style: .continuous)
.strokeBorder(ink.fg(0.12), lineWidth: 1)
.strokeBorder(.white.opacity(0.12), lineWidth: 1)
}
.sensoryFeedback(.selection, trigger: cursor)
.sensoryFeedback(.impact(weight: .light), trigger: pressTick)
@@ -111,11 +110,11 @@ struct GamepadKeyboard: View {
.font(.geist(15, .semibold, relativeTo: .callout))
}
}
.foregroundStyle(focused ? Color.black : ink.fg)
.foregroundStyle(focused ? Color.black : .white)
.frame(maxWidth: .infinity, minHeight: compact ? 34 : 42)
.background {
RoundedRectangle(cornerRadius: 9, style: .continuous)
.fill(focused ? AnyShapeStyle(ink.accent) : AnyShapeStyle(ink.fg(0.08)))
.fill(focused ? AnyShapeStyle(Color.brand) : AnyShapeStyle(.white.opacity(0.08)))
}
.animation(.smooth(duration: 0.12), value: focused)
.contentShape(Rectangle())
@@ -35,10 +35,6 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: 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
@@ -163,7 +159,6 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
case .up, .down: break
}
}
input.onShoulder = { forward in onShoulder?(forward ? 1 : -1) }
#else
input.onMove = { direction in
switch direction {
@@ -175,7 +170,6 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
}
input.onConfirm = { activate() }
input.onBack = onBack
input.onShoulder = { forward in onShoulder?(forward ? 1 : -1) }
#endif
}
@@ -53,18 +53,7 @@ struct HomeView: View {
NavigationStack {
Group {
if store.hosts.isEmpty && discoveredUnsaved.isEmpty {
#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
emptyState
} else {
ScrollView {
if !store.hosts.isEmpty {
@@ -105,7 +94,6 @@ 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.
@@ -118,9 +106,6 @@ struct HomeView: View {
.focusSection()
#endif
}
#if !os(tvOS)
.refreshable { await discovery.rescan() }
#endif
}
}
.navigationTitle("Punktfunk")
@@ -166,7 +151,6 @@ struct HomeView: View {
if showsArrangeMenu {
ToolbarItem(placement: .topBarTrailing) { arrangeMenu }
}
ToolbarItem(placement: .topBarTrailing) { refreshButton }
ToolbarItem(placement: .topBarTrailing) { addHostButton }
#else
if showsArrangeMenu {
@@ -175,10 +159,6 @@ 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")
@@ -344,20 +324,13 @@ struct HomeView: View {
ContentUnavailableView {
Label("No Hosts", systemImage: "rectangle.connected.to.line.below")
} description: {
Text("Add your punktfunk host with the + button, or scan the network again.")
Text("Add your punktfunk host with the + button.")
} 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
@@ -372,18 +345,6 @@ 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.
@@ -19,7 +19,6 @@ import SwiftUI
import GameController
struct LibraryCoverflowView: View {
@Environment(\.gamepadInk) private var ink
let games: [GameEntry]
let imageSession: URLSession?
var onLaunch: ((String) -> Void)?
@@ -47,24 +46,18 @@ struct LibraryCoverflowView: View {
.padding(.vertical, compact ? 6 : 10)
}
.background { GamepadScreenBackground() }
// Publish the palette's ink to this screen (text, glass, accent, scrims) a
// pale palette flips all of them, and no leaf should have to read the setting.
.gamepadPaletteInk()
}
@ViewBuilder private func content(for size: CGSize) -> some View {
// Fit the tallest poster into the height the detail line + paddings leave (the hints are a
// safe-area inset, already out of this budget) capped so it never dwarfs a large iPad and
// clamped by width on a narrow screen.
let reserved: CGFloat = (compact ? 72 : 96) + (showsGroupHeading ? 26 : 0)
let reserved: CGFloat = compact ? 72 : 96 // detail line + spacers
let coverHeight = min(360, min(max(140, size.height - reserved), size.width * 0.9))
let coverWidth = coverHeight * 2 / 3
VStack(spacing: 0) {
Spacer(minLength: 4)
if showsGroupHeading {
groupHeading.padding(.bottom, 6)
}
carousel(coverWidth: coverWidth, coverHeight: coverHeight)
detailPanel
.padding(.top, 12)
@@ -96,12 +89,10 @@ struct LibraryCoverflowView: View {
PosterImage(candidates: game.art.posterCandidates, title: game.title, session: imageSession)
.frame(width: width, height: height)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay(alignment: .topLeading) {
StoreBadge(label: game.storeLabel, isLauncher: game.isLauncher)
}
.overlay(alignment: .topLeading) { StoreBadge(isCustom: game.isCustom) }
.overlay {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(ink.fg(0.12), lineWidth: 1)
.strokeBorder(.white.opacity(0.12), lineWidth: 1)
}
.shadow(color: .black.opacity(0.5), radius: 16, y: 12)
.scrollTransition { content, phase in
@@ -121,42 +112,21 @@ struct LibraryCoverflowView: View {
}
}
/// Does this library have both groups? Only then does the heading earn its row a
/// launcher-less library gets exactly the layout it had before design D4.
private var showsGroupHeading: Bool {
games.contains(where: \.isLauncher) && games.contains { !$0.isLauncher }
}
/// Which group the cursor is in. A coverflow is one-dimensional, so instead of a second focus
/// rail (a whole new up/down nav model for two or three tiles) the heading names the group and
/// changes as the selection crosses the boundary the launcher entries lead the strip.
private var groupHeading: some View {
let selected = games.first { $0.id == selection }
return Text(selected?.isLauncher == true ? "LAUNCHERS" : "GAMES")
.font(.geist(11, .semibold, relativeTo: .caption2))
.tracking(1.4)
.foregroundStyle(ink.fg(0.45))
}
/// The centered title + store tag empty (not hidden) so the layout doesn't jump.
@ViewBuilder private var detailPanel: some View {
let game = games.first { $0.id == selection }
VStack(spacing: 6) {
Text(game?.title ?? " ")
.font(.geist(compact ? 22 : 25, .bold, relativeTo: .title))
.foregroundStyle(ink.fg)
.foregroundStyle(.white)
.lineLimit(1)
.minimumScaleFactor(0.75)
.multilineTextAlignment(.center)
if let game {
// main's richer store label, in the palette's ink.
Text(
game.isLauncher
? "\(game.storeLabel.uppercased()) · LAUNCHER" : game.storeLabel.uppercased()
)
.font(.geist(11, .semibold, relativeTo: .caption2))
.tracking(1.2)
.foregroundStyle(ink.fg(0.5))
Text(game.isCustom ? "CUSTOM" : "STEAM")
.font(.geist(11, .semibold, relativeTo: .caption2))
.tracking(1.2)
.foregroundStyle(.white.opacity(0.5))
}
}
.frame(maxWidth: .infinity)
@@ -169,10 +139,7 @@ struct LibraryCoverflowView: View {
private var hints: [GamepadHint] {
var hints: [GamepadHint] = []
if onLaunch != nil {
// You *open* a launcher and *launch* a game the hint follows the focused entry.
let opens = games.first { $0.id == selection }?.isLauncher == true
hints.append(
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: opens ? "Open" : "Launch"))
hints.append(.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Launch"))
}
hints.append(.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Close"))
return hints
@@ -80,47 +80,21 @@ struct LibraryView: View {
}
private var grid: some View {
// Design D4: launcher entries get their own section above the titles, never interleaved.
// Both headers appear only when both groups exist, so a library without launcher entries
// renders exactly as it did before.
let launchers = games.filter(\.isLauncher)
let titles = games.filter { !$0.isLauncher }
let both = !launchers.isEmpty && !titles.isEmpty
return ScrollView {
VStack(alignment: .leading, spacing: 18) {
if !launchers.isEmpty {
if both { sectionHeader("Launchers") }
tiles(launchers)
}
if !titles.isEmpty {
if both { sectionHeader("Games") }
tiles(titles)
ScrollView {
LazyVGrid(columns: columns, spacing: 18) {
ForEach(games) { game in
if let onLaunch {
Button { onLaunch(game.id) } label: { GameCard(game: game, imageSession: imageSession) }
.buttonStyle(.plain)
} else {
GameCard(game: game, imageSession: imageSession)
}
}
}
.padding()
}
}
private func tiles(_ entries: [GameEntry]) -> some View {
LazyVGrid(columns: columns, spacing: 18) {
ForEach(entries) { game in
if let onLaunch {
Button { onLaunch(game.id) } label: { GameCard(game: game, imageSession: imageSession) }
.buttonStyle(.plain)
} else {
GameCard(game: game, imageSession: imageSession)
}
}
}
}
private func sectionHeader(_ text: String) -> some View {
Text(text)
.font(.geist(12, .semibold, relativeTo: .caption))
.tracking(1.1)
.foregroundStyle(.secondary)
}
private var columns: [GridItem] {
#if os(tvOS)
let minW: CGFloat = 220
@@ -178,15 +152,12 @@ struct LibraryView: View {
return
}
do {
// `launchersFirst` groups launcher entries ahead of titles once, here, so the grid and
// the gamepad coverflow both inherit the D4 ordering.
games = try await LibraryClient.fetch(
address: current.address,
port: current.effectiveMgmtPort,
certPEM: identity.certPEM,
keyPEM: identity.keyPEM,
hostFingerprint: current.pinnedSHA256
).launchersFirst
hostFingerprint: current.pinnedSHA256)
imageSession?.finishTasksAndInvalidate()
imageSession = try LibraryImageLoader.session(
address: current.address,
@@ -214,9 +185,7 @@ private struct GameCard: View {
.aspectRatio(2.0 / 3.0, contentMode: .fit)
.frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.overlay(alignment: .topLeading) {
StoreBadge(label: game.storeLabel, isLauncher: game.isLauncher)
}
.overlay(alignment: .topLeading) { StoreBadge(isCustom: game.isCustom) }
Text(game.title)
.font(.geist(12, relativeTo: .caption))
.lineLimit(2)
@@ -12,21 +12,14 @@ import AppKit
/// The store-provenance badge (Steam vs. a user-curated custom entry) overlaid on a poster
/// shared by the touch grid's `GameCard` and the gamepad coverflow's cover cell.
struct StoreBadge: View {
/// Which store surfaced the entry, already resolved to a display name (`GameEntry.storeLabel`).
let label: String
/// A launcher entry (design D4) gets the brand fill, so "opens Steam" is legible at poster size
/// without reading the title.
var isLauncher: Bool = false
let isCustom: Bool
var body: some View {
Text(label)
Text(isCustom ? "Custom" : "Steam")
.font(.geist(11, .semibold, relativeTo: .caption2))
.foregroundStyle(isLauncher ? AnyShapeStyle(.white) : AnyShapeStyle(.primary))
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(
isLauncher ? AnyShapeStyle(Color.brand) : AnyShapeStyle(.ultraThinMaterial),
in: Capsule())
.background(.ultraThinMaterial, in: Capsule())
.padding(6)
}
}
@@ -65,14 +65,6 @@ 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.
@@ -257,7 +249,6 @@ 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
@@ -616,8 +607,6 @@ 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
@@ -637,36 +626,10 @@ final class SessionModel: ObservableObject {
/// Called (via the main actor) when the pump hits end-of-session.
func sessionEnded() {
guard let conn = connection else { return }
guard connection != nil 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
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)."
}
errorMessage = "Session ended by \(name)."
}
/// Resize overlay START (main actor from the Match-window follower's `onResizeTarget`): the
@@ -11,13 +11,7 @@
// 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 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
// The trailing Profiles section (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.
@@ -33,19 +27,7 @@ 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(\.gamepadInk) private var ink
@Environment(\.dismiss) private var dismiss
/// The saved-host store the pin picker writes `setPinned` through it and the profile rows
/// count pins from its live hosts. Threaded in from GamepadHomeView like the home screen
@@ -73,9 +55,6 @@ 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
@@ -95,23 +74,12 @@ 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?
@@ -125,8 +93,7 @@ struct GamepadSettingsView: View {
focusID: $focusID,
onAdjust: { row, delta in adjust(id: row.id, by: delta) },
onActivate: { activate(id: $0.id) },
onBack: { back() },
onShoulder: { step(tabBy: $0) }
onBack: { back() }
) { row, focused in
rowView(row, focused: focused)
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
@@ -134,25 +101,20 @@ struct GamepadSettingsView: View {
}
.frame(maxWidth: .infinity)
.safeAreaInset(edge: .top, spacing: 0) {
VStack(spacing: compact ? 4 : 8) {
Text(title)
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(ink.fg)
.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) }
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) }
}
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 8) {
Text(focusedDetail)
.font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption))
.foregroundStyle(ink.fg(0.55))
.foregroundStyle(.white.opacity(0.55))
.lineLimit(2, reservesSpace: true)
.animation(.smooth(duration: 0.2), value: focusID)
GamepadHintBar(hints: hints)
@@ -165,13 +127,9 @@ struct GamepadSettingsView: View {
.frame(maxWidth: .infinity, alignment: .leading)
.background { GamepadTrayScrim(edge: .bottom) }
}
// The launcher's living field, calmed (GamepadFormBackground) the glass rows keep real
// colour and luminance to lens without the launcher's contrast, and the palette setting
// applies here too, so this screen previews the row you're stepping.
// 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.
.background { GamepadFormBackground() }
// Publish the palette's ink to this screen (text, glass, accent, scrims) a
// pale palette flips all of them, and no leaf should have to read the setting.
.gamepadPaletteInk()
.onAppear {
gamepads.refresh()
gamepads.startDiscovery()
@@ -179,108 +137,13 @@ 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 ? ink.fg : ink.fg(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(ink.accent(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 {
Button { dismiss() } label: {
Image(systemName: "xmark")
.font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold))
.foregroundStyle(ink.fg)
.foregroundStyle(.white)
.frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide)
.glassBackground(Circle(), interactive: true)
.contentShape(Circle())
@@ -303,19 +166,12 @@ 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 sections
+ [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done")]
return [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done")]
}
return sections + [
return [
.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"),
@@ -345,24 +201,30 @@ 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))
.foregroundStyle(focused ? ink.accent : ink.fg(0.55))
.foregroundStyle(focused ? Color.brand : .white.opacity(0.55))
.frame(width: m.iconWidth)
Text(row.label)
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
.foregroundStyle(ink.fg)
.foregroundStyle(.white)
.lineLimit(1)
Spacer(minLength: 12)
HStack(spacing: 9) {
Image(systemName: "chevron.left")
.font(.system(size: m.chevronFont, weight: .semibold))
.foregroundStyle(
ink.fg(focused && row.adjustable && row.enabled ? 0.6 : 0))
.white.opacity(focused && row.adjustable && row.enabled ? 0.6 : 0))
// Keyed by the value so a change slides the new option in instead of
// hard-swapping the string a QUIET horizontal slip following the user's
// motion (a right-step enters from the right), crossfading over ~14 pt.
@@ -373,7 +235,7 @@ struct GamepadSettingsView: View {
ZStack {
Text(row.value)
.font(.geist(m.valueFont, .medium, relativeTo: .callout))
.foregroundStyle(focused ? ink.fg : ink.fg(0.6))
.foregroundStyle(focused ? .white : .white.opacity(0.6))
.lineLimit(1)
.id(row.value)
.transition(.asymmetric(
@@ -384,7 +246,7 @@ struct GamepadSettingsView: View {
Image(systemName: "chevron.right")
.font(.system(size: m.chevronFont, weight: .semibold))
.foregroundStyle(
ink.fg(focused && row.adjustable && row.enabled ? 0.6 : 0))
.white.opacity(focused && row.adjustable && row.enabled ? 0.6 : 0))
}
}
// Contents only the glass and border below stay at full strength, so a dimmed row
@@ -395,11 +257,11 @@ struct GamepadSettingsView: View {
// Every row is Liquid Glass; the focused one takes a brand wash and reacts to press.
.consoleGlass(
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous),
tint: focused ? ink.accent(0.30) : nil,
tint: focused ? Color.brand.opacity(0.30) : nil,
interactive: focused)
.overlay {
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous)
.strokeBorder(ink.fg(focused ? 0.28 : 0.06), lineWidth: 1)
.strokeBorder(.white.opacity(focused ? 0.28 : 0.06), lineWidth: 1)
}
.scaleEffect(focused ? 1.0 : 0.98)
.animation(.smooth(duration: 0.18), value: focused)
@@ -414,9 +276,8 @@ struct GamepadSettingsView: View {
private struct Row: Identifiable {
let id: 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
/// Section header drawn above this row (the first row of each group carries it).
var header: String?
let icon: String
let label: String
let value: String
@@ -452,17 +313,10 @@ 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) }
@@ -470,7 +324,7 @@ struct GamepadSettingsView: View {
let controllers = SettingsOptions.controllerOptions(gamepads)
var list: [Row] = [
choiceRow(
id: "resolution", tab: .stream, icon: "aspectratio",
id: "resolution", header: "Stream", icon: "aspectratio",
label: "Resolution",
detail: "The host creates a virtual display at exactly this size — no scaling.",
options: resolution, current: "\(width)x\(height)"
@@ -481,48 +335,53 @@ struct GamepadSettingsView: View {
height = parts[1]
},
choiceRow(
id: "refresh", tab: .stream, icon: "gauge.with.needle", label: "Refresh rate",
id: "refresh", icon: "gauge.with.needle", label: "Refresh rate",
detail: "Rates this display can actually show.",
options: refresh, current: hz
) { hz = $0 },
choiceRow(
id: "bitrate", tab: .stream, icon: "speedometer", label: "Bitrate",
id: "bitrate", 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", tab: .stream, icon: "macwindow", label: "Compositor",
id: "compositor", 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", tab: .video, icon: "film", label: "Video codec",
id: "codec", header: "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", tab: .video, icon: "sun.max", label: "10-bit HDR",
id: "hdr", 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", tab: .video, icon: "textformat", label: "Full chroma (4:4:4)",
id: "chroma", 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", tab: .video, icon: "rectangle.stack", label: "Prioritize",
id: "presentPriority", 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", tab: .video, icon: "square.stack.3d.up",
label: "Smoothness buffer",
id: "smoothBuffer", 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.",
@@ -530,22 +389,22 @@ struct GamepadSettingsView: View {
) { smoothBuffer = $0 },
choiceRow(
id: "audio", tab: .audio, icon: "speaker.wave.2", label: "Audio channels",
id: "audio", header: "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", tab: .audio, icon: "mic", label: "Microphone",
id: "mic", icon: "mic", label: "Microphone",
detail: "Send this device's microphone to the host's virtual mic.",
value: $micEnabled),
toggleRow(
id: "echoCancel", tab: .audio, icon: "waveform", label: "Echo cancellation",
id: "echoCancel", 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", tab: .controller, icon: "gamecontroller",
id: "padForward", header: "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 "
@@ -556,28 +415,26 @@ 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", tab: .controller, icon: "gamecontroller", label: "Use controller",
id: "pad", 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", tab: .controller, icon: "dpad", label: "Controller type",
id: "padType", 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", tab: .controller, icon: "house.circle",
label: "Guide button",
id: "systemButtons", 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", tab: .controller, icon: "hand.point.up.left",
label: "Hold Select for guide",
id: "guideGesture", 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,
@@ -585,47 +442,33 @@ struct GamepadSettingsView: View {
) { guideGesture = $0 },
choiceRow(
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",
id: "hud", header: "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", tab: .interface, icon: "rectangle.inset.topright.filled",
label: "Overlay position",
id: "hudPlacement", 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", tab: .interface, icon: "square.grid.2x2", label: "Game library",
id: "library", icon: "square.grid.2x2", label: "Game library",
detail: "Browse and launch the host's games with \(buttonName(\.buttonY, "Y")).",
value: $libraryEnabled),
toggleRow(
id: "gamepadUI", tab: .interface, icon: "hand.tap",
label: "Controller-optimized UI",
id: "gamepadUI", 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 tab) macOS only, mirroring the touch SettingsView's Presentation row
// the Video group) 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", tab: .video, icon: "macwindow.badge.plus",
id: "windowedSafePresent", 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 "
@@ -635,14 +478,14 @@ struct GamepadSettingsView: View {
}
#endif
#if os(iOS)
// 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).
// 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).
if CHHapticEngine.capabilitiesForHardware().supportsHaptics,
let at = list.firstIndex(where: { $0.id == "padType" }) {
list.insert(
toggleRow(
id: "deviceRumble", tab: .controller,
icon: "iphone.radiowaves.left.and.right",
id: "deviceRumble", 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.",
@@ -662,17 +505,17 @@ struct GamepadSettingsView: View {
private var profileRows: [Row] {
guard !profiles.profiles.isEmpty else {
return [Row(
id: "noProfiles", tab: .profiles, icon: "slider.horizontal.3",
id: "noProfiles", header: "Profiles", icon: "slider.horizontal.3",
label: "No profiles yet", value: "",
detail: emptyCatalogDetail,
adjustable: false,
adjust: { _ in false }, activate: {})]
}
return profiles.profiles.map { profile in
return profiles.profiles.enumerated().map { i, profile in
let pins = store.hosts
.filter { ($0.pinnedProfileIDs ?? []).contains(profile.id) }.count
return Row(
id: "profile-\(profile.id)", tab: .profiles,
id: "profile-\(profile.id)", header: i == 0 ? "Profiles" : nil,
icon: "slider.horizontal.3", label: profile.name,
value: pins == 0 ? "Not pinned" : "Pinned to \(pins) host\(pins == 1 ? "" : "s")",
detail: profileDetail,
@@ -694,8 +537,7 @@ struct GamepadSettingsView: View {
private func pinRows(for profile: StreamProfile) -> [Row] {
guard !store.hosts.isEmpty else {
return [Row(
id: "noHosts", tab: .profiles, icon: "desktopcomputer",
label: "No saved hosts yet",
id: "noHosts", icon: "desktopcomputer", label: "No saved hosts yet",
value: "",
detail: "Pair with a host first, then pin this profile to it.",
adjustable: false,
@@ -705,7 +547,7 @@ struct GamepadSettingsView: View {
let hostID = host.id
let pinned = (host.pinnedProfileIDs ?? []).contains(profile.id)
return Row(
id: "pinHost-\(hostID.uuidString)", tab: .profiles, icon: "desktopcomputer",
id: "pinHost-\(hostID.uuidString)", icon: "desktopcomputer",
label: host.displayName,
value: pinned ? "Pinned" : "Off",
detail: "A pinned profile appears as its own card on the host — one press "
@@ -767,13 +609,13 @@ struct GamepadSettingsView: View {
// MARK: - Row builders
private func choiceRow<T: Equatable>(
id: String, tab: GpSettingsTab, icon: String, label: String, detail: String,
id: String, header: String? = nil, 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, tab: tab, icon: icon, label: label,
id: id, header: header, icon: icon, label: label,
value: index.map { options[$0].label } ?? "",
detail: detail,
enabled: enabled,
@@ -796,11 +638,11 @@ struct GamepadSettingsView: View {
}
private func toggleRow(
id: String, tab: GpSettingsTab, icon: String, label: String, detail: String,
id: String, header: String? = nil, icon: String, label: String, detail: String,
value: Binding<Bool>, enabled: Bool = true
) -> Row {
Row(
id: id, tab: tab, icon: icon, label: label,
id: id, header: header, icon: icon, label: label,
value: value.wrappedValue ? "On" : "Off",
detail: detail,
enabled: enabled,
@@ -171,26 +171,14 @@ 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)
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
native = [("This device",
Int(max(bounds.width, bounds.height)),
Int(min(bounds.width, bounds.height)))]
#else
if let screen = NSScreen.main {
let scale = screen.backingScaleFactor
@@ -203,26 +191,6 @@ 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
@@ -80,12 +80,6 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
let shape: S
var tint: Color?
var interactive = false
/// The console surface follows the background palette: a PALE field needs the material to
/// frost light and the glass to read as white, or the dark ink on top of it disappears.
/// Defaults to the dark ink, so every non-gamepad caller is unchanged.
@Environment(\.gamepadInk) private var ink
private var scheme: ColorScheme { ink.isLight ? .light : .dark }
func body(content: Content) -> some View {
#if os(tvOS)
@@ -95,16 +89,16 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
// the 10-foot platform). The tint rides an overlay so the focused row keeps its wash.
content.background {
shape.fill(.ultraThinMaterial)
.environment(\.colorScheme, scheme)
.environment(\.colorScheme, .dark)
.overlay {
if let tint { shape.fill(tint) }
}
}
#else
if #available(iOS 26, macOS 26, *) {
content.glassEffect(glass, in: shape).environment(\.colorScheme, scheme)
content.glassEffect(glass, in: shape)
} else {
content.background { shape.fill(.ultraThinMaterial).environment(\.colorScheme, scheme) }
content.background { shape.fill(.ultraThinMaterial).environment(\.colorScheme, .dark) }
}
#endif
}
@@ -9,25 +9,6 @@
//
// 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
@@ -67,50 +48,12 @@ 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?
/// 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)] = [:]
/// 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] = [:]
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<String> = []
/// 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<Void, Never>?
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() {}
@@ -120,73 +63,34 @@ public final class HostDiscovery: ObservableObject {
guard !debugPinned else { return } // a seeded advert set outranks the live LAN
#endif
guard browser == nil else { return }
armBrowser()
startSweep()
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)
}
/// 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()
deadlines.removeAll()
services.removeAll()
addresses.removeAll()
failures.removeAll()
retryAt.removeAll()
staleAddresses.removeAll()
browserFailures = 0
browserRearmAt = nil
scanningUntil = nil
if isScanning { isScanning = false }
resolved.removeAll()
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() }
}
@@ -220,103 +124,48 @@ public final class HostDiscovery: ObservableObject {
}
#endif
// 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)
private func restart() {
stop()
start()
}
/// 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.
/// Diff the browser's current result set against what we're tracking: drop departed
/// services, resolve newly-seen ones.
private func reconcile(_ results: Set<NWBrowser.Result>) {
var live: Set<String> = []
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
}
for result in results {
let key = Self.key(result)
live.insert(key)
services[key] = result
if resolved[key] == nil, connections[key] == nil { resolve(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). 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) {
/// 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") ?? "")
}
// 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
@@ -328,125 +177,44 @@ 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
// 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 }
guard let self, let conn = self.connections[key] else { return }
switch state {
case .ready:
let endpoint = conn.currentPath?.remoteEndpoint
self.connections[key] = nil
self.deadlines[key] = nil
conn.cancel()
if case let .hostPort(host, port)? = endpoint,
if case let .hostPort(host, port)? = conn.currentPath?.remoteEndpoint,
let address = Self.hostString(host) {
self.addresses[key] = (address, port.rawValue)
self.failures[key] = nil
self.retryAt[key] = nil
self.staleAddresses.remove(key)
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()
} else {
// Ready but no usable remote a failed attempt, not a finished one.
self.resolveFailed(key)
}
conn.cancel()
self.connections[key] = nil
case .failed, .cancelled:
self.connections[key] = nil
self.deadlines[key] = nil
self.resolveFailed(key)
default:
break // .preparing / .waiting the sweep's deadline is what ends these
break
}
}
}
conn.start(queue: .main)
}
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.
/// Publish the resolved set, 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 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
}
for host in resolved.values { 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)"
}
@@ -38,46 +38,12 @@ public struct LaunchSpec: Codable, Hashable, Sendable {
/// One title in the unified library. `id` is store-qualified: `steam:<appid>` / `custom:<id>`.
public struct GameEntry: Codable, Hashable, Identifiable, Sendable {
public var id: String
public var store: String // "steam" | "custom" | "lutris" | "heroic" | "epic" | "gog" | "xbox"
public var store: String // "steam" | "custom"
public var title: String
public var art: Artwork
public var launch: LaunchSpec?
/// `"game"` (the default, and what an older host omits) or `"launcher"` an entry that opens
/// the launcher itself (Steam Big Picture, Heroic) rather than a title. Deliberately a plain
/// optional String: the host owns the vocabulary, and an unknown future value must never fail
/// the whole library decode. Anything that isn't `"launcher"` is a game (design D4).
public var role: String?
public var isCustom: Bool { store == "custom" }
/// Whether this entry opens a launcher rather than a game.
public var isLauncher: Bool { role == "launcher" }
/// Display name for the store badge the same table the Rust clients use
/// (`pf-console-ui::library::store_label`). Before this existed the badge said "Steam" for
/// every non-custom entry, which a Lutris or GOG title made a lie.
public var storeLabel: String {
switch store {
case "steam": return "Steam"
case "custom": return "Custom"
case "heroic": return "Heroic"
case "lutris": return "Lutris"
case "epic": return "Epic"
case "gog": return "GOG"
case "xbox": return "Xbox"
default: return "Game"
}
}
}
public extension Array where Element == GameEntry {
/// Design D4: launcher entries lead the shelf, and the host's title order survives within each
/// group. Applied once where the library is fetched, so no individual view has to remember
/// the rule and a library without launcher entries comes back untouched.
var launchersFirst: [GameEntry] {
let launchers = filter(\.isLauncher)
return launchers.isEmpty ? self : launchers + filter { !$0.isLauncher }
}
}
/// Errors surfaced to the UI so it can guide setup (the common case is "not paired yet").
@@ -1430,49 +1430,6 @@ 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).
@@ -538,25 +538,14 @@ public final class StreamLayerView: NSView {
}
}
/// Tell the host who renders the pointer (the §8 mid-stream render flip). The host may
/// composite one into the video ONLY while we are holding a grabbed, hidden pointer the
/// capture model, engaged. That is the one state with no local cursor on screen.
///
/// Every other state leaves a normal OS cursor visible over the video: the desktop model
/// draws it wearing the host's shape, and a RELEASED view shows the plain arrow. A
/// host-composited pointer then appears *underneath* it as a second cursor and, because a
/// released view forwards no motion, one that never moves. On glass that reads as a frozen
/// duplicate stuck wherever the host pointer was last left (verified: `client_draws=false
/// blended=true live=(-1, 622)` parked on the streamed output's left edge while the user
/// moved their own cursor around freely).
///
/// So "released" counts as WE draw it: the host stops compositing, the client keeps
/// receiving shape/state over the channel (the forwarder only ticks on this side of the
/// flip), and re-engaging is seamless. One edge-detected reconciler, called from every
/// Tell the host who renders the pointer (the §8 mid-stream render flip): we draw it only
/// while the DESKTOP model is engaged (the local OS cursor wears the host shape); under
/// the capture model and while released the host composites it into the video (full
/// fidelity, the pre-channel look). One edge-detected reconciler, called from every
/// transition (chord, engage/release, session start).
private func reconcileCursorRender() {
guard cursorChannelActive, let connection else { return }
let clientDraws = !captured || desktopMouse
let clientDraws = captured && desktopMouse
guard sentClientDraws != clientDraws else { return }
sentClientDraws = clientDraws
connection.setCursorRender(clientDraws: clientDraws)
@@ -225,15 +225,6 @@ 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
@@ -349,80 +340,12 @@ 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<UIPress>, 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<UIPress>, 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<UIPress>, 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)
@@ -555,7 +478,6 @@ 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()
}
@@ -827,16 +749,10 @@ 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
@@ -866,7 +782,6 @@ 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 {
@@ -875,7 +790,6 @@ 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
@@ -916,12 +830,10 @@ public final class StreamViewController: StreamViewControllerBase {
pointerRelockAttempt = 0
}
guard pointerRelockAttempt < Self.pointerRelockAttemptLimit else {
// 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.
// 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.
pointerRelockPending = false
scheduleQuietRelock()
return
}
pointerRelockAttempt += 1
@@ -969,43 +881,6 @@ 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 falsetrue 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 {
@@ -179,14 +179,6 @@ 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
@@ -1,177 +0,0 @@
// The gamepad UI's background colour families, and the ink each one calls for.
//
// A palette is a short ordered ramp of DISTINCT hues, not one hue at several brightnesses. The
// 4×4 mesh samples that ramp diagonally with a per-cell offset (`cellRamp`), so neighbouring
// cells land on different parts of it and the colours pool and swirl the way a real gradient
// poster does; the mesh's existing control-point drift then moves those pools around. An earlier
// version rotated ONE field's hue per palette, which is why every non-default palette read flat.
//
// A palette also owns the UI sitting on it: `accent` is the focus wash / selected pill / switch
// colour, and `light` flips the ink so a pale field gets dark text instead of white.
//
// The table, `ramp` and `cellRamp` are mirrored in `pf-console-ui`'s `library.rs` (Rust) and the
// Android client's `GamepadPalette.kt` (Kotlin) under the same ids, so one `ui_palette` value is
// one look on every client. Keep the three copies in step: a palette added here without the
// others is a value the other clients silently render as Violet.
//
// 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
/// The colour ramp, dark end first. Empty = use `violetMesh` verbatim (the brand default,
/// kept bit-identical to what every install already sees).
public let stops: [SIMD3<Double>]
/// The field's ground what the corners settle onto and what the calm mix lifts toward.
public let ground: SIMD3<Double>
/// The UI accent: focus wash, selected tab pill, switch track, caret.
public let accent: SIMD3<Double>
/// A pale field: the UI flips to dark ink and the legibility scrims go white.
public let light: Bool
/// Where each of the 16 mesh cells samples the ramp. The base is the diagonal
/// `0.5·(x + y)` top-left is the ramp's dark end, bottom-right its bright one and the
/// per-cell nudges break the banding a pure diagonal would give, so hues pool instead of
/// striping.
static let cellRamp: [Double] = [
0.10, -0.06, 0.04, -0.12,
-0.08, 0.14, -0.10, 0.06,
0.06, -0.12, 0.16, -0.04,
-0.10, 0.08, -0.06, 0.12,
]
/// The brand default's 16 mesh colours, row-major 4×4: dark-violet corners sink the frame,
/// the edges carry mid-tone violets, and the interior holds the bright brand family.
public static let violetMesh: [SIMD3<Double>] = {
let corner = SIMD3(0.075, 0.060, 0.160)
return [
corner, SIMD3(0.34, 0.27, 0.72), SIMD3(0.30, 0.26, 0.74), corner,
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),
corner, SIMD3(0.22, 0.18, 0.54), SIMD3(0.24, 0.20, 0.58), corner,
]
}()
/// The brand default's blob ramp the four colours the pre-18/15 legacy field used, kept so
/// `violet` is unchanged on older OSes too.
static let violetBlobs: [SIMD3<Double>] = [
SIMD3(0.53, 0.47, 0.96), SIMD3(0.24, 0.20, 0.72), SIMD3(0.62, 0.30, 0.80),
SIMD3(0.22, 0.38, 0.86), SIMD3(0.53, 0.47, 0.96),
]
/// The twelve shipped palettes: the brand default, five more dark fields, then six pale
/// ones. Cycling order runs dark light, so stepping the row walks the whole range one way.
public static let all: [GamepadPalette] = [
// --- dark fields (white ink) ---
GamepadPalette(
id: "violet", name: "Violet", stops: [],
ground: SIMD3(0.075, 0.060, 0.160), accent: SIMD3(0.525, 0.471, 0.961), light: false),
GamepadPalette(
// Deep indigo climbing through violet into a hot magenta.
id: "nebula", name: "Nebula",
stops: [SIMD3(0.07, 0.05, 0.20), SIMD3(0.26, 0.14, 0.54), SIMD3(0.52, 0.20, 0.72),
SIMD3(0.82, 0.26, 0.62), SIMD3(0.98, 0.46, 0.68)],
ground: SIMD3(0.055, 0.040, 0.135), accent: SIMD3(0.95, 0.42, 0.72), light: false),
GamepadPalette(
// Ink-blue water: teal cerulean a violet undertow.
id: "abyss", name: "Abyss",
stops: [SIMD3(0.02, 0.10, 0.17), SIMD3(0.04, 0.28, 0.42), SIMD3(0.07, 0.46, 0.63),
SIMD3(0.16, 0.38, 0.78), SIMD3(0.26, 0.22, 0.58)],
ground: SIMD3(0.018, 0.070, 0.130), accent: SIMD3(0.26, 0.76, 0.92), light: false),
GamepadPalette(
// Banked coals: plum embers crimson burnt orange gold.
id: "ember", name: "Ember",
stops: [SIMD3(0.16, 0.03, 0.10), SIMD3(0.45, 0.06, 0.12), SIMD3(0.72, 0.18, 0.06),
SIMD3(0.90, 0.42, 0.08), SIMD3(0.95, 0.68, 0.18)],
ground: SIMD3(0.090, 0.035, 0.040), accent: SIMD3(0.98, 0.62, 0.26), light: false),
GamepadPalette(
// Forest floor into moss and a lime break.
id: "moss", name: "Moss",
stops: [SIMD3(0.03, 0.11, 0.09), SIMD3(0.06, 0.27, 0.20), SIMD3(0.09, 0.45, 0.31),
SIMD3(0.28, 0.61, 0.28), SIMD3(0.58, 0.77, 0.31)],
ground: SIMD3(0.025, 0.085, 0.070), accent: SIMD3(0.48, 0.86, 0.46), light: false),
GamepadPalette(
// Neutral, but never flat: barely-there saturation that still travels from a cool
// charcoal to a warm stone.
id: "graphite", name: "Graphite",
stops: [SIMD3(0.06, 0.07, 0.11), SIMD3(0.15, 0.18, 0.25), SIMD3(0.30, 0.31, 0.35),
SIMD3(0.45, 0.42, 0.38), SIMD3(0.60, 0.56, 0.49)],
ground: SIMD3(0.055, 0.055, 0.070), accent: SIMD3(0.78, 0.80, 0.86), light: false),
// --- pale fields (dark ink) ---
GamepadPalette(
// The holographic foil: rose lilac periwinkle aqua, with a white bloom.
id: "holo", name: "Holo",
stops: [SIMD3(0.99, 0.72, 0.90), SIMD3(0.80, 0.60, 0.98), SIMD3(0.58, 0.62, 0.99),
SIMD3(0.55, 0.86, 0.98), SIMD3(0.94, 0.98, 1.00)],
ground: SIMD3(0.96, 0.92, 0.99), accent: SIMD3(0.42, 0.28, 0.86), light: true),
GamepadPalette(
// The poster sunset: periwinkle magenta scarlet tangerine gold.
id: "sunset", name: "Sunset",
stops: [SIMD3(0.55, 0.45, 0.92), SIMD3(0.86, 0.31, 0.66), SIMD3(0.97, 0.26, 0.34),
SIMD3(0.99, 0.51, 0.18), SIMD3(1.00, 0.80, 0.22)],
ground: SIMD3(0.98, 0.74, 0.34), accent: SIMD3(0.64, 0.13, 0.44), light: true),
GamepadPalette(
// Peach into blush and lilac the softest of the set.
id: "bloom", name: "Bloom",
stops: [SIMD3(1.00, 0.86, 0.72), SIMD3(0.99, 0.73, 0.79), SIMD3(0.95, 0.65, 0.89),
SIMD3(0.82, 0.68, 0.96), SIMD3(0.73, 0.79, 0.99)],
ground: SIMD3(0.99, 0.90, 0.89), accent: SIMD3(0.72, 0.24, 0.55), light: true),
GamepadPalette(
// First light: pale gold coral lilac.
id: "dawn", name: "Dawn",
stops: [SIMD3(1.00, 0.92, 0.70), SIMD3(1.00, 0.80, 0.62), SIMD3(0.99, 0.66, 0.62),
SIMD3(0.90, 0.62, 0.78), SIMD3(0.77, 0.69, 0.95)],
ground: SIMD3(1.00, 0.93, 0.82), accent: SIMD3(0.82, 0.33, 0.28), light: true),
GamepadPalette(
// Sea glass: mint aqua a pale sky.
id: "mint", name: "Mint",
stops: [SIMD3(0.82, 0.98, 0.90), SIMD3(0.62, 0.94, 0.88), SIMD3(0.55, 0.88, 0.95),
SIMD3(0.63, 0.82, 0.99), SIMD3(0.82, 0.87, 1.00)],
ground: SIMD3(0.90, 0.98, 0.96), accent: SIMD3(0.04, 0.42, 0.40), light: true),
GamepadPalette(
// Near-white, but iridescent rather than flat rose, sky, mint and cream in turn.
id: "opal", name: "Opal",
stops: [SIMD3(0.98, 0.92, 0.96), SIMD3(0.87, 0.93, 0.99), SIMD3(0.91, 0.99, 0.95),
SIMD3(0.99, 0.96, 0.88), SIMD3(0.94, 0.90, 0.99)],
ground: SIMD3(0.97, 0.96, 0.99), accent: SIMD3(0.36, 0.32, 0.44), light: true),
]
/// 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]
}
/// Sample an ordered colour ramp at `t` [0, 1] (linear between neighbouring stops).
public static func ramp(_ stops: [SIMD3<Double>], _ t: Double) -> SIMD3<Double> {
guard let first = stops.first else { return SIMD3(0, 0, 0) }
guard stops.count > 1 else { return first }
let x = min(max(t, 0), 1) * Double(stops.count - 1)
let i = min(Int(x.rounded(.down)), stops.count - 2)
let f = x - Double(i)
return stops[i] + (stops[i + 1] - stops[i]) * f
}
/// The 16 mesh colours for this palette: the ramp sampled per cell, or `violetMesh` verbatim
/// for the brand default.
public var meshColors: [SIMD3<Double>] {
guard !stops.isEmpty else { return Self.violetMesh }
return (0..<16).map { i in
let (x, y) = (Double(i % 4) / 3.0, Double(i / 4) / 3.0)
return Self.ramp(stops, 0.5 * (x + y) + Self.cellRamp[i])
}
}
/// Four drifting blob colours for the pre-18/15 legacy field. Spread across the ramp so it
/// still shows several hues at once.
public var blobColors: [SIMD3<Double>] {
let s = stops.isEmpty ? Self.violetBlobs : stops
return (0..<4).map { Self.ramp(s, 0.15 + 0.25 * Double($0)) }
}
}
@@ -1,86 +0,0 @@
// 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 4459 pt; a plain status bar (older
/// iPhones, every iPad) reports 2024 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))
}
}
@@ -1,110 +0,0 @@
// The gamepad UI's background palettes. These assertions are the CONTRACT the Rust
// (`pf-console-ui::library`) and Kotlin (`GamepadPalette.kt`) ports reproduce the same ids in
// the same order, the same light/dark split, the same ramp so one `ui_palette` value is one look
// on every client.
import XCTest
import simd
@testable import PunktfunkShared
final class GamepadPaletteTests: XCTestCase {
private func luma(_ c: SIMD3<Double>) -> Double {
0.2126 * c.x + 0.7152 * c.y + 0.0722 * c.z
}
/// Hue angle in degrees, or nil for something too grey to have one.
private func hue(_ c: SIMD3<Double>) -> Double? {
let maxV = max(c.x, c.y, c.z)
let minV = min(c.x, c.y, c.z)
let d = maxV - minV
guard d >= 0.04 else { return nil }
let h: Double
if maxV == c.x {
h = 60 * (((c.y - c.z) / d).truncatingRemainder(dividingBy: 6))
} else if maxV == c.y {
h = 60 * ((c.z - c.x) / d + 2)
} else {
h = 60 * ((c.x - c.y) / d + 4)
}
return (h + 360).truncatingRemainder(dividingBy: 360)
}
/// The brand default must still be the SHIPPED field, colour for colour. Every install
/// already sees it, and a palette table that quietly restyled the default would be a
/// regression dressed as a feature.
func testVioletIsTheUntouchedShippedField() {
let violet = GamepadPalette.named("violet")
XCTAssertEqual(GamepadPalette.all.first?.id, "violet")
XCTAssertTrue(violet.stops.isEmpty, "the default is the explicit grid")
XCTAssertEqual(violet.meshColors, GamepadPalette.violetMesh)
// An unknown name is a newer client's palette, not an error.
XCTAssertEqual(GamepadPalette.named("chartreuse").id, "violet")
XCTAssertEqual(GamepadPalette.named("").id, "violet")
}
/// Ids, order and the light/dark split are the cross-client contract.
func testTableMatchesTheOtherClients() {
XCTAssertEqual(
GamepadPalette.all.map(\.id),
["violet", "nebula", "abyss", "ember", "moss", "graphite",
"holo", "sunset", "bloom", "dawn", "mint", "opal"])
// Dark fields lead, pale ones follow, so stepping the row walks one direction.
let firstLight = GamepadPalette.all.firstIndex { $0.light }
XCTAssertEqual(firstLight, 6)
XCTAssertTrue(GamepadPalette.all.dropFirst(6).allSatisfy(\.light))
}
/// A palette must read as SEVERAL hues, not one hue at several brightnesses that was
/// exactly the complaint about the hue-rotation model this replaced.
func testEveryPaletteIsMultiTone() {
for p in GamepadPalette.all {
let hues = p.meshColors.compactMap(hue)
XCTAssertGreaterThanOrEqual(hues.count, 8, "\(p.id): too few coloured cells")
var spread = 0.0
for a in hues {
for b in hues {
let d = abs(a - b).truncatingRemainder(dividingBy: 360)
spread = max(spread, min(d, 360 - d))
}
}
// Graphite and Opal are deliberately near-neutral; the rest must travel.
let floor = (p.id == "graphite" || p.id == "opal") ? 20.0 : 45.0
XCTAssertGreaterThanOrEqual(spread, floor, "\(p.id) spans only \(spread)° of hue")
}
}
/// Every colour stays in gamut, and a pale palette really is pale its ink flips, so a
/// mislabelled one would put dark text on a dark field.
func testPalettesAreInGamutAndHonestAboutLightness() {
for p in GamepadPalette.all {
for c in p.meshColors + p.blobColors {
for v in [c.x, c.y, c.z] {
XCTAssertTrue((0...1).contains(v), "\(p.id) \(c)")
}
}
let mean = p.meshColors.map(luma).reduce(0, +) / Double(p.meshColors.count)
if p.light {
XCTAssertGreaterThan(mean, 0.5, "\(p.id) is flagged light")
XCTAssertGreaterThan(luma(p.ground), 0.6, "\(p.id)'s ground is dark")
XCTAssertLessThan(luma(p.accent), 0.45, "\(p.id)'s accent is too pale")
} else {
XCTAssertLessThan(mean, 0.45, "\(p.id) is flagged dark")
XCTAssertLessThan(luma(p.ground), 0.2, "\(p.id)'s ground is light")
XCTAssertGreaterThan(luma(p.accent), 0.25, "\(p.id)'s accent is too dark")
}
}
}
/// The ramp is the shared sampling rule the Rust and Kotlin ports reproduce.
func testRampInterpolatesBetweenStops() {
let stops = [SIMD3(0.0, 0.0, 0.0), SIMD3(1.0, 0.0, 0.0), SIMD3(1.0, 1.0, 1.0)]
XCTAssertEqual(GamepadPalette.ramp(stops, 0), SIMD3(0.0, 0.0, 0.0))
XCTAssertEqual(GamepadPalette.ramp(stops, 1), SIMD3(1.0, 1.0, 1.0))
XCTAssertEqual(GamepadPalette.ramp(stops, 0.5), SIMD3(1.0, 0.0, 0.0))
XCTAssertEqual(GamepadPalette.ramp(stops, 0.25).x, 0.5, accuracy: 1e-9)
// Out of range clamps rather than trapping.
XCTAssertEqual(GamepadPalette.ramp(stops, -3), SIMD3(0.0, 0.0, 0.0))
XCTAssertEqual(GamepadPalette.ramp(stops, 9), SIMD3(1.0, 1.0, 1.0))
XCTAssertEqual(GamepadPalette.ramp([], 0.5), SIMD3(0.0, 0.0, 0.0))
}
}
@@ -50,21 +50,5 @@ 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")
}
}
@@ -1,74 +0,0 @@
// 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)
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "Punktfunk",
"name": "punktfunk",
"author": "enrico",
"flags": ["debug"],
"api_version": 1,
+1 -3
View File
@@ -12,9 +12,7 @@
set -euo pipefail
HERE="$(cd "$(dirname "$0")/.." && pwd)"
DECK="${DECK:?set DECK=deck@<ip>}"
# 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
NAME="$(python3 -c 'import json;print(json.load(open("'"$HERE"'/plugin.json"))["name"])')"
STAGE_LOCAL="$HERE/out/$NAME"
[ -d "$STAGE_LOCAL" ] || { echo "$STAGE_LOCAL missing — run scripts/package.sh first" >&2; exit 1; }
+4 -8
View File
@@ -5,13 +5,9 @@
# package.json,decky.pyi,LICENSE,README.md}
# out/punktfunk/ (the same tree, unzipped — rsync this with scripts/deploy.sh)
#
# 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/<dir>). 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.
# 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.
set -euo pipefail
HERE="$(cd "$(dirname "$0")/.." && pwd)"
cd "$HERE"
@@ -19,7 +15,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=punktfunk # the on-disk plugin dir (see the header) — NOT plugin.json "name"
NAME="$(python3 -c 'import json;print(json.load(open("plugin.json"))["name"])')"
VER="$(python3 -c 'import json;print(json.load(open("package.json"))["version"])')"
STAGE="$(mktemp -d)"
+2 -24
View File
@@ -122,25 +122,6 @@ 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.
*
@@ -153,7 +134,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: hostLabel(s, advert),
name: s.name || s.addr,
addr: advert?.addr ?? s.addr,
port: advert?.port ?? s.port,
fp: s.fp_hex,
@@ -406,10 +387,7 @@ 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,
// 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",
"punktfunk",
info.latest,
info.hash,
INSTALL_TYPE_UPDATE,
+3 -5
View File
@@ -337,11 +337,9 @@ export default definePlugin(() => {
// controller config. Fire-and-forget: cosmetic library upkeep must never block plugin load.
void ensureGamepadUiShortcut();
return {
// `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",
// `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",
// `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: <div className={staticClasses?.Title}>Punktfunk</div>,
+3 -12
View File
@@ -70,18 +70,9 @@ declare const appStore:
* entry from a false "missing". A confident null means the shortcut was deleted recreate. */
function shortcutStillExists(appId: number): boolean {
try {
// 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;
const get = appStore?.GetAppOverviewByAppID;
if (!get) return true; // no way to verify — preserve the reuse path
return get(appId) != null;
} catch {
return true;
}
-7
View File
@@ -61,13 +61,6 @@ const CSS: &str = "
.pf-poster { border-radius: 10px; background: alpha(currentColor, 0.08); }
.pf-poster-monogram { font-size: 2.4em; font-weight: bold; color: alpha(currentColor, 0.45); }
.pf-store-badge { color: white; background: rgba(0, 0, 0, 0.55); }
/* Launcher entries (design D4) open the launcher itself. They rarely have poster art, so an
art-less one must not read as a game whose cover failed to load: accent face, the launcher
named instead of a title monogram, and an accent badge. */
.pf-poster.pf-launcher { background: alpha(@accent_color, 0.18); }
.pf-poster-launcher-name { font-size: 1.15em; font-weight: bold; color: alpha(currentColor, 0.85); }
.pf-store-badge.pf-launcher { color: white; background: @accent_color; }
.pf-group-heading { font-size: 0.8em; font-weight: bold; color: alpha(currentColor, 0.55); }
";
/// Everything the shell shares below the component tree.
+2 -11
View File
@@ -204,18 +204,10 @@ pub fn headless_library(target: &str) -> glib::ExitCode {
});
match crate::library::fetch_games(&addr, port, &identity, pin) {
Ok(games) => {
// A fourth column, appended: `game` or `launcher` (design D4). Appended rather than
// folded into an existing field so anything reading the first three columns is
// untouched.
for g in &games {
let role = if g.is_launcher() { "launcher" } else { "game" };
println!("{}\t{}\t{}\t{}", g.id, g.store, g.title, role);
}
let launchers = games.iter().filter(|g| g.is_launcher()).count();
match launchers {
0 => println!("{} game(s)", games.len()),
n => println!("{} game(s), {} launcher(s)", games.len() - n, n),
println!("{}\t{}\t{}", g.id, g.store, g.title);
}
println!("{} game(s)", games.len());
glib::ExitCode::SUCCESS
}
Err(e) => {
@@ -781,7 +773,6 @@ fn mock_library() -> (
title: title.to_string(),
art: crate::library::Artwork::default(),
platform: None,
role: None,
};
let games = vec![
game("steam:570", "steam", "Dota 2"),
+1 -24
View File
@@ -674,9 +674,6 @@ pub struct HostsPage {
saved: FactoryVecDeque<HostCard>,
discovered: FactoryVecDeque<HostCard>,
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<discovery::Rescan>,
}
struct PageWidgets {
@@ -696,10 +693,6 @@ 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<String, bool>),
/// Mark the card matching `ConnectRequest::card_key` as connecting; `None` restores.
@@ -848,13 +841,6 @@ 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"));
@@ -881,8 +867,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 {
@@ -951,7 +937,6 @@ impl SimpleComponent for HostsPage {
disc_heading,
searching,
},
rescan: Some(rescan),
};
model.rebuild();
@@ -969,14 +954,6 @@ 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();
+3 -74
View File
@@ -28,12 +28,6 @@ struct State {
req: ConnectRequest,
stack: gtk::Stack,
flow: gtk::FlowBox,
/// Launcher entries (design D4) get their own shelf above the games, so a handful of ways to
/// open a launcher aren't buried in a 400-title grid. Hidden outright when there are none.
launcher_flow: gtk::FlowBox,
launchers_group: gtk::Box,
/// The "Games" heading — only earns its space once a Launchers shelf is above it.
games_heading: gtk::Label,
error_page: adw::StatusPage,
/// Per-page poster cache (entry id → texture) — a Retry re-renders without refetching.
art: RefCell<HashMap<String, gdk::Texture>>,
@@ -100,44 +94,11 @@ fn build(
flow.connect_child_activated(|_, child| {
child.activate();
});
// The launcher shelf: same tile geometry as the games grid, its own FlowBox so the two
// groups never interleave and each wraps on its own.
let launcher_flow = gtk::FlowBox::builder()
.selection_mode(gtk::SelectionMode::None)
.activate_on_single_click(true)
.homogeneous(true)
.min_children_per_line(2)
.max_children_per_line(6)
.column_spacing(12)
.row_spacing(18)
.valign(gtk::Align::Start)
.build();
launcher_flow.connect_child_activated(|_, child| {
child.activate();
});
let launchers_heading = gtk::Label::new(Some("Launchers"));
launchers_heading.add_css_class("pf-group-heading");
launchers_heading.set_halign(gtk::Align::Start);
launchers_heading.set_margin_bottom(8);
let launchers_group = gtk::Box::new(gtk::Orientation::Vertical, 0);
launchers_group.append(&launchers_heading);
launchers_group.append(&launcher_flow);
launchers_group.set_margin_bottom(24);
launchers_group.set_visible(false);
let games_heading = gtk::Label::new(Some("Games"));
games_heading.add_css_class("pf-group-heading");
games_heading.set_halign(gtk::Align::Start);
games_heading.set_margin_bottom(8);
games_heading.set_visible(false);
let content = gtk::Box::new(gtk::Orientation::Vertical, 0);
content.set_margin_top(24);
content.set_margin_bottom(24);
content.set_margin_start(12);
content.set_margin_end(12);
content.append(&launchers_group);
content.append(&games_heading);
content.append(&flow);
let clamp = adw::Clamp::builder()
.maximum_size(1100)
@@ -205,9 +166,6 @@ fn build(
req,
stack,
flow,
launcher_flow,
launchers_group,
games_heading,
error_page,
art: RefCell::new(HashMap::new()),
pics: RefCell::new(HashMap::new()),
@@ -266,41 +224,18 @@ fn load(state: &Rc<State>) {
/// immediately; the rest keep their monogram placeholder until `load_art` delivers.
fn render(state: &Rc<State>, games: &[GameEntry]) {
state.flow.remove_all();
state.launcher_flow.remove_all();
state.pics.borrow_mut().clear();
// Design D4: launchers never interleave with titles. The host already sorts by title, and
// `partition` is stable, so each group keeps that order.
let (launchers, titles): (Vec<&GameEntry>, Vec<&GameEntry>) =
games.iter().partition(|g| g.is_launcher());
for game in &launchers {
state.launcher_flow.append(&game_card(state, game));
}
for game in &titles {
for game in games {
state.flow.append(&game_card(state, game));
}
// A library with no launcher entries looks exactly as it did before this existed.
state.launchers_group.set_visible(!launchers.is_empty());
state
.games_heading
.set_visible(!launchers.is_empty() && !titles.is_empty());
}
/// One poster tile: 2:3 art (~150×225 logical) over the title, with a store badge and a
/// monogram placeholder underneath the async art. Activation starts a session launching
/// this title (silent on a pinned host — the normal trust gate applies).
fn game_card(state: &Rc<State>, game: &GameEntry) -> gtk::FlowBoxChild {
// A launcher usually ships no poster. Naming the launcher on an accent face says "opens
// Steam"; a title monogram on the neutral face would say "a game whose cover didn't load".
let launcher = game.is_launcher();
let monogram = if launcher {
let l = gtk::Label::new(Some(store_label(&game.store)));
l.add_css_class("pf-poster-launcher-name");
l
} else {
let l = gtk::Label::new(Some(&initials(&game.title)));
l.add_css_class("pf-poster-monogram");
l
};
let monogram = gtk::Label::new(Some(&initials(&game.title)));
monogram.add_css_class("pf-poster-monogram");
monogram.set_halign(gtk::Align::Center);
monogram.set_valign(gtk::Align::Center);
let placeholder = gtk::Box::new(gtk::Orientation::Vertical, 0);
@@ -317,9 +252,6 @@ fn game_card(state: &Rc<State>, game: &GameEntry) -> gtk::FlowBoxChild {
let badge = gtk::Label::new(Some(store_label(&game.store)));
badge.add_css_class("pf-pill");
badge.add_css_class("pf-store-badge");
if launcher {
badge.add_css_class("pf-launcher");
}
badge.set_halign(gtk::Align::Start);
badge.set_valign(gtk::Align::Start);
badge.set_margin_start(6);
@@ -330,9 +262,6 @@ fn game_card(state: &Rc<State>, game: &GameEntry) -> gtk::FlowBoxChild {
poster.add_overlay(&pic);
poster.add_overlay(&badge);
poster.add_css_class("pf-poster");
if launcher {
poster.add_css_class("pf-launcher");
}
poster.set_overflow(gtk::Overflow::Hidden);
poster.set_size_request(150, 225);
poster.set_halign(gtk::Align::Center);
+1 -10
View File
@@ -46,13 +46,8 @@ pub fn wake_and_connect(
let sender = sender.clone();
glib::spawn_future_local(async move {
use std::time::Duration;
let (events, rescan) = crate::discovery::browse();
let events = 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();
@@ -105,10 +100,6 @@ pub fn wake_and_connect(
}
None => {}
}
ticks += 1;
if ticks % 5 == 0 {
rescan.request();
}
glib::timeout_future(Duration::from_secs(1)).await;
}
});
+1 -15
View File
@@ -343,7 +343,6 @@ 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)
})
@@ -374,14 +373,11 @@ struct ServiceState {
last_probe: Instant,
/// Cancels the active wake thread (it owns the model's wake status).
wake_cancel: Option<Arc<AtomicBool>>,
/// Forces the mDNS browse to re-query. Installed by `run`; `None` before it starts.
rescan: Option<discovery::Rescan>,
}
impl ServiceState {
fn run(mut self, stop: Arc<AtomicBool>) {
let (discovery_rx, rescan) = discovery::browse();
self.rescan = Some(rescan);
let discovery_rx = discovery::browse();
while !stop.load(Ordering::SeqCst) {
// mDNS churn.
while let Ok(ev) = discovery_rx.try_recv() {
@@ -516,14 +512,6 @@ 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,
@@ -803,7 +791,6 @@ fn spawn_fetch(
id: g.id.clone(),
title: g.title.clone(),
store: g.store.clone(),
launcher: g.is_launcher(),
})
.collect(),
);
@@ -844,7 +831,6 @@ fn load_fake(shared: &LibraryShared, path: &str) {
id: g.id.clone(),
title: g.title.clone(),
store: g.store.clone(),
launcher: g.is_launcher(),
})
.collect(),
);
+1 -9
View File
@@ -490,13 +490,9 @@ fn wake_and_connect(
let (ctx, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
std::thread::spawn(move || {
let (rx, rescan) = crate::discovery::browse();
let rx = crate::discovery::browse();
let mut seen: Vec<DiscoveredHost> = 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) {
@@ -559,10 +555,6 @@ fn wake_and_connect(
}
None => {}
}
ticks += 1;
if ticks.is_multiple_of(5) {
rescan.request();
}
std::thread::sleep(Duration::from_secs(1));
}
});
-16
View File
@@ -595,22 +595,6 @@ 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 {
+28 -85
View File
@@ -39,10 +39,6 @@ pub(crate) struct Game {
pub(crate) id: String,
pub(crate) title: String,
pub(crate) store: String,
/// This entry opens the launcher itself (Steam Big Picture, Heroic) rather than a title —
/// design D4. Reduced from the wire's `role` by `GameEntry::is_launcher`, so "anything that
/// isn't `launcher` is a game" is decided in one place for every client.
pub(crate) launcher: bool,
}
#[derive(Clone, PartialEq, Default)]
@@ -139,7 +135,6 @@ pub(crate) fn start_fetch(ctx: &Arc<AppCtx>, set_library: &AsyncSetState<Library
id: g.id.clone(),
title: g.title.clone(),
store: g.store.clone(),
launcher: g.is_launcher(),
})
.collect(),
);
@@ -220,17 +215,6 @@ fn initials(title: &str) -> String {
.collect()
}
/// A small group label above a tile grid ("Launchers" / "Games"). Only drawn when the page shows
/// both groups — a single unlabelled grid is what every launcher-less library looked like before.
fn group_heading(text: &str) -> Element {
text_block(text)
.font_size(12.0)
.semibold()
.foreground(ThemeRef::SecondaryText)
.margin(edges(2.0, 8.0, 2.0, 2.0))
.into()
}
/// One poster tile: the artwork (or a monogram placeholder while it loads) with the store
/// badge overlaid top-left, the title below, tap-to-launch across the whole tile.
fn poster_tile(
@@ -244,20 +228,13 @@ fn poster_tile(
.stretch(Stretch::UniformToFill)
.height(poster_h)
.into(),
// A launcher rarely has poster art, and an art-less launcher drawn like an art-less game
// reads as "a game whose cover failed to load". So it names its launcher instead of
// showing a title monogram, and the frame below picks up the accent stroke.
None => border(
text_block(if game.launcher {
store_label(&game.store).to_string()
} else {
initials(&game.title)
})
.font_size(if game.launcher { 18.0 } else { 28.0 })
.semibold()
.foreground(ThemeRef::SecondaryText)
.horizontal_alignment(HorizontalAlignment::Center)
.vertical_alignment(VerticalAlignment::Center),
text_block(initials(&game.title))
.font_size(28.0)
.semibold()
.foreground(ThemeRef::SecondaryText)
.horizontal_alignment(HorizontalAlignment::Center)
.vertical_alignment(VerticalAlignment::Center),
)
.background(ThemeRef::SubtleFill)
.height(poster_h)
@@ -265,27 +242,14 @@ fn poster_tile(
};
let framed = border(grid(vec![
poster,
// `Pill::Info` rather than a solid accent fill — `style.rs` is explicit that
// white-on-bright is unreadable here.
pill(
store_label(&game.store),
if game.launcher {
Pill::Info
} else {
Pill::Neutral
},
)
.horizontal_alignment(HorizontalAlignment::Left)
.vertical_alignment(VerticalAlignment::Top)
.margin(uniform(6.0))
.into(),
pill(store_label(&game.store), Pill::Neutral)
.horizontal_alignment(HorizontalAlignment::Left)
.vertical_alignment(VerticalAlignment::Top)
.margin(uniform(6.0))
.into(),
]))
.corner_radius(8.0)
.border_brush(if game.launcher {
ThemeRef::Accent
} else {
ThemeRef::CardStroke
})
.border_brush(ThemeRef::CardStroke)
.border_thickness(uniform(1.0));
border(
@@ -368,43 +332,22 @@ pub(crate) fn library_page(props: &LibraryProps, cx: &mut RenderCx) -> Element {
.into(),
),
LibraryPhase::Ready(games) => {
let tile = |g: &Game| -> Element {
let (ctx2, ss, st) = (ctx.clone(), ss.clone(), st.clone());
let (target, id) = (target.clone(), g.id.clone());
poster_tile(
g,
props.state.art.get(&g.id).map(String::as_str),
poster_h,
Box::new(move || initiate_launch(&ctx2, target.clone(), id.clone(), &ss, &st)),
)
};
// Design D4: launcher entries get their own shelf above the titles, never
// interleaved. `partition` is stable, so the host's title order survives in each
// group. Headings appear only when both groups exist, so a library without launcher
// entries renders exactly as it did before.
let (launchers, titles): (Vec<&Game>, Vec<&Game>) =
games.iter().partition(|g| g.launcher);
let both = !launchers.is_empty() && !titles.is_empty();
if !launchers.is_empty() {
if both {
body.push(group_heading("Launchers"));
}
body.push(tile_grid(
launchers.iter().map(|g| tile(g)).collect(),
cols,
POSTER_GAP,
));
}
if !titles.is_empty() {
if both {
body.push(group_heading("Games"));
}
body.push(tile_grid(
titles.iter().map(|g| tile(g)).collect(),
cols,
POSTER_GAP,
));
}
let tiles: Vec<Element> = games
.iter()
.map(|g| {
let (ctx2, ss, st) = (ctx.clone(), ss.clone(), st.clone());
let (target, id) = (target.clone(), g.id.clone());
poster_tile(
g,
props.state.art.get(&g.id).map(String::as_str),
poster_h,
Box::new(move || {
initiate_launch(&ctx2, target.clone(), id.clone(), &ss, &st)
}),
)
})
.collect();
body.push(tile_grid(tiles, cols, POSTER_GAP));
}
}
+1 -7
View File
@@ -147,10 +147,6 @@ impl PartialEq for Svc {
#[derive(Default)]
pub(crate) struct Shared {
pub(crate) target: Mutex<Target>,
/// 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<Option<discovery::Rescan>>,
/// 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<crate::spawn::SessionChild>,
@@ -463,10 +459,8 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
cx.use_effect((), {
let set_hosts = set_hosts.clone();
let ctx = ctx.clone();
move || {
let (rx, rescan) = discovery::browse();
*ctx.shared.rescan.lock().unwrap() = Some(rescan);
let rx = discovery::browse();
std::thread::spawn(move || {
let mut acc: Vec<DiscoveredHost> = Vec::new();
while let Ok(h) = rx.recv_blocking() {
+1 -26
View File
@@ -1911,35 +1911,10 @@ pub(crate) fn settings_page(
} else {
border(vstack(Vec::<Element>::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::<Element>::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![
Element::from(vstack(vec![store_slot, scope_bar])).grid_row(0),
scope_bar.grid_row(0),
Element::from(grid(vec![nav.into(), sheet_slot, confirm])).grid_row(1),
])
.rows([GridLength::Auto, GridLength::STAR])
+7 -55
View File
@@ -3,12 +3,6 @@
//! 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 {
@@ -30,25 +24,10 @@ pub struct DiscoveredHost {
pub os: String,
}
/// 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<AtomicBool>);
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<DiscoveredHost>, Rescan) {
/// 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<DiscoveredHost> {
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 || {
@@ -59,45 +38,18 @@ pub fn browse() -> (async_channel::Receiver<DiscoveredHost>, Rescan) {
return;
}
};
let mut receiver = match daemon.browse(SERVICE_TYPE) {
let receiver = match daemon.browse("_punktfunk._udp.local.") {
Ok(r) => r,
Err(e) => {
tracing::warn!(error = %e, "mDNS browse failed — discovery disabled");
return;
}
};
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
};
while let Ok(event) = receiver.recv() {
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();
// IPv4 only, like every other client (`pf_client_core::discovery`): the core
// dials `format!("{host}:{port}").parse::<SocketAddr>()`, 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())
let Some(addr) = info.get_addresses().iter().next().map(|a| a.to_string())
else {
continue;
};
@@ -133,5 +85,5 @@ pub fn browse() -> (async_channel::Receiver<DiscoveredHost>, Rescan) {
let _ = daemon.shutdown();
})
.expect("spawn mdns thread");
(rx, Rescan(flag))
rx
}
+1 -1
View File
@@ -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, _rescan) = discovery::browse();
let rx = discovery::browse();
let deadline = Instant::now() + Duration::from_secs(5);
let mut seen = std::collections::HashSet::new();
while Instant::now() < deadline {
+6 -44
View File
@@ -5,13 +5,8 @@
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.
@@ -59,32 +54,10 @@ pub enum DiscoveryEvent {
Removed { fullname: String },
}
/// 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<AtomicBool>);
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<DiscoveryEvent>, Rescan) {
/// 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<DiscoveryEvent> {
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 || {
@@ -95,7 +68,7 @@ pub fn browse() -> (async_channel::Receiver<DiscoveryEvent>, Rescan) {
return;
}
};
let mut receiver = match daemon.browse(SERVICE_TYPE) {
let receiver = match daemon.browse("_punktfunk._udp.local.") {
Ok(r) => r,
Err(e) => {
tracing::warn!(error = %e, "mDNS browse failed — discovery disabled");
@@ -115,17 +88,6 @@ pub fn browse() -> (async_channel::Receiver<DiscoveryEvent>, Rescan) {
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,
@@ -185,7 +147,7 @@ pub fn browse() -> (async_channel::Receiver<DiscoveryEvent>, Rescan) {
let _ = daemon.shutdown();
})
.expect("spawn mdns thread");
(rx, Rescan(flag))
rx
}
/// The advert map one browse window folded down to. Kept separate from [`discover_for`] so the
@@ -212,7 +174,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<DiscoveredHost> {
let (rx, _rescan) = browse();
let rx = browse();
let deadline = Instant::now() + timeout;
let mut adverts = Adverts::new();
while Instant::now() < deadline {
-14
View File
@@ -66,20 +66,6 @@ pub struct GameEntry {
/// host's flattened `GameMeta`; the rest of the metadata is not decoded until a UI needs it.
#[serde(default)]
pub platform: Option<String>,
/// `"game"` (the default, and what an older host omits) or `"launcher"` — an entry that opens
/// the launcher itself (Steam Big Picture, Heroic) rather than a title. A UI may group these
/// separately; one that doesn't renders them as ordinary tiles, which is the intended
/// degradation (design D4). Kept a plain string: the host owns the vocabulary, and an unknown
/// future value must never fail the whole library decode.
#[serde(default)]
pub role: Option<String>,
}
impl GameEntry {
/// Whether this entry opens a launcher rather than a game.
pub fn is_launcher(&self) -> bool {
self.role.as_deref() == Some("launcher")
}
}
/// Errors surfaced to the UI so it can guide setup (the common case is "not paired yet").
+3 -3
View File
@@ -840,7 +840,7 @@ struct PadOut {
#[cfg(windows)]
impl PadOut {
/// Correlate (HID container → endpoint id) and open a shared event-driven render stream ON
/// that endpoint (`audio_wasapi::render_thread`'s shape — autoconvert, default period).
/// that endpoint (`audio::render_thread`'s shape — autoconvert, default period).
fn open() -> anyhow::Result<PadOut> {
use anyhow::{anyhow, Context};
let hid_path =
@@ -921,8 +921,8 @@ fn pad_render_thread(
const BLOCK_ALIGN: usize = PAD_CHANNELS * 4; // f32 interleaved
let enumerator = wasapi::DeviceEnumerator::new().context("DeviceEnumerator")?;
// Not `get_device`: that helper resolves through a freed string — see
// [`crate::audio::device_by_id`] (audio_wasapi.rs, mounted as `crate::audio` on
// Windows by lib.rs's `#[path]` swap — there is no `audio_wasapi` module name).
// [`crate::audio::device_by_id`]. (`audio_wasapi.rs` is mounted as `crate::audio`
// on Windows via `#[path]`, so it has no `crate::audio_wasapi` name to reach it by.)
let device = crate::audio::device_by_id(&enumerator, &Direction::Render, endpoint_id)
.map_err(|e| anyhow!("correlated endpoint not found: {e:#}"))?;
let mut audio_client = device.get_iaudioclient().context("IAudioClient")?;
+1 -21
View File
@@ -908,27 +908,7 @@ fn pump(
}
}
Err(PunktfunkError::NoFrame) => {}
// 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(PunktfunkError::Closed) => break Some("Host ended the session".to_string()),
Err(e) => break Some(format!("session: {e:?}")),
}
+9 -238
View File
@@ -91,131 +91,22 @@ 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\<SID>\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 = 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)
}
}
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)
}
// 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<Option<String>> = 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<String> {
LAST_ERROR.lock().ok().and_then(|s| s.clone())
}
}
@@ -1111,15 +1002,6 @@ 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
@@ -1204,10 +1086,6 @@ fn default_true() -> bool {
true
}
fn default_ui_palette() -> String {
"violet".into()
}
fn default_pad_speaker() -> String {
"pad".into()
}
@@ -1316,7 +1194,6 @@ 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(),
@@ -2063,7 +1940,6 @@ 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()
@@ -2077,112 +1953,7 @@ 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!(!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}");
assert!(!p.with_extension("json.tmp").exists());
let _ = std::fs::remove_dir_all(&dir);
}
}
+5 -6
View File
@@ -270,11 +270,7 @@ fn load_floor(path: &Path, channel: &str) -> u64 {
.unwrap_or(0)
}
/// 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.
/// Raise (never lower) the floor; atomic tmp+rename so a power cut can't half-write it.
fn store_floor(path: &Path, channel: &str, serial: u64) {
let mut file: FloorFile = std::fs::read(path)
.ok()
@@ -291,7 +287,10 @@ fn store_floor(path: &Path, channel: &str, serial: u64) {
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
let _ = crate::trust::write_atomic(path, &bytes);
let tmp = path.with_extension("json.tmp");
if std::fs::write(&tmp, &bytes).is_ok() {
let _ = std::fs::rename(&tmp, path);
}
}
// ---------------------------------------------------------------- check
+235 -5
View File
@@ -286,6 +286,9 @@ pub struct Decoder {
/// The pump drains it and asks the host — under the infinite GOP there is no periodic
/// keyframe, so a rebuilt/erroring decoder would otherwise stay gray/frozen forever.
want_keyframe: bool,
/// Consecutive frames libavcodec concealed rather than decoded — see
/// [`Decoder::note_concealed`]. Separate from [`Self::vaapi_fails`] on purpose.
concealed_run: u32,
/// The presenter has the win32 external-memory import path, so D3D11VA frames can reach
/// the screen — kept for the mid-session Vulkan→D3D11VA demotion rung (the Windows
/// analog of Linux's Vulkan→VAAPI rung).
@@ -435,12 +438,78 @@ pub fn decodable_codecs_for(vk: Option<&VulkanDecodeDevice>) -> u8 {
bits
}
/// Count of libavcodec messages at `AV_LOG_ERROR` or worse since process start, written
/// by [`pf_av_log`]. [`Decoder::decode_frame`] samples it around each AU: a backend that
/// returns a frame while this moved decoded something libavcodec itself called broken.
///
/// Process-global because `av_log_set_callback` is. A second concurrent session would make
/// the attribution fuzzy (both sessions' errors land in one counter) — the consequence is a
/// spurious keyframe request on the other session, which is exactly what it would do for a
/// real error anyway, so it is not worth a per-context registry.
static AVCODEC_ERRORS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// Does an `av_log` level mean "this decode is wrong", as opposed to chatter?
///
/// libavcodec's ladder is PANIC 0 / FATAL 8 / ERROR 16 / WARNING 24 / INFO 32 / VERBOSE 40.
/// The cut is at ERROR deliberately: the reference-damage messages we are hunting
/// (`Error constructing the frame RPS`, `First slice in a frame missing`, `Previous slice
/// segment missing`) are all ERROR, while WARNING is full of benign noise like swscale's
/// "deprecated pixel format used" — counting that would request a keyframe on every frame
/// of a perfectly good session.
fn counts_as_decode_error(level: std::os::raw::c_int) -> bool {
const AV_LOG_ERROR: std::os::raw::c_int = 16;
level <= AV_LOG_ERROR
}
/// libavcodec's `av_log` sink.
///
/// The `va_list` argument is deliberately typed `*mut c_void` and NEVER read — formatting
/// it would need the unstable `c_variadic` feature, and we only want the level and the
/// message identity. `fmt` is the static format string (`"Error constructing the frame
/// RPS.\n"`), which is enough to say what happened; only the substituted values are lost.
///
/// # Safety
/// Called by libavcodec from decoder threads. `fmt` is a NUL-terminated static string
/// (libavcodec passes only string literals). We do not touch `avcl` or `vl`.
unsafe extern "C" fn pf_av_log(
_avcl: *mut std::os::raw::c_void,
level: std::os::raw::c_int,
fmt: *const std::os::raw::c_char,
_vl: *mut std::os::raw::c_void,
) {
if counts_as_decode_error(level) {
AVCODEC_ERRORS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
if fmt.is_null() {
return;
}
// SAFETY: libavcodec only ever passes a NUL-terminated static format string here.
let msg = unsafe { std::ffi::CStr::from_ptr(fmt) }
.to_string_lossy()
.trim_end()
.to_string();
// Route into tracing rather than the raw stderr libavcodec would otherwise write to:
// these lines are decode evidence and belong in the log a field report ships us.
if counts_as_decode_error(level) {
tracing::debug!(target: "ffmpeg", level, "{msg}");
} else {
tracing::trace!(target: "ffmpeg", level, "{msg}");
}
}
/// libavcodec logs reference-frame recovery to the process stderr very verbosely
/// (`First slice in a frame missing`, `Could not find ref with POC …`, `Error
/// constructing the frame RPS`) — normal chatter while the decoder waits for a keyframe
/// after loss, but a raw flood in the user's terminal (it bypasses our tracing). Default
/// it to fatal-only; `PUNKTFUNK_FFMPEG_LOG=<quiet|error|warning|info|debug>` restores it
/// for decode debugging. Process-global; set once per decoder build (idempotent).
/// after loss, but a raw flood in the user's terminal (it bypasses our tracing).
///
/// Two jobs. It sets the level (default fatal-only;
/// `PUNKTFUNK_FFMPEG_LOG=<quiet|error|warning|info|debug>` restores it for decode
/// debugging) AND installs [`pf_av_log`], which is what makes those messages *countable*.
/// The level only gates libavcodec's own default sink; a custom callback is handed every
/// message regardless, so quieting the terminal no longer means throwing the signal away —
/// which is what it meant before, for the whole life of this decoder.
///
/// Process-global; set once per decoder build (idempotent).
fn quiet_ffmpeg_log() {
use ffmpeg::util::log::Level;
let level = match std::env::var("PUNKTFUNK_FFMPEG_LOG").ok().as_deref() {
@@ -452,6 +521,33 @@ fn quiet_ffmpeg_log() {
_ => Level::Fatal,
};
ffmpeg::util::log::set_level(level);
let cb: unsafe extern "C" fn(
*mut std::os::raw::c_void,
std::os::raw::c_int,
*const std::os::raw::c_char,
*mut std::os::raw::c_void,
) = pf_av_log;
// The turbofish clippy asks for cannot be written here: the target type is whatever
// bindgen generated for `va_list` on THIS target (`*mut __va_list_tag` on Linux, a
// different type on Windows), so naming it would need a cfg ladder per platform and
// per arch — the exact portability problem this signature avoids.
#[allow(clippy::missing_transmute_annotations)]
// SAFETY: `av_log_set_callback` stores a function pointer libavcodec calls for every
// message; `pf_av_log` is a `extern "C"` fn with static lifetime, so it stays valid for
// the process. The transmute only retypes the 4th parameter from our `*mut c_void` to
// whatever bindgen named `va_list` on this target — that parameter is pointer-sized on
// every target we build (x86-64/aarch64 SysV pass the va_list struct indirectly; the
// Windows x64/arm64 ABI defines `va_list` as a plain `char *`), and `pf_av_log` never
// dereferences it, so no ABI-visible difference remains.
unsafe {
ffmpeg::ffi::av_log_set_callback(Some(std::mem::transmute(cb)))
};
}
/// Snapshot of [`AVCODEC_ERRORS`], for bracketing one decode call.
fn avcodec_error_count() -> u64 {
AVCODEC_ERRORS.load(std::sync::atomic::Ordering::Relaxed)
}
impl Decoder {
@@ -492,6 +588,7 @@ impl Decoder {
vaapi_fails: 0,
first_fail: None,
want_keyframe: false,
concealed_run: 0,
#[cfg(windows)]
d3d11_import,
#[cfg(windows)]
@@ -711,6 +808,7 @@ impl Decoder {
vaapi_fails: 0,
first_fail: None,
want_keyframe: false,
concealed_run: 0,
// A PyroWave session never demotes (nothing else decodes it — a failure
// renegotiates the codec instead), so the D3D11VA rebuild facts are unused
// here; keep them well-formed rather than plumbing them in for nothing.
@@ -743,6 +841,47 @@ impl Decoder {
Ok(())
}
/// A decode that **succeeded loudly**: libavcodec logged an error and then concealed,
/// handing back a frame and a success code. HEVC does this for `Error constructing the
/// frame RPS` / `First slice in a frame missing` / `Previous slice segment missing`,
/// H.264 for its reference-list equivalents — every one of them means the picture was
/// built on references the decoder could not resolve, i.e. it is wrong on screen.
///
/// Before this existed the `Ok` arm reset the streak, so this class was not merely
/// undetected but actively *erased* the evidence of the errors around it: a decoder
/// concealing every second frame looked perfectly healthy, never asked for an IDR, and
/// under the infinite GOP kept the damage for the life of the session.
///
/// The response is the IDR request, which is the thing that actually repairs the
/// picture. It deliberately does NOT feed [`Self::vaapi_fails`], the hardware-demotion
/// streak: an ordinary packet loss makes the decoder conceal every AU until the
/// requested IDR lands, and at 120 fps a 100300 ms round trip is 1236 of them — far
/// past [`VAAPI_DEMOTE_AFTER`], and past [`HW_DEMOTE_MIN_STREAK`] too if that IDR is
/// itself lost. Counting concealment there would demote a perfectly good decoder for
/// the crime of surviving a lossy second. Its own counter keeps the evidence (and the
/// log line a field report needs) without arming that trigger.
fn note_concealed(&mut self) {
self.want_keyframe = true;
self.concealed_run = self.concealed_run.saturating_add(1);
// Every AU of a loss burst comes through here, so this is debug, not warn — the
// run length is the interesting number and it is on the line.
tracing::debug!(
run = self.concealed_run,
"decoder concealed a damaged frame (libavcodec logged an error but returned \
success) requesting a keyframe"
);
}
/// Consecutive concealed frames, reset by the first clean decode. A healthy session
/// shows short runs that end when the requested IDR lands; a run that keeps climbing
/// across many IDR cycles is a decoder producing wrong pictures from good input, which
/// is the shape of the Windows FFmpeg-Vulkan field reports. Exposed so the pump can put
/// it on the stats line — nothing else can see it, because libavcodec reports this by
/// logging rather than by failing.
pub fn concealed_run(&self) -> u32 {
self.concealed_run
}
/// Feed one access unit; returns the decoded frame (the host's streams are
/// one-in/one-out). A software decode error after packet loss is survivable — log
/// upstream and keep feeding. A VAAPI error re-requests an IDR and retries the hardware
@@ -769,6 +908,10 @@ impl Decoder {
user_flags: u32,
complete: bool,
) -> Result<Option<DecodedImage>> {
// Bracket the decode: libavcodec reports reference damage by LOGGING and then
// concealing, returning a frame and a success code. Without this the whole class is
// invisible to us — see `pf_av_log` and `note_concealed`.
let errors_before = avcodec_error_count();
let result = match &mut self.backend {
Backend::Vulkan(v) => {
debug_assert!(complete, "partial AUs are pyrowave-only");
@@ -792,8 +935,19 @@ impl Decoder {
};
match result {
Ok(f) => {
self.vaapi_fails = 0;
self.first_fail = None;
if avcodec_error_count() > errors_before {
self.note_concealed();
} else {
if self.concealed_run > 0 {
tracing::debug!(
run = self.concealed_run,
"decoder recovered — clean frame after a concealment run"
);
self.concealed_run = 0;
}
self.vaapi_fails = 0;
self.first_fail = None;
}
Ok(f)
}
Err(e) => {
@@ -1132,6 +1286,82 @@ mod tests {
assert!(!decode_device(0x8086, "Intel(R) Arc(TM) Pro Graphics").prefer_vulkan_first());
}
/// The cut that decides whether a libavcodec message arms a keyframe request. ERROR and
/// worse mean the picture is wrong; WARNING and below are chatter. Getting this wrong is
/// not subtle in either direction — too low and every session requests keyframes forever
/// off swscale's "deprecated pixel format used", too high and the concealment class this
/// whole mechanism exists to catch goes back to being invisible.
#[test]
fn only_error_and_worse_count_as_a_bad_decode() {
// PANIC / FATAL / ERROR
assert!(counts_as_decode_error(0));
assert!(counts_as_decode_error(8));
assert!(counts_as_decode_error(16));
// WARNING / INFO / VERBOSE / DEBUG / TRACE
assert!(!counts_as_decode_error(24));
assert!(!counts_as_decode_error(32));
assert!(!counts_as_decode_error(40));
assert!(!counts_as_decode_error(48));
assert!(!counts_as_decode_error(56));
}
/// The callback itself, through the same pointer libavcodec will call it by — the FFI
/// signature and the counter increment, not just the classifier. Deltas rather than
/// absolute values because the counter is process-global and tests run in parallel.
#[test]
fn the_log_callback_counts_errors_and_ignores_chatter() {
let msg = c"pf test message\n";
let before = avcodec_error_count();
// SAFETY: exactly what libavcodec does — a NUL-terminated static format string, a
// null context, and a va_list `pf_av_log` never reads (null is therefore fine).
unsafe { pf_av_log(std::ptr::null_mut(), 16, msg.as_ptr(), std::ptr::null_mut()) };
assert!(
avcodec_error_count() > before,
"an ERROR-level message must be counted"
);
let mid = avcodec_error_count();
// SAFETY: as above.
unsafe { pf_av_log(std::ptr::null_mut(), 24, msg.as_ptr(), std::ptr::null_mut()) };
assert_eq!(
avcodec_error_count(),
mid,
"a WARNING-level message must NOT be counted"
);
// A null fmt must not be dereferenced (defensive: libavcodec always passes one).
let pre_null = avcodec_error_count();
// SAFETY: the null-fmt path returns before any dereference — that is what is under test.
unsafe {
pf_av_log(
std::ptr::null_mut(),
16,
std::ptr::null(),
std::ptr::null_mut(),
)
};
assert_eq!(avcodec_error_count(), pre_null + 1);
}
/// Installing the callback must succeed on whatever this platform's `va_list` is — the
/// transmute in `quiet_ffmpeg_log` is the one place the FFI signature could be wrong,
/// and a wrong one is a crash inside libavcodec rather than a compile error.
#[test]
fn installing_the_log_callback_is_safe_and_idempotent() {
quiet_ffmpeg_log();
quiet_ffmpeg_log();
// Drive a real message through libavcodec's own dispatcher, which now routes to
// `pf_av_log`: this is the end-to-end proof that the installed pointer is callable.
let before = avcodec_error_count();
// SAFETY: `av_log` with a literal format string and no varargs to substitute.
unsafe { ffmpeg::ffi::av_log(std::ptr::null_mut(), 16, c"pf install probe\n".as_ptr()) };
assert!(
avcodec_error_count() > before,
"libavcodec must reach our callback after quiet_ffmpeg_log()"
);
}
/// Lock the DRM FourCC magic numbers against typos — these are the exact values
/// `<drm_fourcc.h>` defines, and a wrong one is what painted the Steam Deck green.
#[test]
+1 -61
View File
@@ -309,24 +309,6 @@ pub fn offer_wire_mimes(raw: &[String]) -> Vec<&'static str> {
out
}
/// Whether a non-canonical, client-supplied MIME is safe to hand to Wayland as a string argument.
///
/// Deliberately strict: printable ASCII only (so no NUL and no other control byte can reach the
/// `CString` in the generated encoder), bounded length, and it must actually look like a MIME type.
/// A real `type/subtype[;params]` passes; nothing that could crash or confuse the compositor does.
#[cfg(target_os = "linux")]
fn valid_passthrough_mime(m: &str) -> bool {
let Some((ty, rest)) = m.split_once('/') else {
return false;
};
!ty.is_empty()
&& !rest.is_empty()
&& m.len() <= 255
// 0x21..=0x7E: printable ASCII without space. Excludes NUL, every other control byte, and
// any non-ASCII byte.
&& m.bytes().all(|b| (0x21..=0x7E).contains(&b))
}
/// The Wayland MIMEs to advertise when installing a source for a client's offer. Each wire MIME
/// expands to its canonical Wayland name(s); a rich-text-only offer also advertises `text/plain`
/// so plain-text targets always paste (§3.5 synthesis — destination-side, one direction only).
@@ -360,17 +342,7 @@ pub fn wayland_offers_for(wire_mimes: &[String]) -> Vec<String> {
WIRE_PNG => push("image/png"),
WIRE_JPEG => push("image/jpeg"),
WIRE_GIF => push("image/gif"),
// A MIME we don't canonicalize is passed through verbatim — so it is the one value on
// this path the CLIENT fully controls, and it ends up as a Wayland string argument.
// The wayland-scanner-generated request encoder builds a `CString` and `unwrap()`s it,
// so a single interior NUL turns one control message into a host clipboard panic
// (2026-08-05 review L-8). `String::from_utf8_lossy` on the wire preserves `\0`, so
// nothing upstream removes it. Validate here, at the boundary where the value stops
// being ours and becomes libwayland's.
other if valid_passthrough_mime(other) => push(other),
other => {
tracing::debug!(mime = %other.escape_debug(), "clipboard: dropping a malformed client MIME");
}
other => push(other),
}
}
// Synthesis: rich text without plain text → also advertise plain (the source derives it lazily).
@@ -417,38 +389,6 @@ mod tests {
assert_eq!(offer_wire_mimes(&raw), vec![WIRE_TEXT, WIRE_HTML]);
}
/// One control message must not be able to panic the host clipboard coordinator
/// (2026-08-05 review L-8). The passthrough branch is the only place a client string becomes a
/// Wayland argument, and the generated encoder `unwrap()`s a `CString` built from it.
#[test]
fn passthrough_mimes_cannot_carry_a_nul_or_control_byte() {
// The crash payload: an interior NUL survives `String::from_utf8_lossy` on the wire.
assert!(!valid_passthrough_mime("image/webp\0"));
assert!(!valid_passthrough_mime("\0"));
assert!(!valid_passthrough_mime("image/\0webp"));
// Other control bytes and whitespace are refused for the same reason.
assert!(!valid_passthrough_mime("image/web\np"));
assert!(!valid_passthrough_mime("image/web p"));
assert!(!valid_passthrough_mime("image/web\tp"));
// Shapes that are not a MIME type at all.
assert!(!valid_passthrough_mime(""));
assert!(!valid_passthrough_mime("noslash"));
assert!(!valid_passthrough_mime("/nosubtype"));
assert!(!valid_passthrough_mime("notype/"));
assert!(!valid_passthrough_mime(&format!(
"image/{}",
"x".repeat(300)
)));
// Legitimate uncanonicalized MIMEs still pass through.
assert!(valid_passthrough_mime("image/webp"));
assert!(valid_passthrough_mime("application/x-custom+json"));
assert!(valid_passthrough_mime("text/plain;charset=utf-8"));
// End to end: the offer list is built without the malformed entry, and does not panic.
let offers = wayland_offers_for(&["image/webp\0".to_string(), WIRE_PNG.to_string()]);
assert_eq!(offers, vec!["image/png".to_string()]);
}
#[test]
fn pick_wayland_mime_prefers_canonical() {
let avail = vec!["text/plain".to_string(), "UTF8_STRING".to_string()];
+1 -21
View File
@@ -169,27 +169,7 @@ fn strip_trailing_nul(b: &[u8]) -> &[u8] {
/// bytes (BITMAPINFOHEADER, 32bpp BGRA, BI_RGB, bottom-up). GIFs contribute their first frame.
/// `None` when the bytes don't decode — the caller leaves the format unrendered (empty paste).
pub fn image_to_dib(bytes: &[u8]) -> Option<Vec<u8>> {
// Bound the DECODE, not just the result.
//
// These bytes are client-supplied, and `load_from_memory` used the `image` crate's DEFAULT
// limits — 512 MiB of decode allowance — while the 32767 dimension check below only ran on the
// already-decoded image. So a small, valid PNG declaring enormous dimensions was allocated in
// full before anything rejected it: ~1000× amplification from a few KB of wire (2026-08-05
// review L-9). Limits applied here make the allocation refuse instead.
//
// The caps are the clipboard's own contract expressed up front: the same 32767 per side that
// is checked below (a CF_DIB cannot express more), and 256 MiB, which is more than the largest
// representable 32bpp image anyone pastes and far less than a memory-exhaustion primitive.
let mut limits = image::Limits::default();
limits.max_image_width = Some(32767);
limits.max_image_height = Some(32767);
limits.max_alloc = Some(256 * 1024 * 1024);
let reader = image::ImageReader::new(std::io::Cursor::new(bytes))
.with_guessed_format()
.ok()?;
let mut reader = reader;
reader.limits(limits);
let img = reader.decode().ok()?;
let img = image::load_from_memory(bytes).ok()?;
let rgba = img.to_rgba8();
let (w, h) = (rgba.width() as usize, rgba.height() as usize);
if w == 0 || h == 0 || w > 32767 || h > 32767 {
+16 -16
View File
@@ -5,9 +5,9 @@
//! no pad at all the legend swaps to keyboard keycaps — the console stays fully
//! drivable either way.
use crate::theme::{fg, Fonts, W};
use crate::theme::{white, Fonts, W};
use punktfunk_core::config::GamepadPref;
use skia_safe::{Canvas, Paint, Path, Point, RRect, Rect};
use skia_safe::{Canvas, Color4f, Paint, Path, Point, RRect, Rect};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum GlyphStyle {
@@ -94,13 +94,13 @@ pub(crate) fn hint_bar(
let rect = Rect::from_xywh((x) as f32, (bottom - h) as f32, w as f32, h as f32);
canvas.draw_rrect(
RRect::new_rect_xy(rect, (h / 2.0) as f32, (h / 2.0) as f32),
&Paint::new(crate::theme::shade(0.30), None),
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.30), None),
);
canvas.draw_rrect(
RRect::new_rect_xy(rect, (h / 2.0) as f32, (h / 2.0) as f32),
&Paint::new(fg(0.06), None),
&Paint::new(white(0.06), None),
);
let mut sp = Paint::new(fg(0.12), None);
let mut sp = Paint::new(white(0.12), None);
sp.set_style(skia_safe::PaintStyle::Stroke);
sp.set_stroke_width(1.0);
sp.set_anti_alias(true);
@@ -122,7 +122,7 @@ pub(crate) fn hint_bar(
cy + LABEL_SIZE * k * 0.36,
W::SemiBold,
LABEL_SIZE * k,
fg(0.85),
white(0.85),
);
pen += lw + gap_hint;
}
@@ -199,8 +199,8 @@ fn draw_glyph(
Resolved::Badge(face) => {
let r = BADGE_D * k / 2.0;
let center = Point::new((x + r) as f32, cy as f32);
canvas.draw_circle(center, r as f32, &Paint::new(fg(0.10), None));
let mut ring = Paint::new(fg(0.32), None);
canvas.draw_circle(center, r as f32, &Paint::new(white(0.10), None));
let mut ring = Paint::new(white(0.32), None);
ring.set_style(skia_safe::PaintStyle::Stroke);
ring.set_stroke_width((1.2 * k) as f32);
ring.set_anti_alias(true);
@@ -223,7 +223,7 @@ fn draw_glyph(
cy + size * 0.36,
W::SemiBold,
size,
fg(0.92),
white(0.92),
);
}
}
@@ -235,7 +235,7 @@ fn draw_glyph(
let rect = Rect::from_xywh(pen as f32, (cy - h / 2.0) as f32, w as f32, h as f32);
canvas.draw_rrect(
RRect::new_rect_xy(rect, (4.0 * k) as f32, (4.0 * k) as f32),
&Paint::new(fg(0.10), None),
&Paint::new(white(0.10), None),
);
let size = 10.0 * k;
let tw = fonts.measure(label, W::SemiBold, size) as f64;
@@ -246,7 +246,7 @@ fn draw_glyph(
cy + size * 0.36,
W::SemiBold,
size,
fg(0.92),
white(0.92),
);
pen += w + 3.0 * k;
}
@@ -257,7 +257,7 @@ fn draw_glyph(
let (cx, cyf) = ((x + r) as f32, cy as f32);
let (tw, th) = ((4.5 * k) as f32, (5.5 * k) as f32);
let gap = (2.6 * k) as f32;
let paint = Paint::new(fg(0.85), None);
let paint = Paint::new(white(0.85), None);
let mut left = Path::new();
left.move_to((cx - gap, cyf - th));
left.line_to((cx - gap - tw, cyf));
@@ -277,9 +277,9 @@ fn draw_glyph(
let rect = Rect::from_xywh(x as f32, (cy - h / 2.0) as f32, w as f32, h as f32);
canvas.draw_rrect(
RRect::new_rect_xy(rect, (5.0 * k) as f32, (5.0 * k) as f32),
&Paint::new(fg(0.10), None),
&Paint::new(white(0.10), None),
);
let mut ring = Paint::new(fg(0.28), None);
let mut ring = Paint::new(white(0.28), None);
ring.set_style(skia_safe::PaintStyle::Stroke);
ring.set_stroke_width(1.0);
ring.set_anti_alias(true);
@@ -296,7 +296,7 @@ fn draw_glyph(
cy + size * 0.36,
W::SemiBold,
size,
fg(0.92),
white(0.92),
);
}
}
@@ -305,7 +305,7 @@ fn draw_glyph(
/// The PlayStation face shapes, stroked inside the badge: Confirm=✕, Back=○, X-position
/// =□, Y-position=△ (the DualSense's physical layout).
fn draw_ps_shape(canvas: &Canvas, face: Face, center: Point, r: f32, stroke: f32) {
let mut p = Paint::new(fg(0.92), None);
let mut p = Paint::new(white(0.92), None);
p.set_style(skia_safe::PaintStyle::Stroke);
p.set_stroke_width(stroke);
p.set_stroke_cap(skia_safe::PaintCap::Round);
+12 -438
View File
@@ -203,237 +203,17 @@ pub const MESH_INTERIOR: [(f64, f64, f64, f64, f64, f64); 4] = [
(0.667, 0.667, 0.12, 0.047, 0.061, 5.0),
];
// --- Background palettes -------------------------------------------------------------------
/// One background colour family for the gamepad UI's living backdrop.
///
/// A palette is a short ordered ramp of [`Palette::stops`] — several DISTINCT hues, not one hue
/// at several brightnesses. The 4×4 mesh samples that ramp diagonally with a per-cell offset
/// ([`CELL_RAMP`]), so neighbouring cells land on different parts of it and the colours pool and
/// swirl the way a real gradient poster does; the interior points' existing domain warp then
/// drifts those pools around. An earlier version rotated ONE field's hue per palette, which is
/// why every non-default palette read as flat and monotone.
///
/// A palette also owns the UI it sits under: [`Palette::accent`] is the focus wash / selected
/// pill / switch colour, and [`Palette::light`] flips the ink (see [`crate::theme::Ink`]) so a
/// pale field gets dark text instead of white. The Apple and Android clients carry the same
/// table under the same ids, so one `ui_palette` value is one look everywhere.
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,
/// The colour ramp, dark end first. `None` = use [`MESH_COLORS`] verbatim (the brand
/// default, kept bit-identical to what every install already sees).
pub stops: Option<&'static [(f64, f64, f64)]>,
/// The field's ground — what the corners settle onto and what the calm mix lifts toward.
pub ground: (f64, f64, f64),
/// The UI accent: focus wash, selected tab pill, switch track, caret.
pub accent: (f64, f64, f64),
/// A pale field: the UI flips to dark ink and the legibility scrims go white.
pub light: bool,
}
/// Where each of the 16 mesh cells samples the ramp. The base is the diagonal
/// `0.5·(x + y)` — top-left is the ramp's dark end, bottom-right its bright one, like both
/// reference gradients — and the per-cell nudges break the banding that a pure diagonal would
/// give, so hues pool instead of striping.
#[rustfmt::skip]
const CELL_RAMP: [f64; 16] = [
0.10, -0.06, 0.04, -0.12,
-0.08, 0.14, -0.10, 0.06,
0.06, -0.12, 0.16, -0.04,
-0.10, 0.08, -0.06, 0.12,
];
/// The twelve shipped palettes: the brand default, five more dark fields, then six pale ones.
/// Cycling order runs dark → light, so stepping the row walks the whole range in one direction.
/// 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.
#[rustfmt::skip]
pub const PALETTES: [Palette; 12] = [
// --- dark fields (white ink) ---
Palette {
id: "violet", name: "Violet", stops: None,
ground: (0.075, 0.060, 0.160), accent: (0.525, 0.471, 0.961), light: false,
},
Palette {
// Deep indigo climbing through violet into a hot magenta.
id: "nebula", name: "Nebula",
stops: Some(&[
(0.07, 0.05, 0.20), (0.26, 0.14, 0.54), (0.52, 0.20, 0.72),
(0.82, 0.26, 0.62), (0.98, 0.46, 0.68),
]),
ground: (0.055, 0.040, 0.135), accent: (0.95, 0.42, 0.72), light: false,
},
Palette {
// Ink-blue water: teal → cerulean → a violet undertow.
id: "abyss", name: "Abyss",
stops: Some(&[
(0.02, 0.10, 0.17), (0.04, 0.28, 0.42), (0.07, 0.46, 0.63),
(0.16, 0.38, 0.78), (0.26, 0.22, 0.58),
]),
ground: (0.018, 0.070, 0.130), accent: (0.26, 0.76, 0.92), light: false,
},
Palette {
// Banked coals: plum embers → crimson → burnt orange → gold.
id: "ember", name: "Ember",
stops: Some(&[
(0.16, 0.03, 0.10), (0.45, 0.06, 0.12), (0.72, 0.18, 0.06),
(0.90, 0.42, 0.08), (0.95, 0.68, 0.18),
]),
ground: (0.090, 0.035, 0.040), accent: (0.98, 0.62, 0.26), light: false,
},
Palette {
// Forest floor into moss and a lime break.
id: "moss", name: "Moss",
stops: Some(&[
(0.03, 0.11, 0.09), (0.06, 0.27, 0.20), (0.09, 0.45, 0.31),
(0.28, 0.61, 0.28), (0.58, 0.77, 0.31),
]),
ground: (0.025, 0.085, 0.070), accent: (0.48, 0.86, 0.46), light: false,
},
Palette {
// Neutral, but never flat: barely-there saturation that still travels from a cool
// charcoal to a warm stone, so even the restrained option has somewhere to go.
id: "graphite", name: "Graphite",
stops: Some(&[
(0.06, 0.07, 0.11), (0.15, 0.18, 0.25), (0.30, 0.31, 0.35),
(0.45, 0.42, 0.38), (0.60, 0.56, 0.49),
]),
ground: (0.055, 0.055, 0.070), accent: (0.78, 0.80, 0.86), light: false,
},
// --- pale fields (dark ink) ---
Palette {
// The holographic foil: rose → lilac → periwinkle → aqua, with a white bloom.
id: "holo", name: "Holo",
stops: Some(&[
(0.99, 0.72, 0.90), (0.80, 0.60, 0.98), (0.58, 0.62, 0.99),
(0.55, 0.86, 0.98), (0.94, 0.98, 1.00),
]),
ground: (0.96, 0.92, 0.99), accent: (0.42, 0.28, 0.86), light: true,
},
Palette {
// The poster sunset: periwinkle → magenta → scarlet → tangerine → gold.
id: "sunset", name: "Sunset",
stops: Some(&[
(0.55, 0.45, 0.92), (0.86, 0.31, 0.66), (0.97, 0.26, 0.34),
(0.99, 0.51, 0.18), (1.00, 0.80, 0.22),
]),
ground: (0.98, 0.74, 0.34), accent: (0.64, 0.13, 0.44), light: true,
},
Palette {
// Peach into blush and lilac — the softest of the set.
id: "bloom", name: "Bloom",
stops: Some(&[
(1.00, 0.86, 0.72), (0.99, 0.73, 0.79), (0.95, 0.65, 0.89),
(0.82, 0.68, 0.96), (0.73, 0.79, 0.99),
]),
ground: (0.99, 0.90, 0.89), accent: (0.72, 0.24, 0.55), light: true,
},
Palette {
// First light: pale gold → coral → lilac.
id: "dawn", name: "Dawn",
stops: Some(&[
(1.00, 0.92, 0.70), (1.00, 0.80, 0.62), (0.99, 0.66, 0.62),
(0.90, 0.62, 0.78), (0.77, 0.69, 0.95),
]),
ground: (1.00, 0.93, 0.82), accent: (0.82, 0.33, 0.28), light: true,
},
Palette {
// Sea glass: mint → aqua → a pale sky.
id: "mint", name: "Mint",
stops: Some(&[
(0.82, 0.98, 0.90), (0.62, 0.94, 0.88), (0.55, 0.88, 0.95),
(0.63, 0.82, 0.99), (0.82, 0.87, 1.00),
]),
ground: (0.90, 0.98, 0.96), accent: (0.04, 0.42, 0.40), light: true,
},
Palette {
// Near-white, but iridescent rather than flat — rose, sky, mint and cream in turn.
id: "opal", name: "Opal",
stops: Some(&[
(0.98, 0.92, 0.96), (0.87, 0.93, 0.99), (0.91, 0.99, 0.95),
(0.99, 0.96, 0.88), (0.94, 0.90, 0.99),
]),
ground: (0.97, 0.96, 0.99), accent: (0.36, 0.32, 0.44), light: true,
},
];
/// 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])
}
/// Sample an ordered colour ramp at `t` ∈ [0, 1] (linear between neighbouring stops). Ported
/// verbatim to Swift and Kotlin — keep the three copies in step or a palette drifts between
/// clients.
pub fn ramp(stops: &[(f64, f64, f64)], t: f64) -> (f64, f64, f64) {
match stops.len() {
0 => (0.0, 0.0, 0.0),
1 => stops[0],
n => {
let x = t.clamp(0.0, 1.0) * (n - 1) as f64;
let i = (x.floor() as usize).min(n - 2);
let f = x - i as f64;
let (a, b) = (stops[i], stops[i + 1]);
(
a.0 + (b.0 - a.0) * f,
a.1 + (b.1 - a.1) * f,
a.2 + (b.2 - a.2) * f,
)
}
}
}
impl Palette {
/// The 16 mesh colours for this palette: the ramp sampled per cell (see [`CELL_RAMP`]), or
/// [`MESH_COLORS`] verbatim for the brand default.
pub fn mesh_colors(&self) -> [(f64, f64, f64); 16] {
let Some(stops) = self.stops else {
return MESH_COLORS;
};
core::array::from_fn(|i| {
let (x, y) = ((i % 4) as f64 / 3.0, (i / 4) as f64 / 3.0);
ramp(stops, 0.5 * (x + y) + CELL_RAMP[i])
})
}
/// Four drifting blob colours, for the clients that approximate the mesh with a blob field
/// (Android). Spread across the ramp so the field still shows several hues at once.
pub fn blob_colors(&self) -> [(f64, f64, f64); 4] {
let stops = self.stops.unwrap_or(&VIOLET_BLOBS);
core::array::from_fn(|i| ramp(stops, 0.15 + 0.25 * i as f64))
}
}
/// The brand default's blob ramp — the four colours the pre-palette Android/legacy-Apple field
/// used, kept so `violet` is unchanged there too.
const VIOLET_BLOBS: [(f64, f64, f64); 5] = [
(0.53, 0.47, 0.96),
(0.24, 0.20, 0.72),
(0.62, 0.30, 0.80),
(0.22, 0.38, 0.86),
(0.53, 0.47, 0.96),
];
/// 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
/// 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
/// 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.
///
/// `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 {
pub fn mesh_sksl() -> String {
// Colours as `float3(r, g, b)` literals, indices 0..15 (row-major 4×4).
let c = |i: usize| {
let (r, g, b) = colors[i];
let (r, g, b) = MESH_COLORS[i];
format!("float3({r}, {g}, {b})")
};
// The four interior-point domain-warp accumulators. Displacement matches Swift `wob()`:
@@ -444,23 +224,14 @@ pub fn mesh_sksl(colors: &[(f64, f64, f64); 16]) -> 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(tt * {sx} + {ph}), \
{amp} * cos(tt * {sy} + {ph} * 1.3));\n\
d = float2({amp} * sin(u_t * {sx} + {ph}), \
{amp} * cos(u_t * {sy} + {ph} * 1.3));\n\
wsum += d * ww; wtot += ww;\n",
));
}
format!(
"uniform float2 u_res;\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\
// rgb = what the vignette and scrims tend toward (black under a dark palette, white\n\
// under a pale one — darkening a pastel field would strand the dark text on it), and\n\
// a = how hard. A pale field needs far less: mixing toward white at the dark field's\n\
// strength bleaches the chroma straight out of the gradient.\n\
uniform float4 u_scrim;\n\
uniform float u_t;\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\
@@ -479,7 +250,6 @@ pub fn mesh_sksl(colors: &[(f64, f64, f64); 16]) -> 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\
@@ -493,27 +263,19 @@ pub fn mesh_sksl(colors: &[(f64, f64, f64); 16]) -> 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(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\
\x20 col = hue(col, sin(u_t * 0.021) * 0.1396263);\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)\n\
\x20 * mix(0.42, 0.21, calm) * u_scrim.a;\n\
\x20 col = mix(col, u_scrim.rgb, vig);\n\
\x20 float vig = clamp((length(e) - 0.25) / 0.90, 0.0, 1.0) * 0.42;\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\
\x20 float v = xy.y / u_res.y;\n\
\x20 float s = v < 0.32 ? mix(0.38, 0.06, v / 0.32)\n\
\x20 : v < 0.68 ? mix(0.06, 0.08, (v - 0.32) / 0.36)\n\
\x20 : mix(0.08, 0.40, (v - 0.68) / 0.32);\n\
\x20 col = mix(col, u_scrim.rgb, s * u_scrim.a);\n\
\x20 col *= 1.0 - s;\n\
\n\
\x20 return half4(half3(col), 1.0);\n\
}}\n",
@@ -544,11 +306,6 @@ pub struct LibraryGame {
pub id: String,
pub title: String,
pub store: String,
/// This entry opens the launcher itself (Steam Big Picture, Heroic, Lutris) rather than a
/// title — design D4. The host's `role` field, already reduced to a boolean by
/// [`pf_client_core::library::GameEntry::is_launcher`] so the "anything that isn't
/// `launcher` is a game" rule lives in exactly one place.
pub launcher: bool,
}
struct Shared {
@@ -584,15 +341,7 @@ impl LibraryShared {
}
/// Loaded games → the carousel (empty = the empty scene).
///
/// **Launcher entries are moved to the front, keeping the host's title order within each
/// group.** Grouping here rather than in the renderer means the carousel's cursor arithmetic,
/// the art pump and every future consumer of this model all inherit the invariant for free —
/// a launcher tile is never buried in the middle of a 400-title shelf.
pub fn set_games(&self, games: Vec<LibraryGame>) {
let mut games = games;
// `sort_by_key` is stable, so this is a partition that preserves the incoming order.
games.sort_by_key(|g| !g.launcher);
let mut s = self.0.lock().unwrap();
s.phase = if games.is_empty() {
LibraryPhase::Empty
@@ -659,52 +408,6 @@ mod tests {
assert_eq!(step_cursor(0, 0, 1, false), StepResult::Boundary);
}
/// Design D4: launcher entries lead the shelf, and the host's title order survives within
/// each group. The renderer's `launcher_count()` reads the launcher group as the prefix
/// `0..n`, so an interleaved list would silently mislabel the group heading.
#[test]
fn set_games_groups_launchers_first_and_keeps_title_order() {
let g = |title: &str, launcher: bool| LibraryGame {
id: format!("steam:{title}"),
title: title.to_string(),
store: "steam".into(),
launcher,
};
let shared = LibraryShared::default();
shared.set_games(vec![
g("Celeste", false),
g("Big Picture", true),
g("Portal 2", false),
g("Heroic", true),
]);
let (phase, games, _) = shared.snapshot();
assert!(matches!(phase, LibraryPhase::Ready));
let titles: Vec<&str> = games.iter().map(|g| g.title.as_str()).collect();
assert_eq!(titles, ["Big Picture", "Heroic", "Celeste", "Portal 2"]);
assert_eq!(games.iter().take_while(|g| g.launcher).count(), 2);
}
/// A library with no launcher entries is untouched — the whole point of the grouping being
/// invisible until a plugin actually publishes a launcher tile.
#[test]
fn set_games_leaves_a_launcher_less_library_alone() {
let shared = LibraryShared::default();
shared.set_games(
["Celeste", "Portal 2", "Tunic"]
.iter()
.map(|t| LibraryGame {
id: format!("steam:{t}"),
title: (*t).to_string(),
store: "steam".into(),
launcher: false,
})
.collect(),
);
let (_, games, _) = shared.snapshot();
let titles: Vec<&str> = games.iter().map(|g| g.title.as_str()).collect();
assert_eq!(titles, ["Celeste", "Portal 2", "Tunic"]);
}
#[test]
fn jump_clamps_onto_the_ends() {
assert_eq!(step_cursor(1, 5, -JUMP, true), StepResult::Moved(0));
@@ -779,139 +482,10 @@ 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(&MESH_COLORS);
let src = mesh_sksl();
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 still be the SHIPPED field, colour for colour. Every install
/// already sees it, and a palette table that quietly restyled the default would be a
/// regression dressed as a feature.
#[test]
fn violet_is_the_untouched_shipped_field() {
assert_eq!(PALETTES[0].id, "violet");
assert!(
PALETTES[0].stops.is_none(),
"the default is the explicit grid"
);
assert_eq!(palette("violet").mesh_colors(), MESH_COLORS);
// An unknown name is a newer client's palette, not an error.
assert_eq!(palette("chartreuse").id, "violet");
assert_eq!(palette("").id, "violet");
}
/// Hue angle in degrees, or `None` for something too grey to have one.
fn hue(c: (f64, f64, f64)) -> Option<f64> {
let (r, g, b) = c;
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let d = max - min;
if d < 0.04 {
return None;
}
let h = if max == r {
60.0 * (((g - b) / d) % 6.0)
} else if max == g {
60.0 * ((b - r) / d + 2.0)
} else {
60.0 * ((r - g) / d + 4.0)
};
Some((h + 360.0) % 360.0)
}
/// A palette must read as SEVERAL hues, not one hue at several brightnesses — that was
/// exactly the complaint about the hue-rotation model this replaced. Measured as the
/// widest gap between any two of the 16 mesh colours' hue angles.
#[test]
fn every_palette_is_multi_tone() {
for p in &PALETTES {
let hues: Vec<f64> = p.mesh_colors().iter().filter_map(|c| hue(*c)).collect();
assert!(hues.len() >= 8, "{}: too few coloured cells", p.id);
let spread = hues
.iter()
.flat_map(|a| {
hues.iter().map(move |b| {
let d = (a - b).abs() % 360.0;
d.min(360.0 - d)
})
})
.fold(0.0f64, f64::max);
// Graphite and Opal are deliberately near-neutral; everything else must carry a
// real hue journey.
let floor = if matches!(p.id, "graphite" | "opal") {
20.0
} else {
45.0
};
assert!(spread >= floor, "{} spans only {spread:.0}° of hue", p.id);
}
}
/// Ids, order and the light/dark split are the cross-client contract — the Apple and
/// Android tables must match this exactly.
#[test]
fn table_matches_the_other_clients() {
let ids: Vec<&str> = PALETTES.iter().map(|p| p.id).collect();
assert_eq!(
ids,
[
"violet", "nebula", "abyss", "ember", "moss", "graphite", "holo", "sunset",
"bloom", "dawn", "mint", "opal",
]
);
// Dark fields lead, pale ones follow, so stepping the row walks one direction.
let first_light = PALETTES
.iter()
.position(|p| p.light)
.expect("some are light");
assert!(PALETTES[first_light..].iter().all(|p| p.light));
assert_eq!(first_light, 6);
}
/// Every colour a palette produces stays in gamut, and a pale palette really is pale —
/// its ink flips, so a mislabelled one would put dark text on a dark field.
#[test]
fn palettes_are_in_gamut_and_honest_about_lightness() {
let luma = |c: (f64, f64, f64)| 0.2126 * c.0 + 0.7152 * c.1 + 0.0722 * c.2;
for p in &PALETTES {
for c in p.mesh_colors().iter().chain(p.blob_colors().iter()) {
for v in [c.0, c.1, c.2] {
assert!((0.0..=1.0).contains(&v), "{} {c:?}", p.id);
}
}
let mean = p.mesh_colors().iter().map(|c| luma(*c)).sum::<f64>() / 16.0;
if p.light {
assert!(mean > 0.5, "{} is flagged light but means {mean:.2}", p.id);
assert!(luma(p.ground) > 0.6, "{}'s ground is dark", p.id);
} else {
assert!(mean < 0.45, "{} is flagged dark but means {mean:.2}", p.id);
assert!(luma(p.ground) < 0.2, "{}'s ground is light", p.id);
}
// The accent tints glass of the OPPOSITE polarity to the field, so it has to be
// legible there: dark accents on white frost, bright ones on dark glass.
let a = luma(p.accent);
if p.light {
assert!(a < 0.45, "{}'s accent is too pale for white glass", p.id);
} else {
assert!(a > 0.25, "{}'s accent is too dark for dark glass", p.id);
}
}
}
/// The ramp is the shared sampling rule the Swift and Kotlin ports reproduce.
#[test]
fn ramp_interpolates_between_stops() {
let stops = [(0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (1.0, 1.0, 1.0)];
assert_eq!(ramp(&stops, 0.0), (0.0, 0.0, 0.0));
assert_eq!(ramp(&stops, 1.0), (1.0, 1.0, 1.0));
assert_eq!(ramp(&stops, 0.5), (1.0, 0.0, 0.0));
let q = ramp(&stops, 0.25);
assert!((q.0 - 0.5).abs() < 1e-9 && q.1 == 0.0);
// Out of range clamps rather than panicking.
assert_eq!(ramp(&stops, -3.0), (0.0, 0.0, 0.0));
assert_eq!(ramp(&stops, 9.0), (1.0, 1.0, 1.0));
assert_eq!(ramp(&[], 0.5), (0.0, 0.0, 0.0));
}
}
+2 -3
View File
@@ -21,10 +21,9 @@ 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 at full contrast (home, library).
/// The living mesh aurora (home, library).
Aurora,
/// 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.
/// The quiet indigo form backdrop (settings, add-host, pair).
Form,
}
+2 -2
View File
@@ -7,7 +7,7 @@
use crate::glyphs::{Hint, HintKey};
use crate::model::ConsoleCmd;
use crate::screens::{Ctx, Outbox};
use crate::theme::{fg, Fonts, W};
use crate::theme::{Fonts, DIM, W};
use crate::widgets::{permits, Charset, KeyMsg, Keyboard, ListMsg, MenuList, RowSpec};
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
use skia_safe::{Canvas, Rect};
@@ -214,7 +214,7 @@ impl AddHostScreen {
"Hosts on this network appear automatically — add one by address for everything else.",
W::Regular,
13.0 * k,
fg(0.55),
DIM,
cx,
f64::from(rect.top) + 2.0 * k,
f64::from(rect.width()) * 0.72,
+22 -22
View File
@@ -10,7 +10,7 @@ use crate::glyphs::{Hint, HintKey};
use crate::library::{step_cursor, StepResult, BUMP_C, BUMP_K, BUMP_PX, SPRING_C, SPRING_K};
use crate::model::{ConsoleCmd, HostRow};
use crate::screens::{ConnectIntent, Ctx, Outbox, Screen};
use crate::theme::{accent, fg, Fonts, PanelStroke, ONLINE_GREEN, W};
use crate::theme::{brand, white, Fonts, PanelStroke, BRAND, DIM, ONLINE_GREEN, W, WHITE};
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse};
use skia_safe::{Canvas, Color4f, MaskFilter, Paint, Path, Point, RRect, Rect};
@@ -251,7 +251,7 @@ impl HomeScreen {
"Hosts on this network appear automatically — add one by address for everything else.",
W::Regular,
13.0 * k,
fg(0.55),
DIM,
f64::from(rect.left) + w / 2.0,
cy + tile_h / 2.0 + 24.0 * k,
w * 0.7,
@@ -265,7 +265,7 @@ fn draw_host_tile(canvas: &Canvas, fonts: &Fonts, h: &HostRow, rect: Rect, k: f6
canvas,
rect,
TILE_CORNER as f32,
h.saved.then(|| accent(0.20)),
h.saved.then(|| brand(0.20)),
if h.saved {
PanelStroke::Gradient
} else {
@@ -327,7 +327,7 @@ fn draw_host_tile(canvas: &Canvas, fonts: &Fonts, h: &HostRow, rect: Rect, k: f6
sub_base,
W::Regular,
13.0 * k,
fg(0.55),
white(0.55),
max_w,
);
let x = l + addr_w + 8.0 * k;
@@ -352,7 +352,7 @@ fn draw_host_tile(canvas: &Canvas, fonts: &Fonts, h: &HostRow, rect: Rect, k: f6
sub_base,
W::Regular,
13.0 * k,
fg(0.55),
white(0.55),
max_w,
);
}
@@ -364,22 +364,22 @@ fn draw_host_tile(canvas: &Canvas, fonts: &Fonts, h: &HostRow, rect: Rect, k: f6
sub_base - 22.0 * k,
W::Bold,
23.0 * k,
fg(1.0),
WHITE,
max_w,
);
}
/// A profile's `#RRGGBB` accent as a color, defaulting to the PALETTE's accent. Parsed
/// A profile's `#RRGGBB` accent as a color, defaulting to the brand tint. Parsed
/// leniently — a malformed accent (hand-edited catalog) falls back rather than erroring.
fn accent_color(hex: Option<&str>) -> skia_safe::Color4f {
let Some(hex) = hex
fn accent_color(accent: Option<&str>) -> skia_safe::Color4f {
let Some(hex) = accent
.and_then(|a| a.strip_prefix('#'))
.filter(|h| h.len() == 6)
else {
return accent(1.0);
return BRAND;
};
let Ok(v) = u32::from_str_radix(hex, 16) else {
return accent(1.0);
return BRAND;
};
skia_safe::Color4f::new(
((v >> 16) & 0xff) as f32 / 255.0,
@@ -404,9 +404,9 @@ fn draw_add_tile(canvas: &Canvas, fonts: &Fonts, rect: Rect, k: f64) {
let badge = Rect::from_xywh(l as f32, t as f32, (52.0 * k) as f32, (52.0 * k) as f32);
canvas.draw_rrect(
RRect::new_rect_xy(badge, (15.0 * k) as f32, (15.0 * k) as f32),
&Paint::new(accent(0.16), None),
&Paint::new(brand(0.16), None),
);
let mut ring = Paint::new(accent(0.5), None);
let mut ring = Paint::new(brand(0.5), None);
ring.set_style(skia_safe::PaintStyle::Stroke);
ring.set_stroke_width(1.0);
ring.set_anti_alias(true);
@@ -415,7 +415,7 @@ fn draw_add_tile(canvas: &Canvas, fonts: &Fonts, rect: Rect, k: f64) {
&ring,
);
let (bcx, bcy) = (l + 26.0 * k, t + 26.0 * k);
let mut p = Paint::new(accent(1.0), None);
let mut p = Paint::new(BRAND, None);
p.set_style(skia_safe::PaintStyle::Stroke);
p.set_stroke_width((3.0 * k) as f32);
p.set_stroke_cap(skia_safe::PaintCap::Round);
@@ -441,7 +441,7 @@ fn draw_add_tile(canvas: &Canvas, fonts: &Fonts, rect: Rect, k: f64) {
sub_base,
W::Regular,
13.0 * k,
fg(0.55),
white(0.55),
max_w,
);
fonts.draw_clipped(
@@ -451,7 +451,7 @@ fn draw_add_tile(canvas: &Canvas, fonts: &Fonts, rect: Rect, k: f64) {
sub_base - 22.0 * k,
W::Bold,
23.0 * k,
fg(1.0),
WHITE,
max_w,
);
}
@@ -467,8 +467,8 @@ fn draw_monogram(canvas: &Canvas, fonts: &Fonts, name: &str, filled: bool, x: f6
Point::new(badge.left, badge.bottom),
),
skia_safe::gradient_shader::GradientShaderColors::Colors(&[
accent(1.0).to_color(),
accent(0.68).to_color(),
BRAND.to_color(),
brand(0.68).to_color(),
]),
None,
skia_safe::TileMode::Clamp,
@@ -477,8 +477,8 @@ fn draw_monogram(canvas: &Canvas, fonts: &Fonts, name: &str, filled: bool, x: f6
));
canvas.draw_rrect(rr, &p);
} else {
canvas.draw_rrect(rr, &Paint::new(accent(0.16), None));
let mut ring = Paint::new(accent(0.5), None);
canvas.draw_rrect(rr, &Paint::new(brand(0.16), None));
let mut ring = Paint::new(brand(0.5), None);
ring.set_style(skia_safe::PaintStyle::Stroke);
ring.set_stroke_width(1.0);
ring.set_anti_alias(true);
@@ -499,13 +499,13 @@ fn draw_monogram(canvas: &Canvas, fonts: &Fonts, name: &str, filled: bool, x: f6
y + 26.0 * k + size * 0.36,
W::Bold,
size,
if filled { fg(1.0) } else { accent(1.0) },
if filled { WHITE } else { BRAND },
);
}
/// A small padlock: filled body + stroked shackle (the paired-identity mark).
fn draw_lock(canvas: &Canvas, x: f64, y: f64, k: f64) {
let ink = fg(0.5);
let ink = white(0.5);
let body_w = 11.0 * k;
let body_h = 8.0 * k;
let body_top = y + 5.0 * k;
+21 -88
View File
@@ -12,7 +12,7 @@ use crate::library::{
};
use crate::model::{ConsoleCmd, HostRow};
use crate::screens::{ConnectIntent, Ctx, Outbox};
use crate::theme::{accent, fg, Fonts, W};
use crate::theme::{white, Fonts, DIM, W, WHITE};
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse};
use skia_safe::{Canvas, Color4f, Data, Image, Paint, Point, RRect, Rect, M44};
use std::collections::HashMap;
@@ -168,31 +168,10 @@ impl LibraryScreen {
}
}
/// How many launcher entries lead the shelf — [`LibraryShared::set_games`] groups them at the
/// front, so the launcher group is always the prefix `0..launcher_count()`.
fn launcher_count(&self) -> usize {
self.games.iter().take_while(|g| g.launcher).count()
}
/// Is the focused entry a launcher? (Drives the confirm hint: you *open* Steam, you *play* a
/// game.)
fn focused_is_launcher(&self) -> bool {
self.games
.get(self.cursor as usize)
.is_some_and(|g| g.launcher)
}
pub(crate) fn hints(&self, _ctx: &Ctx) -> Vec<Hint> {
match &self.phase {
LibraryPhase::Ready => vec![
Hint::new(
HintKey::Confirm,
if self.focused_is_launcher() {
"Open"
} else {
"Play"
},
),
Hint::new(HintKey::Confirm, "Play"),
Hint::new(HintKey::Shoulders, "Jump"),
Hint::new(HintKey::Back, "Back"),
],
@@ -237,7 +216,7 @@ impl LibraryScreen {
"Loading library…",
W::Regular,
14.0 * k,
fg(0.55),
DIM,
cx,
cy_all + 16.0 * k,
w * 0.8,
@@ -249,7 +228,7 @@ impl LibraryScreen {
"No games found",
W::Bold,
22.0 * k,
fg(1.0),
WHITE,
cx,
cy_all - 20.0 * k,
w * 0.8,
@@ -259,7 +238,7 @@ impl LibraryScreen {
"Install Steam titles or add custom entries in the host's web console.",
W::Regular,
14.0 * k,
fg(0.55),
DIM,
cx,
cy_all + 12.0 * k,
w * 0.8,
@@ -271,7 +250,7 @@ impl LibraryScreen {
&title,
W::Bold,
22.0 * k,
fg(1.0),
WHITE,
cx,
cy_all - 32.0 * k,
w * 0.8,
@@ -281,7 +260,7 @@ impl LibraryScreen {
&body,
W::Regular,
14.0 * k,
fg(0.55),
DIM,
cx,
cy_all + 4.0 * k,
(600.0 * k).min(w * 0.85),
@@ -298,30 +277,6 @@ impl LibraryScreen {
let pos = self.anim.pos;
let bump = self.bump.pos * k;
// Group heading. The model groups launcher entries at the front (design D4), and a
// coverflow is one-dimensional — so instead of a second focus rail (a new up/down nav
// model, in three renderers, for two or three tiles) the heading names the group the
// cursor is in and changes as it crosses the boundary. Drawn only when the shelf
// actually has both groups, so a library without launchers looks exactly as before.
let launchers = self.launcher_count();
if launchers > 0 && launchers < self.games.len() {
let heading = if (self.cursor as usize) < launchers {
"LAUNCHERS"
} else {
"GAMES"
};
fonts.centered(
canvas,
heading,
W::SemiBold,
12.0 * k,
fg(0.5),
f64::from(rect.left) + w / 2.0,
cy - card_h / 2.0 - 22.0 * k,
w * 0.5,
);
}
// Paint order = draw order: farthest from the (integer) cursor first, so the
// dense side stacks overlap toward the focus.
let mut order: Vec<usize> = (0..self.games.len()).collect();
@@ -371,31 +326,21 @@ impl LibraryScreen {
}
None => {
// Solid face, not glass: the side cards OVERLAP.
//
// A launcher tile usually has no poster, and an art-less launcher drawn like
// an art-less game reads as "a game whose cover failed to load". So it gets
// the brand-tinted face and names its launcher, instead of a title monogram.
let face = if game.launcher {
Color4f::new(0.153, 0.137, 0.267, 1.0)
} else {
Color4f::new(0.118, 0.118, 0.145, 1.0)
};
canvas.draw_rect(crect, &Paint::new(face, None));
let (glyph, size, ink) = if game.launcher {
(store_label(&game.store).to_string(), 22.0 * k, fg(0.85))
} else {
(initials(&game.title), 38.0 * k, fg(0.45))
};
let font = fonts.font(W::Bold, size);
let tw = font.measure_str(&glyph, None).0;
canvas.draw_rect(
crect,
&Paint::new(Color4f::new(0.118, 0.118, 0.145, 1.0), None),
);
let mono = initials(&game.title);
let font = fonts.font(W::Bold, 38.0 * k);
let tw = font.measure_str(&mono, None).0;
canvas.draw_str(
&glyph,
&mono,
Point::new(
(card_w as f32 - tw) / 2.0,
card_h as f32 / 2.0 + 13.0 * k as f32,
),
&font,
&Paint::new(ink, None),
&Paint::new(white(0.45), None),
);
}
}
@@ -406,20 +351,13 @@ impl LibraryScreen {
let tw = fonts.measure(label, W::SemiBold, size) as f64;
let (px, py) = (8.0 * k, 8.0 * k);
let (bw, bh) = (tw + 16.0 * k, 20.0 * k);
// Brand-filled for a launcher, smoked glass for a game — the one cue that
// survives being three cards deep in the recede.
let pill = if game.launcher {
accent(0.85)
} else {
crate::theme::shade(0.55)
};
canvas.draw_rrect(
RRect::new_rect_xy(
Rect::from_xywh(px as f32, py as f32, bw as f32, bh as f32),
(bh / 2.0) as f32,
(bh / 2.0) as f32,
),
&Paint::new(pill, None),
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.55), None),
);
fonts.draw(
canvas,
@@ -428,7 +366,7 @@ impl LibraryScreen {
py + 14.0 * k,
W::SemiBold,
size,
fg(1.0),
WHITE,
);
}
// The brightness recede: an opaque-black veil, never whole-card alpha.
@@ -452,22 +390,17 @@ impl LibraryScreen {
&g.title,
W::Bold,
27.0 * k,
fg(1.0),
WHITE,
cx,
f64::from(rect.bottom) - 64.0 * k,
w * 0.8,
);
let sub = if g.launcher {
format!("{} · LAUNCHER", store_label(&g.store).to_uppercase())
} else {
store_label(&g.store).to_uppercase()
};
fonts.centered(
canvas,
&sub,
&store_label(&g.store).to_uppercase(),
W::Regular,
12.0 * k,
fg(0.5),
white(0.5),
cx,
f64::from(rect.bottom) - 30.0 * k,
w * 0.5,
+3 -3
View File
@@ -7,7 +7,7 @@
use crate::glyphs::{Hint, HintKey};
use crate::model::{ConsoleCmd, HostRow, PairPhase};
use crate::screens::{ConnectIntent, Ctx, Outbox};
use crate::theme::{fg, Fonts, ERROR, W};
use crate::theme::{Fonts, DIM, ERROR, W};
use crate::widgets::{permits, Charset, KeyMsg, Keyboard, ListMsg, MenuList, RowSpec};
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
use skia_safe::{Canvas, Rect};
@@ -297,7 +297,7 @@ impl PairScreen {
intro,
W::Regular,
13.0 * k,
fg(0.55),
DIM,
cx,
f64::from(rect.top) + 2.0 * k,
f64::from(rect.width()) * 0.72,
@@ -336,7 +336,7 @@ impl PairScreen {
"Pairing… confirm the PIN on the host",
W::Regular,
13.0 * k,
fg(0.55),
DIM,
cx + 10.0 * k,
status_y,
f64::from(rect.width()) * 0.6,
@@ -8,7 +8,7 @@
use crate::glyphs::{Hint, HintKey};
use crate::model::ConsoleCmd;
use crate::screens::{Ctx, Outbox};
use crate::theme::{fg, Fonts, W};
use crate::theme::{Fonts, DIM, W};
use crate::widgets::{ListMsg, MenuList, RowSpec};
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
use skia_safe::{Canvas, Rect};
@@ -115,7 +115,7 @@ impl PinHostsScreen {
"No saved hosts yet — pair with a host first, then pin this profile to it.",
W::Regular,
14.0 * k,
fg(0.55),
DIM,
cx,
f64::from(rect.top) + f64::from(rect.height()) / 2.0,
f64::from(rect.width()) * 0.7,
@@ -157,7 +157,7 @@ impl PinHostsScreen {
"A pinned profile appears as its own card on the host — one press connects with it.",
W::Regular,
13.0 * k,
fg(0.55),
DIM,
cx,
f64::from(rect.bottom) - detail_h + 6.0 * k,
f64::from(rect.width()) * 0.8,
+78 -270
View File
@@ -2,17 +2,13 @@
//! 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, 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.
//! forward wrapping, B closes. Every change persists immediately; the desktop shells
//! read the same file, so values round-trip freely.
use crate::glyphs::{Hint, HintKey};
use crate::screens::{Ctx, Outbox, Screen};
use crate::theme::{fg, Fonts, W};
use crate::widgets::{ListMsg, MenuList, RowSpec, TabStrip, TAB_STRIP_H};
use crate::theme::{Fonts, DIM, W};
use crate::widgets::{ListMsg, MenuList, RowSpec};
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
use pf_client_core::trust::{MouseMode, StatsVerbosity, TouchMode};
use skia_safe::{Canvas, Rect};
@@ -55,10 +51,6 @@ 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
@@ -66,77 +58,39 @@ 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 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", &[]),
// 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,
];
/// 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),
@@ -215,12 +169,6 @@ 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.
@@ -241,37 +189,20 @@ 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 rows of the CURRENT tab. Profiles is built from the catalog: one row per
/// profile, or the explainer placeholder while there are none.
/// 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.
fn row_ids(&self) -> Vec<RowId> {
if self.tab != PROFILES_TAB {
return TABS[self.tab].1.to_vec();
}
let mut ids = ROWS.to_vec();
if self.profiles.is_empty() {
vec![RowId::NoProfiles]
ids.push(RowId::NoProfiles);
} else {
(0..self.profiles.len()).map(RowId::Profile).collect()
ids.extend((0..self.profiles.len()).map(RowId::Profile));
}
}
/// 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<MenuPulse> {
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)
ids
}
pub(crate) fn menu(
@@ -280,14 +211,9 @@ impl SettingsScreen {
ctx: &mut Ctx,
fx: &mut Outbox,
) -> Option<MenuPulse> {
match ev {
MenuEvent::Back => {
fx.pop();
return None;
}
MenuEvent::JumpBack => return self.switch_tab(-1),
MenuEvent::JumpForward => return self.switch_tab(1),
_ => {}
if ev == MenuEvent::Back {
fx.pop();
return None;
}
let ids = self.row_ids();
let (msg, pulse) = self.list.menu(ev, ids.len());
@@ -345,22 +271,18 @@ impl SettingsScreen {
}
pub(crate) fn hints(&self, _ctx: &Ctx) -> Vec<Hint> {
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![
match self.row_ids()[self.list.cursor] {
RowId::Profile(_) => vec![
Hint::new(HintKey::Confirm, "Pin to hosts…"),
Hint::new(HintKey::Back, "Done"),
],
Some(RowId::NoProfiles) | None => vec![Hint::new(HintKey::Back, "Done")],
Some(_) => vec![
RowId::NoProfiles => vec![Hint::new(HintKey::Back, "Done")],
_ => vec![
Hint::new(HintKey::Adjust, "Adjust"),
Hint::new(HintKey::Confirm, "Change"),
Hint::new(HintKey::Back, "Done"),
],
});
hints
}
}
pub(crate) fn render(
@@ -372,23 +294,11 @@ impl SettingsScreen {
fonts: &Fonts,
ctx: &mut Ctx,
) {
// The tab strip takes the top band, the focused row's explainer a reserved band
// under the list; the rows get what's between.
// The focused row's explainer sits in a reserved band under the list.
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 + strip_h as f32,
rect.top,
rect.right,
rect.bottom - detail_h as f32,
);
@@ -399,13 +309,13 @@ impl SettingsScreen {
.collect();
self.list
.render(canvas, list_rect, &rows, fonts, k, dt, true);
let detail = ids.get(self.list.cursor).copied().map_or("", detail);
let detail = detail(ids[self.list.cursor]);
fonts.centered(
canvas,
detail,
W::Regular,
13.0 * k,
fg(0.55),
DIM,
f64::from(rect.left) + f64::from(rect.width()) / 2.0,
f64::from(rect.bottom) - detail_h + 6.0 * k,
f64::from(rect.width()) * 0.8,
@@ -425,7 +335,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: None,
header: (i == 0).then_some("Profiles"),
label: name.clone(),
value: Some(match pins {
0 => "Not pinned".into(),
@@ -439,7 +349,9 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
};
}
RowId::NoProfiles => {
return RowSpec::action("No profiles yet", false);
let mut row = RowSpec::action("No profiles yet", false);
row.header = Some("Profiles");
return row;
}
_ => {}
}
@@ -460,7 +372,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 => (
None,
Some("Stream"),
"Resolution",
if s.match_window {
"Match window".into()
@@ -504,7 +416,11 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
"Compositor",
label_for(&COMPOSITORS, &s.compositor).into(),
),
RowId::Codec => (None, "Video codec", label_for(&CODECS, &s.codec).into()),
RowId::Codec => (
Some("Video"),
"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()),
@@ -525,7 +441,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 => (
None,
Some("Audio"),
"Audio channels",
AUDIO
.iter()
@@ -536,7 +452,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 => (
None,
Some("Controller"),
"Forward controllers",
on_off(s.gamepad_forwarding).into(),
),
@@ -567,7 +483,11 @@ 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 => (None, "Touch mode", s.touch_mode().label().into()),
RowId::Touch => (
Some("Touchscreen"),
"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 => (
@@ -575,13 +495,8 @@ 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 => (
None,
Some("Interface"),
"Statistics overlay",
s.stats_verbosity().label().into(),
),
@@ -688,10 +603,6 @@ 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."
@@ -855,11 +766,6 @@ 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),
@@ -1165,18 +1071,19 @@ mod tests {
("p1".into(), "Work".into()),
("p2".into(), "Game".into()),
]);
s.tab = PROFILES_TAB;
let ids = s.row_ids();
assert_eq!(ids, vec![RowId::Profile(0), RowId::Profile(1)]);
assert_eq!(ids.len(), ROWS.len() + 2);
assert_eq!(ids[ROWS.len()], RowId::Profile(0));
let spec = row_spec(RowId::Profile(0), &ctx, &s.profiles);
assert_eq!(spec.header, None, "the tab pill names the section");
assert_eq!(spec.header, Some("Profiles"));
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 = 0; // onto "Work"
s.list.cursor = ROWS.len(); // onto "Work"
let mut fx = Outbox::default();
s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
assert!(
@@ -1211,10 +1118,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, vec![RowId::NoProfiles]);
assert_eq!(*ids.last().unwrap(), 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;
@@ -1223,103 +1130,4 @@ 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<RowId> = 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"
);
}
}

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