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
206 changed files with 1856 additions and 12996 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
+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:
+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,13 +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.
CompositionLocalProvider(
LocalGamepadPalette provides GamepadPalette.named(settings.uiPalette),
) {
AnimatedContent(
targetState = session,
transitionSpec = {
@@ -121,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,
@@ -142,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
@@ -230,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 }
@@ -255,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 /
@@ -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,
)
}
}
}
@@ -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
@@ -93,53 +86,32 @@ private val auroraBlobs = listOf(
AuroraBlob(Color(0xFF3862DB), 0.72f, 0.14f, 0.10f, 0.08f, 1, 3, 1.2f, 0.48f, 0.40f), // cool blue
)
/** The deep base the field sits on — and, scaled, the [calm] lift that flattens it. */
private val auroraBase = Color(0xFF131126)
/**
* The living console backdrop: soft brand-family blobs drifting over a deep base on slow, seamless
* loops, finished with a centre-pooling vignette and top/bottom legibility scrims. A Compose
* approximation of the Apple client's MeshGradient aurora — same colour family, same "ambience,
* never content" role, and the same [GamepadPalette] setting recolours both.
*
* [calm] is what the FORM screens wear: the pools dim onto the base so the glass rows keep real
* colour and luminance without the launcher's contrast. Motion is identical either way on purpose —
* only the contrast differs, so moving between screens can't make the field jump.
*
* Honours the system's "remove animations" accessibility setting by freezing at a fixed phase, the
* same courtesy the Apple client pays Reduce Motion.
* 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 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(
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
// Tinting is per-frame-cheap but not free, and the palette changes about once a year.
val blobs = remember(palette.id) { auroraBlobs.map { it to palette.tint(it.color) } }
val base = remember(palette.id) { palette.tint(auroraBase) }
Canvas(modifier) {
drawRect(if (calm) base else Color.Black)
drawRect(Color.Black)
val span = max(size.width, size.height)
for ((b, tinted) in blobs) {
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 base
// stays put and only the pools come down to meet it, which is the same "lower the
// contrast, keep the colour" the desktop console's `calm` uniform does.
val alpha = if (calm) b.alpha * 0.62f else b.alpha
drawCircle(
brush = Brush.radialGradient(
colors = listOf(tinted.copy(alpha = alpha), Color.Transparent),
colors = listOf(b.color.copy(alpha = b.alpha), Color.Transparent),
center = Offset(cx, cy),
radius = r,
),
@@ -148,15 +120,10 @@ fun GamepadAuroraBackground(modifier: Modifier = Modifier, calm: Boolean = false
blendMode = BlendMode.Plus,
)
}
// Cinematic vignette: pool light centre, sink the corners. Halved under calm: a launcher's
// cards sit in the pooled centre, but a form screen's rows run out toward the edges, where
// crushing to black just eats them. (Matches the Apple client and the desktop console.)
// Cinematic vignette: pool light centre, sink the corners.
drawRect(
Brush.radialGradient(
colors = listOf(
Color.Transparent,
Color.Black.copy(alpha = if (calm) 0.22f else 0.44f),
),
colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.44f)),
center = Offset(size.width / 2, size.height / 2),
radius = span * 0.92f,
),
@@ -174,96 +141,33 @@ fun GamepadAuroraBackground(modifier: Modifier = Modifier, calm: Boolean = false
}
/**
* `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 listState = rememberLazyListState()
LaunchedEffect(selected) {
runCatching { listState.animateScrollToItem(selected.coerceAtLeast(0)) }
}
LazyRow(
state = listState,
modifier = modifier,
contentPadding = PaddingValues(horizontal = ConsoleEdgeInset),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
itemsIndexed(titles) { i, title ->
val active = i == selected
val background by animateColorAsState(
if (active) Color(0xD96656F2) else Color(0x14FFFFFF),
tween(180),
label = "tabBg",
)
val ink by animateColorAsState(
Color.White.copy(alpha = if (active) 1f else 0.55f),
tween(180),
label = "tabInk",
)
val ring by animateColorAsState(
Color.White.copy(alpha = if (active && focused) 0.85f else 0f),
tween(180),
label = "tabRing",
)
Text(
title,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = ink,
maxLines = 1,
modifier = Modifier
.clip(RoundedCornerShape(50))
.background(background)
.border(1.5.dp, ring, RoundedCornerShape(50))
.clickable { onSelect(i) }
.padding(horizontal = 14.dp, vertical = 7.dp),
)
}
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,
)
}
}
@@ -272,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
@@ -567,12 +471,7 @@ fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, haze
Row(
modifier = frosted
.border(1.dp, Color.White.copy(alpha = 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()),
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(11.dp),
) {
@@ -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,84 +0,0 @@
package io.unom.punktfunk
import androidx.compose.ui.graphics.Color
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
// The console (gamepad) UI's background colour families.
//
// A palette is NOT a second hand-tuned colour field: it is a hue rotation + saturation scale
// applied to the ONE field GamepadAuroraBackground already draws, so every palette inherits its
// structure (dark base, bright drifting pools) and the brand default is exactly the shipped look —
// `violet` is the identity transform.
//
// The table and the `tint` maths are mirrored in `pf-console-ui`'s `library.rs` (Rust) and the
// Apple client's `GamepadPalette.swift` under the same ids, so the shared `ui_palette` setting
// names the same colour family on every client. Keep the three copies in step: a palette added
// here without the others is a value the other clients will silently render as Violet.
/**
* One background colour family. [hueDegrees] rotates about the grey axis (positive runs
* red → green → blue) and [saturation] scales saturation about luminance.
*/
class GamepadPalette(
/** The stored `ui_palette` value ([Settings.uiPalette]). */
val id: String,
/** What the settings row shows. */
val name: String,
val hueDegrees: Double,
val saturation: Double,
) {
/** True for the identity transform, so the default path skips the per-colour work. */
val isIdentity: Boolean get() = hueDegrees == 0.0 && saturation == 1.0
/** Apply this palette to one packed sRGB colour, keeping its alpha. */
fun tint(c: Color): Color {
if (isIdentity) return c
val (r, g, b) = tint(Triple(c.red.toDouble(), c.green.toDouble(), c.blue.toDouble()))
return Color(r.toFloat(), g.toFloat(), b.toFloat(), c.alpha)
}
/**
* Rotate `c` about the grey axis by [hueDegrees] (Rodrigues — the same rotation, in the same
* orientation, that the desktop console's shader uses for its ±8° warm/cool sway) and scale
* its saturation about luminance. Clamped, because a large rotation can push a channel out of
* gamut.
*/
fun tint(c: Triple<Double, Double, Double>): Triple<Double, Double, Double> {
val (r, g, b) = c
val a = Math.toRadians(hueDegrees)
val cs = cos(a)
val sn = sin(a)
val invSqrt3 = 1.0 / sqrt(3.0)
val grey = (r + g + b) / 3.0 * (1.0 - cs)
// The `sn` term is cross(k, c) with k = (1,1,1)/√3.
val rr = r * cs + (b - g) * invSqrt3 * sn + grey
val rg = g * cs + (r - b) * invSqrt3 * sn + grey
val rb = b * cs + (g - r) * invSqrt3 * sn + grey
val luma = 0.2126 * rr + 0.7152 * rg + 0.0722 * rb
fun mix(v: Double) = (luma + (v - luma) * saturation).coerceIn(0.0, 1.0)
return Triple(mix(rr), mix(rg), mix(rb))
}
companion object {
/**
* The six shipped palettes, in cycling order: the brand violet, then cool → warm, then
* the neutral.
*/
val ALL = listOf(
GamepadPalette("violet", "Violet", 0.0, 1.0),
GamepadPalette("tide", "Tide", -70.0, 1.0),
GamepadPalette("forest", "Forest", -130.0, 0.9),
GamepadPalette("ember", "Ember", 105.0, 1.0),
GamepadPalette("rose", "Rose", 60.0, 0.95),
GamepadPalette("graphite", "Graphite", 0.0, 0.12),
)
/**
* The palette stored under [id], falling back to the brand default — an unknown name is a
* palette a newer client shipped, not a reason to draw nothing.
*/
fun named(id: String): GamepadPalette = ALL.firstOrNull { it.id == id } ?: ALL[0]
}
}
@@ -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),
)
@@ -445,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,
@@ -454,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,
@@ -478,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,
@@ -490,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
@@ -510,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.",
@@ -570,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,
@@ -589,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,
@@ -601,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,
@@ -611,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).
@@ -678,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 " +
@@ -691,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"
@@ -145,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(
@@ -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,132 +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::tint`) and Swift (`GamepadPalette.tint`) ports have to reproduce — the
// same ids, the same rotation orientation, the same in-gamut results — so one `ui_palette` value
// names the same colour family on every client.
class GamepadPaletteTest {
/** The brightest pool of the field — the colour a palette is judged by. */
private val violetPool = Triple(0.49, 0.39, 0.95)
/**
* The brand default must be the IDENTITY transform. Every existing install already sees the
* shipped violet backdrop, and a palette table that quietly restyled it would be a regression
* dressed as a feature.
*/
@Test
fun violetIsTheUntouchedShippedField() {
val violet = GamepadPalette.named("violet")
assertEquals("violet", GamepadPalette.ALL.first().id)
assertTrue(violet.isIdentity)
assertEquals(violetPool, violet.tint(violetPool))
// An unknown name is a newer client's palette, not an error.
assertEquals("violet", GamepadPalette.named("chartreuse").id)
assertEquals("violet", GamepadPalette.named("").id)
}
/** The ids and their order are the cross-client contract (strip order, and the L1/R1 cycle). */
@Test
fun tableMatchesTheOtherClients() {
assertEquals(
listOf("violet", "tide", "forest", "ember", "rose", "graphite"),
GamepadPalette.ALL.map { it.id },
)
assertEquals(
listOf("Violet", "Tide", "Forest", "Ember", "Rose", "Graphite"),
GamepadPalette.ALL.map { it.name },
)
}
/**
* A rotation moves the hue while roughly holding luminance, and the saturation scale collapses
* toward grey the same four checks the Rust and Swift tests make.
*/
@Test
fun tintRotatesHueAndScalesSaturation() {
assertTrue(violetPool.third > violetPool.first && violetPool.third > violetPool.second)
// +105° (Ember) turns the blue-dominant pool red-dominant…
val ember = GamepadPalette.named("ember").tint(violetPool)
assertTrue("$ember should be warm", ember.first > ember.third)
// …−130° (Forest) turns it green-dominant…
val forest = GamepadPalette.named("forest").tint(violetPool)
assertTrue("$forest", forest.second > forest.first && forest.second > forest.third)
// …and 70° (Tide) lands on a cyan whose green and blue both beat red.
val tide = GamepadPalette.named("tide").tint(violetPool)
assertTrue("$tide", tide.second > tide.first && tide.third > tide.first)
// Graphite's saturation scale leaves the channels nearly equal…
val grey = GamepadPalette.named("graphite").tint(violetPool)
val channels = listOf(grey.first, grey.second, grey.third)
assertTrue("$grey", channels.max() - channels.min() < 0.08)
// …at about the source's luminance (it desaturates, it doesn't dim).
val luma = 0.2126 * violetPool.first + 0.7152 * violetPool.second + 0.0722 * violetPool.third
assertEquals(luma, grey.second, 0.05)
}
/**
* Every palette stays in gamut on every colour the field is built from an out-of-range
* channel would clamp differently on each platform's rasteriser.
*/
@Test
fun everyPaletteStaysInGamut() {
val field = listOf(
Triple(0.075, 0.060, 0.160), Triple(0.34, 0.27, 0.72), Triple(0.30, 0.26, 0.74),
Triple(0.42, 0.20, 0.54), Triple(0.49, 0.39, 0.95), Triple(0.28, 0.31, 0.84),
Triple(0.16, 0.26, 0.64), Triple(0.45, 0.23, 0.60), Triple(0.53, 0.31, 0.75),
Triple(0.35, 0.35, 0.91), Triple(0.19, 0.28, 0.70), Triple(0.22, 0.18, 0.54),
Triple(0.24, 0.20, 0.58),
)
for (palette in GamepadPalette.ALL) {
for (c in field) {
val t = palette.tint(c)
for (v in listOf(t.first, t.second, t.third)) {
assertTrue("${palette.id} $c$t", v in 0.0..1.0)
}
}
}
}
/**
* Every settings row lands in exactly one tab a row missing from the tab map is a setting
* that became unreachable on a TV, which is precisely what this screen exists to prevent.
*/
@Test
fun everySettingsRowHasATab() {
val rows = buildSettingsRows(Settings(), hasBodyVibrator = true, av1Capable = true) {}
assertTrue(rows.isNotEmpty())
assertEquals(rows.size, rows.map { it.id }.toSet().size)
// Profiles is built separately (from the catalog), so no settings row claims it.
assertTrue(rows.none { it.tab == GpTab.PROFILES })
for (t in listOf(GpTab.STREAM, GpTab.VIDEO, GpTab.AUDIO, GpTab.CONTROLLER, GpTab.INTERFACE)) {
assertTrue("$t is empty", rows.any { it.tab == t })
}
}
/** The Background row steps the shared `ui_palette` key and wraps on A, like every choice row. */
@Test
fun backgroundRowStepsTheSharedKey() {
var s = Settings()
fun rows() = buildSettingsRows(s, hasBodyVibrator = false, av1Capable = false) { s = it }
fun palette() = rows().first { it.id == "palette" }
assertEquals("violet", s.uiPalette)
assertEquals("Violet", palette().value)
assertTrue("already the first = thud", !palette().adjust(-1))
assertTrue(palette().adjust(1))
assertEquals(GamepadPalette.ALL[1].id, s.uiPalette)
// A from the last entry wraps home.
s = s.copy(uiPalette = GamepadPalette.ALL.last().id)
palette().activate()
assertEquals("violet", s.uiPalette)
// A store written by a newer client shows the palette that is actually drawing.
s = s.copy(uiPalette = "chartreuse")
assertEquals("Violet", palette().value)
}
}
@@ -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,9 +106,6 @@ class ScreenshotTest {
@Test
fun connectingConsole() = shootRoot("connecting-console") { ConnectConsoleScene() }
@Test
fun consoleSettings() = shootRoot("console-settings") { ConsoleSettingsScene() }
@Test
fun trust() = shootScreen("trust") {
HostsScene()
@@ -31,7 +31,6 @@ import io.unom.punktfunk.BrandDark
import io.unom.punktfunk.ConnectModal
import io.unom.punktfunk.ConnectPhase
import io.unom.punktfunk.ConnectTakeover
import io.unom.punktfunk.GamepadSettingsScreen
import io.unom.punktfunk.Settings
import io.unom.punktfunk.TouchMode
import io.unom.punktfunk.SettingsCategory
@@ -356,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(
@@ -407,13 +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() =
GamepadSettingsScreen(initial = SHOT_SETTINGS, 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
@@ -127,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")
@@ -168,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).
@@ -122,21 +122,12 @@ struct GamepadHintBar: View {
}
}
/// 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
@@ -145,52 +136,35 @@ struct GamepadHintBar: View {
/// can't inflate the caller's layout past the safe area (see the layout note in GamepadHomeView's
/// header). Honors Reduce Motion by freezing the field at a fixed phase.
struct GamepadScreenBackground: View {
/// Quiet the field for a form screen (see the type comment).
var calm = false
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
var body: some View {
let palette = GamepadPalette.named(paletteID)
Group {
if reduceMotion {
composite(at: 0, 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 {
/// 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, palette: palette)
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 + corner·0.4: over black, `.opacity` IS the multiply
.opacity(calm ? 0.6 : 1)
if calm {
// and a plusLighter wash of the palette's own corner colour IS the add. Chosen so
// a corner lands exactly where it was and the bright pools come down to meet it.
Self.color(palette.tint(Self.cornerRGB))
.opacity(0.4)
.blendMode(.plusLighter)
}
// Cinematic vignette: darker toward the edges so the cards sit in the pooled light.
// Soft (extends past the frame) so the corners deepen rather than crush to black.
// Halved under calm: a launcher's cards sit in the pooled centre, but a form screen's
// rows run out toward the edges, where crushing to black just eats them.
EllipticalGradient(
colors: [.clear, .black.opacity(calm ? 0.21 : 0.42)],
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
// darkens the aurora itself (it's the backdrop's bottom layer nothing behind it to
@@ -206,45 +180,33 @@ struct GamepadScreenBackground: View {
}
}
@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: Self.meshColors(palette),
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)
}
/// The corner colour the four pinned corners AND the calm lift's base.
static let cornerRGB = SIMD3(0.075, 0.060, 0.160)
/// Sixteen mesh colours (row-major, 4×4): dark-violet corners sink the frame, the edges carry
/// mid-tone violets, and the four interior points hold the bright brand family a violet and a
/// blue-violet up top, a magenta-violet and a violet below so warm pools on the left, cool on
/// the right, and the silk shifts temperature as those interior points drift. A palette rotates
/// the whole grid; `violet` is the identity, so this array IS what the default draws.
private static let baseMeshRGB: [SIMD3<Double>] = [
cornerRGB, SIMD3(0.34, 0.27, 0.72), SIMD3(0.30, 0.26, 0.74), cornerRGB,
SIMD3(0.42, 0.20, 0.54), SIMD3(0.49, 0.39, 0.95), SIMD3(0.28, 0.31, 0.84), SIMD3(0.16, 0.26, 0.64),
SIMD3(0.45, 0.23, 0.60), SIMD3(0.53, 0.31, 0.75), SIMD3(0.35, 0.35, 0.91), SIMD3(0.19, 0.28, 0.70),
cornerRGB, SIMD3(0.22, 0.18, 0.54), SIMD3(0.24, 0.20, 0.58), cornerRGB,
]
/// `baseMeshRGB` under a palette. Recomputed per frame rather than cached sixteen `tint`
/// calls at 30 Hz costs nothing next to rasterising the mesh, and the obvious cache would be
/// mutable global state on a type SwiftUI is free to evaluate off the main actor.
private static func meshColors(_ palette: GamepadPalette) -> [Color] {
baseMeshRGB.map { color(palette.tint($0)) }
}
/// the 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
@@ -271,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.
private struct Blob {
let rgb: SIMD3<Double>
let color: Color
let center: CGPoint
let drift: CGSize
let speed: (x: Double, y: Double)
@@ -293,19 +252,19 @@ private struct LegacyBlobField: View {
}
private static let blobs: [Blob] = [
Blob(rgb: SIMD3(0.53, 0.47, 0.96), // brand violet
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(rgb: SIMD3(0.24, 0.20, 0.72), // deep indigo
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(rgb: SIMD3(0.62, 0.30, 0.80), // plum
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(rgb: SIMD3(0.22, 0.38, 0.86), // cool blue
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),
@@ -328,10 +287,9 @@ private struct LegacyBlobField: View {
let y = blob.center.y + blob.drift.height * CGFloat(cos(t * blob.speed.y + blob.phase.y))
let r = side * blob.radius
* (1 + blob.breathe.amount * CGFloat(sin(t * blob.breathe.speed + blob.phase.x)))
let color = GamepadScreenBackground.color(palette.tint(blob.rgb))
return Circle()
.fill(RadialGradient(
colors: [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)
@@ -372,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()
}
}
@@ -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
@@ -263,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"))
}
@@ -330,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
@@ -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.
@@ -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,17 +27,6 @@ import GameController
import CoreHaptics
#endif
/// The settings screen's sections. Order IS the strip order and the L1/R1 cycle order; the names
/// match `pf-console-ui`'s `TABS` and the Android client's `GpTab`.
enum GpSettingsTab: String, CaseIterable, Hashable {
case stream = "Stream"
case video = "Video"
case audio = "Audio"
case controller = "Controller"
case interface = "Interface"
case profiles = "Profiles"
}
struct GamepadSettingsView: View {
@Environment(\.dismiss) private var dismiss
/// The saved-host store the pin picker writes `setPinned` through it and the profile rows
@@ -72,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
@@ -94,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?
@@ -124,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)
@@ -133,19 +101,14 @@ 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(.white)
.frame(maxWidth: .infinity)
.overlay(alignment: .trailing) { closeButton.padding(.trailing, 20) }
// The picker is one layer deeper its rows aren't sections of anything, so the
// strip would be a control that does nothing while it's up.
if pinTarget == nil { tabStrip }
}
.padding(.top, gamepadTitleTopPadding(compact: compact))
.padding(.bottom, compact ? 4 : 8)
.background { GamepadTrayScrim(edge: .top) }
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) {
@@ -164,9 +127,8 @@ 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() }
.onAppear {
gamepads.refresh()
@@ -175,101 +137,6 @@ struct GamepadSettingsView: View {
.onDisappear { gamepads.stopDiscovery() }
}
/// The section switcher. Horizontally scrollable so a narrow phone in landscape never has to
/// squeeze six pills the selected one is always scrolled into view, whether it was reached
/// by shoulder button, tap, or (tvOS) the focus engine.
private var tabStrip: some View {
ScrollViewReader { proxy in
ScrollView(.horizontal) {
HStack(spacing: 6) {
ForEach(GpSettingsTab.allCases, id: \.self) { t in
#if os(tvOS)
// Focusable, because L1/R1 is NOT a route here: a Siri Remote has no
// extended gamepad profile, so it never reaches GamepadMenuList's poll.
// As focusable Buttons the pills are simply above the rows, and moving
// focus up onto one switches section the standard tvOS tab bar.
Button { select(tab: t) } label: { pill(t) }
.buttonStyle(ConsoleBareButtonStyle())
.focused($focusedTab, equals: t)
.id(t)
#else
pill(t)
.contentShape(Capsule())
.onTapGesture { select(tab: t) }
.id(t)
#endif
}
}
.padding(.horizontal, 24)
}
.scrollIndicators(.never)
.animation(.smooth(duration: 0.22), value: tab)
.onChange(of: tab) { _, t in
withAnimation(.easeOut(duration: 0.2)) { proxy.scrollTo(t) }
}
#if os(tvOS)
.onChange(of: focusedTab) { _, t in
// Focus IS selection on a tab bar; nil means focus dropped back into the rows.
if let t { select(tab: t) }
}
#endif
}
}
private func pill(_ t: GpSettingsTab) -> some View {
let selected = t == tab
return Text(t.rawValue)
.font(.geist(compact ? 12 : 13, .semibold, relativeTo: .footnote))
.foregroundStyle(selected ? .white : .white.opacity(0.55))
.padding(.horizontal, 13)
.padding(.vertical, 7)
.background {
// One shared capsule that MOVES between pills, rather than one per pill fading
// in and out the highlight travels the way the press did.
if selected {
Capsule()
.fill(Color.brand.opacity(0.85))
.matchedGeometryEffect(id: "tab", in: tabHighlight)
}
}
}
/// Whether the legend advertises the shoulder shortcut. Held back on an iPhone, whose legend
/// is already at its width and would push "Done" off the edge the strip is visible and
/// tappable there anyway. Never on tvOS: a Siri Remote has no shoulders, and its route to the
/// sections is the focus engine (see `tabStrip`).
private var showsSectionHint: Bool {
#if os(tvOS)
false
#elseif os(iOS)
hSizeClass == .regular
#else
true
#endif
}
/// L1/R1 one section along, wrapping (the strip is a ring, like A's value cycle).
private func step(tabBy delta: Int) {
guard pinTarget == nil else { return }
let all = GpSettingsTab.allCases
guard let i = all.firstIndex(of: tab) else { return }
let n = all.count
select(tab: all[((i + delta) % n + n) % n])
}
private func select(tab next: GpSettingsTab) {
guard next != tab else { return }
tabFocus[tab] = focusID
// Restore where this tab was, if that row is still in it (a row can come and go with the
// hardware it depends on); otherwise the focus list seeds its first row. Resolved against
// `allRows` rather than `rows` so it doesn't depend on `tab`'s write being visible yet.
let landing = tabFocus[next].flatMap { id in
allRows.contains { $0.tab == next && $0.id == id } ? id : nil
}
tab = next
focusID = landing
}
/// Touch/click fallback for closing the controller path is B, a hardware keyboard's Esc
/// rides the cancel action.
private var closeButton: some View {
@@ -299,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"),
@@ -341,9 +201,15 @@ 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))
@@ -410,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
@@ -448,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) }
@@ -466,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)"
@@ -477,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.",
@@ -526,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 "
@@ -552,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,
@@ -581,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 "
@@ -631,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.",
@@ -658,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,
@@ -690,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,
@@ -701,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 "
@@ -763,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,
@@ -792,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
@@ -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)"
}
@@ -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,79 +0,0 @@
// The gamepad UI's background colour families.
//
// A palette is NOT a second hand-tuned colour field: it is a hue rotation + saturation scale
// applied to the ONE field GamepadScreenBackground already draws, so every palette inherits its
// structure (dark corners, bright interior pools, warm-left/cool-right) and the brand default is
// exactly the shipped look `violet` is the identity transform.
//
// The table and the `tint` math are mirrored in `pf-console-ui`'s `library.rs` (Rust) and the
// Android client's `GamepadPalette.kt` (Kotlin) under the same ids, so the shared `ui_palette`
// setting names the same colour family on every client. Keep the three copies in step: a palette
// added here without the others is a value the other clients will silently render as Violet.
//
// It lives in PunktfunkShared rather than next to the views because that is the target the tests
// can reach the arithmetic below is the part that has to agree across three languages.
import Foundation
import simd
public struct GamepadPalette: Identifiable, Equatable, Sendable {
/// The stored `ui_palette` value (`DefaultsKey.uiPalette`).
public let id: String
/// What the settings row shows.
public let name: String
/// Hue rotation about the grey axis, degrees positive runs red green blue.
public let hueDegrees: Double
/// Saturation scale about luminance; 1 keeps the source saturation.
public let saturation: Double
/// The six shipped palettes, in cycling order: the brand violet, then cool warm, then the
/// neutral.
public static let all: [GamepadPalette] = [
GamepadPalette(id: "violet", name: "Violet", hueDegrees: 0, saturation: 1.0),
GamepadPalette(id: "tide", name: "Tide", hueDegrees: -70, saturation: 1.0),
GamepadPalette(id: "forest", name: "Forest", hueDegrees: -130, saturation: 0.9),
GamepadPalette(id: "ember", name: "Ember", hueDegrees: 105, saturation: 1.0),
GamepadPalette(id: "rose", name: "Rose", hueDegrees: 60, saturation: 0.95),
GamepadPalette(id: "graphite", name: "Graphite", hueDegrees: 0, saturation: 0.12),
]
/// The palette stored under `id`, falling back to the brand default an unknown name is a
/// palette a newer client shipped, not a reason to draw nothing.
public static func named(_ id: String) -> GamepadPalette {
all.first { $0.id == id } ?? all[0]
}
/// `true` for the identity transform, so the default path can skip the per-colour work.
public var isIdentity: Bool { hueDegrees == 0 && saturation == 1 }
/// Apply this palette to one RGB triple.
public func tint(_ c: SIMD3<Double>) -> SIMD3<Double> {
guard !isIdentity else { return c }
return GamepadPalette.tint(c, hueDegrees: hueDegrees, saturation: saturation)
}
/// Rotate `c` about the grey axis by `hueDegrees` (Rodrigues the same rotation the field's
/// own ±8° warm/cool sway uses, in the same orientation) and scale its saturation about
/// luminance. Clamped, because a large rotation can push a channel out of gamut.
///
/// Deliberately computed here rather than left to SwiftUI's `.hueRotation`: that modifier's
/// exact behaviour is the framework's, and the Rust and Kotlin clients have no equivalent
/// doing the arithmetic on the COLOURS keeps the three implementations identical.
public static func tint(
_ c: SIMD3<Double>, hueDegrees: Double, saturation: Double
) -> SIMD3<Double> {
let a = hueDegrees * .pi / 180
let cs = cos(a)
let sn = sin(a)
let invSqrt3 = 1 / 3.0.squareRoot()
let grey = (c.x + c.y + c.z) / 3 * (1 - cs)
// The `sn` term is cross(k, c) with k = (1,1,1)/3.
let rot = SIMD3(
c.x * cs + (c.z - c.y) * invSqrt3 * sn + grey,
c.y * cs + (c.x - c.z) * invSqrt3 * sn + grey,
c.z * cs + (c.y - c.x) * invSqrt3 * sn + grey)
let luma = 0.2126 * rot.x + 0.7152 * rot.y + 0.0722 * rot.z
func mix(_ v: Double) -> Double { min(max(luma + (v - luma) * saturation, 0), 1) }
return SIMD3(mix(rot.x), mix(rot.y), mix(rot.z))
}
}
@@ -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,81 +0,0 @@
// The gamepad UI's background palettes. These assertions are the CONTRACT the Rust
// (`pf-console-ui::library::tint`) and Kotlin (`GamepadPalette.tint`) ports have to reproduce
// the same ids, the same rotation orientation, the same in-gamut results so one `ui_palette`
// value names the same colour family on every client.
import XCTest
import simd
@testable import PunktfunkShared
final class GamepadPaletteTests: XCTestCase {
/// The brightest interior pool of the mesh field the colour a palette is judged by.
private let violetPool = SIMD3(0.49, 0.39, 0.95)
/// The brand default must be the IDENTITY transform. Every existing install already sees the
/// shipped violet backdrop, and a palette table that quietly restyled it would be a
/// regression dressed as a feature.
func testVioletIsTheUntouchedShippedField() {
let violet = GamepadPalette.named("violet")
XCTAssertEqual(GamepadPalette.all.first?.id, "violet")
XCTAssertTrue(violet.isIdentity)
XCTAssertEqual(violet.tint(violetPool), violetPool)
// An unknown name is a newer client's palette, not an error.
XCTAssertEqual(GamepadPalette.named("chartreuse").id, "violet")
XCTAssertEqual(GamepadPalette.named("").id, "violet")
}
/// The ids and their order are the cross-client contract (the strip order, and the order
/// L1/R1 and A cycle through).
func testTableMatchesTheOtherClients() {
XCTAssertEqual(
GamepadPalette.all.map(\.id),
["violet", "tide", "forest", "ember", "rose", "graphite"])
XCTAssertEqual(
GamepadPalette.all.map(\.name),
["Violet", "Tide", "Forest", "Ember", "Rose", "Graphite"])
}
/// A rotation moves the hue while roughly holding luminance, and the saturation scale
/// collapses toward grey the same four checks the Rust test makes.
func testTintRotatesHueAndScalesSaturation() {
XCTAssertTrue(violetPool.z > violetPool.x && violetPool.z > violetPool.y, "blue-dominant")
// +105° (Ember) turns the blue-dominant pool red-dominant
let ember = GamepadPalette.named("ember").tint(violetPool)
XCTAssertGreaterThan(ember.x, ember.z, "\(ember) should be warm")
// 130° (Forest) turns it green-dominant
let forest = GamepadPalette.named("forest").tint(violetPool)
XCTAssertTrue(forest.y > forest.x && forest.y > forest.z, "\(forest)")
// and 70° (Tide) lands on a cyan whose green and blue both beat red.
let tide = GamepadPalette.named("tide").tint(violetPool)
XCTAssertTrue(tide.y > tide.x && tide.z > tide.x, "\(tide)")
// Graphite's saturation scale leaves the channels nearly equal
let grey = GamepadPalette.named("graphite").tint(violetPool)
let spread = max(grey.x, grey.y, grey.z) - min(grey.x, grey.y, grey.z)
XCTAssertLessThan(spread, 0.08, "\(grey)")
// at about the source's luminance (it desaturates, it doesn't dim).
let luma = 0.2126 * violetPool.x + 0.7152 * violetPool.y + 0.0722 * violetPool.z
XCTAssertEqual(grey.y, luma, accuracy: 0.05)
}
/// Every palette stays in gamut on every colour the field is built from an out-of-range
/// channel would clamp differently on each platform's rasteriser.
func testEveryPaletteStaysInGamut() {
let field: [SIMD3<Double>] = [
SIMD3(0.075, 0.060, 0.160), SIMD3(0.34, 0.27, 0.72), SIMD3(0.30, 0.26, 0.74),
SIMD3(0.42, 0.20, 0.54), SIMD3(0.49, 0.39, 0.95), SIMD3(0.28, 0.31, 0.84),
SIMD3(0.16, 0.26, 0.64), SIMD3(0.45, 0.23, 0.60), SIMD3(0.53, 0.31, 0.75),
SIMD3(0.35, 0.35, 0.91), SIMD3(0.19, 0.28, 0.70), SIMD3(0.22, 0.18, 0.54),
SIMD3(0.24, 0.20, 0.58),
]
for palette in GamepadPalette.all {
for c in field {
let t = palette.tint(c)
for v in [t.x, t.y, t.z] {
XCTAssertTrue((0...1).contains(v), "\(palette.id) \(c)\(t)")
}
}
}
}
}
@@ -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;
}
-1
View File
@@ -773,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();
+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 -13
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,
+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 % 5 == 0 {
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 {
+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 {
+10 -169
View File
@@ -203,118 +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 console's living backdrop. A palette is NOT a second
/// hand-tuned 16-colour grid: it is a hue rotation + saturation scale applied to
/// [`MESH_COLORS`], so every palette inherits the field's structure (dark corners, bright
/// interior pools, warm-left/cool-right) and the brand default is exactly the shipped look —
/// `violet` is the identity transform. The Apple and Android clients carry the same table and
/// the same [`tint`] math, so a palette reads as the same colour family on every client.
pub struct Palette {
/// The stored `ui_palette` value (see `trust::Settings::ui_palette`).
pub id: &'static str,
/// What the settings row shows.
pub name: &'static str,
/// Hue rotation about the grey axis, degrees — positive runs red → green → blue.
pub hue_deg: f64,
/// Saturation scale about luminance; `1.0` keeps the source saturation.
pub sat: f64,
}
/// The six shipped palettes, in cycling order (the brand violet first, then cool → warm,
/// then the neutral). Adding one here adds it to every console settings screen; the Apple
/// and Android tables must gain the same entry to keep the `ui_palette` key portable.
pub const PALETTES: [Palette; 6] = [
Palette {
id: "violet",
name: "Violet",
hue_deg: 0.0,
sat: 1.0,
},
Palette {
id: "tide",
name: "Tide",
hue_deg: -70.0,
sat: 1.0,
},
Palette {
id: "forest",
name: "Forest",
hue_deg: -130.0,
sat: 0.9,
},
Palette {
id: "ember",
name: "Ember",
hue_deg: 105.0,
sat: 1.0,
},
Palette {
id: "rose",
name: "Rose",
hue_deg: 60.0,
sat: 0.95,
},
Palette {
id: "graphite",
name: "Graphite",
hue_deg: 0.0,
sat: 0.12,
},
];
/// The palette stored under `id`, falling back to the brand default — an unknown name is a
/// palette a newer client shipped, not a reason to draw nothing.
pub fn palette(id: &str) -> &'static Palette {
PALETTES.iter().find(|p| p.id == id).unwrap_or(&PALETTES[0])
}
/// Rotate `(r, g, b)` about the grey axis by `deg` (Rodrigues — the same rotation the shader
/// already uses for the ±8° warm/cool sway) and scale its saturation about luminance. Clamped,
/// because a large rotation can push a channel out of gamut. Ported verbatim to Swift and
/// Kotlin: keep the three copies in step or the palettes drift apart between clients.
pub fn tint(c: (f64, f64, f64), deg: f64, sat: f64) -> (f64, f64, f64) {
let (r, g, b) = c;
let a = deg.to_radians();
let (sn, cs) = a.sin_cos();
let inv_sqrt3 = 1.0 / 3.0f64.sqrt();
let grey = (r + g + b) / 3.0 * (1.0 - cs);
// The `sn` term is `cross(k, c)` with k = (1,1,1)/√3 — the SAME orientation the shader's
// own `hue()` uses, so a palette rotation and the ±8° sway agree on which way is warmer.
let rot = (
r * cs + (b - g) * inv_sqrt3 * sn + grey,
g * cs + (r - b) * inv_sqrt3 * sn + grey,
b * cs + (g - r) * inv_sqrt3 * sn + grey,
);
let luma = 0.2126 * rot.0 + 0.7152 * rot.1 + 0.0722 * rot.2;
let mix = |v: f64| (luma + (v - luma) * sat).clamp(0.0, 1.0);
(mix(rot.0), mix(rot.1), mix(rot.2))
}
impl Palette {
/// [`MESH_COLORS`] under this palette's transform.
pub fn mesh_colors(&self) -> [(f64, f64, f64); 16] {
core::array::from_fn(|i| tint(MESH_COLORS[i], self.hue_deg, self.sat))
}
}
/// The mesh gradient as SkSL, palette + motion baked into the source (resolution, time and
/// the calm mix are uniforms). A smooth bicubic blend of the 16 colours — a separable
/// 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()`:
@@ -325,18 +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\
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\
@@ -355,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\
@@ -369,18 +263,11 @@ 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) * mix(0.42, 0.21, calm);\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\
@@ -595,56 +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 be the IDENTITY transform — the shipped violet backdrop is
/// what every existing install already sees, and a palette table that quietly restyled
/// it would be a regression dressed as a feature.
#[test]
fn violet_is_the_untouched_shipped_field() {
assert_eq!(PALETTES[0].id, "violet");
for (a, b) in palette("violet").mesh_colors().iter().zip(&MESH_COLORS) {
assert!((a.0 - b.0).abs() < 1e-9, "{a:?} vs {b:?}");
assert!((a.1 - b.1).abs() < 1e-9, "{a:?} vs {b:?}");
assert!((a.2 - b.2).abs() < 1e-9, "{a:?} vs {b:?}");
}
// An unknown name is a newer client's palette, not an error.
assert_eq!(palette("chartreuse").id, "violet");
assert_eq!(palette("").id, "violet");
}
/// The transform's two knobs do what they claim: a rotation moves the hue while holding
/// roughly the same luminance, and the saturation scale collapses toward grey. These are
/// the numbers the Swift and Kotlin ports have to reproduce.
#[test]
fn tint_rotates_hue_and_scales_saturation() {
let violet = MESH_COLORS[5]; // the brightest interior pool: blue dominates
assert!(violet.2 > violet.0 && violet.2 > violet.1);
// +105° (Ember) turns the blue-dominant pool red-dominant.
let ember = tint(violet, 105.0, 1.0);
assert!(ember.0 > ember.2, "{ember:?} should be warm");
// 130° (Forest) turns it green-dominant.
let forest = tint(violet, -130.0, 1.0);
assert!(forest.1 > forest.0 && forest.1 > forest.2, "{forest:?}");
// Graphite's saturation scale leaves the three channels nearly equal…
let grey = tint(violet, 0.0, 0.12);
let spread = grey.0.max(grey.1).max(grey.2) - grey.0.min(grey.1).min(grey.2);
assert!(spread < 0.08, "{grey:?} spread {spread}");
// …at about the source's luminance (it desaturates, it doesn't dim).
let luma = 0.2126 * violet.0 + 0.7152 * violet.1 + 0.0722 * violet.2;
assert!((grey.1 - luma).abs() < 0.05, "{grey:?} vs luma {luma}");
// Every palette stays in gamut on every mesh colour.
for p in &PALETTES {
for c in p.mesh_colors() {
for v in [c.0, c.1, c.2] {
assert!((0.0..=1.0).contains(&v), "{} {c:?}", p.id);
}
}
}
}
}
+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,
}
+76 -268
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::{Fonts, DIM, W};
use crate::widgets::{ListMsg, MenuList, RowSpec, TabStrip, TAB_STRIP_H};
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,7 +309,7 @@ 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,
@@ -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"
);
}
}
+9 -79
View File
@@ -11,7 +11,7 @@
use crate::anim::Progress;
use crate::glyphs::GlyphStyle;
use crate::library::{mesh_sksl, palette, LibraryShared};
use crate::library::{mesh_sksl, LibraryShared};
use crate::model::{ConsoleBus, ConsoleCmd, ConsoleShared, HostRow, PairPhase, WakeStatus};
use crate::screens::{Bg, ConnectIntent, Ctx, Nav, Outbox, Screen};
use anyhow::{anyhow, Result};
@@ -81,17 +81,7 @@ pub(crate) struct Shell {
wake_optimistic: bool,
toast: Option<Toast>,
mesh: RuntimeEffect,
/// The `ui_palette` the compiled `mesh` bakes. The settings screen can change the palette
/// mid-frame-loop, so [`Self::sync`] recompiles when this falls out of step — the backdrop
/// re-colours under the cursor as the row is stepped, which is the whole point of putting
/// the picker on a screen the backdrop is behind.
mesh_palette: String,
/// The palette's corner colour × 0.4 — the calm lift, precomputed with `mesh`. Chosen so
/// `col*0.6 + lift` leaves a corner EXACTLY where it was and pulls the bright pools down
/// to it: the form screens lose the launcher's contrast, not its colour.
mesh_lift: [f32; 3],
/// 0 = launcher aurora, 1 = the calm form field — chased, so the backdrop settles into
/// (or out of) calm alongside the screen transition.
/// 0 = aurora, 1 = form — chased, so backdrops crossfade with the transition.
bg_mix: f64,
glyphs: GlyphStyle,
chip: Option<String>,
@@ -109,8 +99,8 @@ impl Shell {
stack: Vec<Screen>,
) -> Result<Shell> {
anyhow::ensure!(!stack.is_empty(), "the console needs a root screen");
let settings = trust::Settings::load();
let (mesh, mesh_lift) = build_mesh(&settings.ui_palette)?;
let mesh = RuntimeEffect::make_for_shader(mesh_sksl(), None)
.map_err(|e| anyhow!("mesh-gradient SkSL: {e}"))?;
let bg_mix = match stack.last().expect("non-empty").background() {
Bg::Aurora => 0.0,
Bg::Form => 1.0,
@@ -122,8 +112,7 @@ impl Shell {
library,
bus,
actions: VecDeque::new(),
mesh_palette: settings.ui_palette.clone(),
settings,
settings: trust::Settings::load(),
hosts: Vec::new(),
hosts_gen: u64::MAX,
device_name: opts.device_name,
@@ -134,7 +123,6 @@ impl Shell {
wake_optimistic: false,
toast: None,
mesh,
mesh_lift,
bg_mix,
glyphs: GlyphStyle::Keyboard,
chip: None,
@@ -200,26 +188,6 @@ impl Shell {
// --- Model sync (hosts, pairing, wake) — before input and before render --------------
fn sync(&mut self) {
// The settings screen writes `ui_palette` straight into `self.settings`; recompiling
// here is what makes the backdrop re-colour live under the row being stepped. A
// rejected compile keeps the palette that IS drawing — the field never goes black
// because someone picked a colour.
if self.settings.ui_palette != self.mesh_palette {
match build_mesh(&self.settings.ui_palette) {
Ok((mesh, lift)) => {
self.mesh = mesh;
self.mesh_lift = lift;
self.mesh_palette = self.settings.ui_palette.clone();
}
Err(e) => {
tracing::warn!(
"console: {} palette rejected: {e}",
self.settings.ui_palette
);
self.mesh_palette = self.settings.ui_palette.clone();
}
}
}
if self.console.hosts_gen() != self.hosts_gen {
(self.hosts, self.hosts_gen) = self.console.hosts_snapshot();
}
@@ -464,25 +432,12 @@ impl Shell {
}
}
/// The living backdrop. `calm` 0 = the launcher's aurora, 1 = the quiet field the form
/// screens sit on; the shell chases it, so there is only ever ONE backdrop pass — the
/// former aurora-over-static-form crossfade is now a single uniform.
fn draw_aurora(&self, canvas: &Canvas, w: f64, h: f64, t: f64, calm: f64) {
// Laid out to match the SkSL block: u_res (float2), u_tc (float2), u_lift (float4).
let uniforms: [f32; 8] = [
w as f32,
h as f32,
t as f32,
calm as f32,
self.mesh_lift[0],
self.mesh_lift[1],
self.mesh_lift[2],
0.0,
];
// SAFETY: `uniforms` is a local `[f32; 8]` — exactly 32 bytes — and `f32` has no padding or
fn draw_aurora(&self, canvas: &Canvas, w: f64, h: f64, t: f64) {
let uniforms: [f32; 3] = [w as f32, h as f32, t as f32];
// SAFETY: `uniforms` is a local `[f32; 3]` — exactly 12 bytes — and `f32` has no padding or
// invalid bit patterns, so reading it as bytes is sound; the slice is copied by
// `Data::new_copy` before `uniforms` goes out of scope.
let bytes = unsafe { std::slice::from_raw_parts(uniforms.as_ptr().cast::<u8>(), 32) };
let bytes = unsafe { std::slice::from_raw_parts(uniforms.as_ptr().cast::<u8>(), 12) };
match self.mesh.make_shader(Data::new_copy(bytes), &[], None) {
Some(shader) => {
let mut paint = Paint::default();
@@ -496,30 +451,5 @@ impl Shell {
}
}
/// Compile the mesh shader for a palette, returning it with its precomputed calm lift.
/// `uniform_size` is checked rather than assumed: the byte buffer [`Shell::draw_aurora`]
/// hands Skia is hand-packed, and a silent layout change would feed the field garbage
/// instead of failing.
fn build_mesh(palette_id: &str) -> Result<(RuntimeEffect, [f32; 3])> {
let p = palette(palette_id);
let colors = p.mesh_colors();
let effect = RuntimeEffect::make_for_shader(mesh_sksl(&colors), None)
.map_err(|e| anyhow!("mesh-gradient SkSL: {e}"))?;
anyhow::ensure!(
effect.uniform_size() == 32,
"mesh uniform block is {} bytes, expected 32 (u_res, u_tc, u_lift)",
effect.uniform_size()
);
let corner = colors[0];
Ok((
effect,
[
(corner.0 * 0.4) as f32,
(corner.1 * 0.4) as f32,
(corner.2 * 0.4) as f32,
],
))
}
#[cfg(test)]
mod tests;
+1 -1
View File
@@ -166,7 +166,7 @@ impl Shell {
canvas.save_layer_alpha_f(None, appear as f32);
// Opaque aurora — the same living backdrop the home wears, so the takeover reads as the
// console taking over rather than a card popping up.
self.draw_aurora(canvas, w, h, t, 0.0);
self.draw_aurora(canvas, w, h, t);
// A soft pool of shade under the centre seats the white text against a bright aurora.
let mut vignette = Paint::default();
vignette.set_shader(gradient_shader::radial(
+12 -5
View File
@@ -8,7 +8,7 @@ use crate::screens::{Bg, Ctx, Screen};
use crate::theme::{white, Fonts, PanelStroke, W, WHITE};
use pf_client_core::gamepad::PadInfo;
use pf_client_core::trust;
use skia_safe::{Canvas, Rect};
use skia_safe::{Canvas, Color4f, Rect};
use std::time::Instant;
use super::{Motion, Shell, BOTTOM_BAND, TOP_BAND};
@@ -67,9 +67,7 @@ impl Shell {
}
};
// The backdrop settles into (or out of) calm with the screen transition. It is the
// SAME living field either way — a form screen quiets it, it doesn't replace it —
// so this is one shader pass with a chased uniform, not two stacked backdrops.
// Backdrop crossfade follows the top screen.
let bg_target = match self.stack.last().expect("non-empty").background() {
Bg::Aurora => 0.0,
Bg::Form => 1.0,
@@ -78,7 +76,16 @@ impl Shell {
if (self.bg_mix - bg_target).abs() < 0.005 {
self.bg_mix = bg_target;
}
self.draw_aurora(canvas, w, h, t, self.bg_mix);
if self.bg_mix < 1.0 {
self.draw_aurora(canvas, w, h, t);
} else {
canvas.clear(Color4f::new(0.0, 0.0, 0.0, 1.0));
}
if self.bg_mix > 0.0 {
canvas.save_layer_alpha_f(None, self.bg_mix as f32);
crate::theme::draw_form_background(canvas, w, h);
canvas.restore();
}
// The screens, through the transition choreography.
let content = Rect::from_ltrb(
-61
View File
@@ -167,47 +167,6 @@ fn wake_gates_input_in_the_same_press() {
assert!(s.handle_menu(MenuEvent::Move(MenuDir::Left)).is_some());
}
/// Every settings tab actually RASTERS. The eyeball dump below is `#[ignore]`d, so without
/// this nothing in the normal gate ever ran the tab strip's layout arithmetic or a settings
/// screen's rows — a bad index there would only surface on a Deck. CPU raster: the SkSL
/// backdrop, the layers and the text all run without a GPU.
#[test]
fn every_settings_tab_rasters() {
let fonts = crate::theme::build_fonts().unwrap();
let (w, h) = (1280u32, 800u32);
let pads: Vec<PadInfo> = Vec::new();
let mut surface = skia_safe::surfaces::raster_n32_premul((w as i32, h as i32)).unwrap();
let (mut s, _console, _library) = shell(vec![Screen::Home(HomeScreen::new())]);
s.handle_menu(MenuEvent::Tertiary); // X → Settings
let mut frame = |s: &mut Shell| {
s.render(
surface.canvas(),
w,
h,
&fonts,
Some("Xbox Wireless Controller"),
Some(GamepadPref::Xbox360),
&pads,
);
};
// One lap of the strip — R1 wraps back to where it started. Every tab's rows fit on an
// 800-tall window at once, so ONE frame per tab draws all of them; the cursor is walked to
// the end first (input only, no render) so the focused and unfocused row paths both run.
// Deliberately frugal: a full-screen SkSL field on the CPU costs the better part of a second
// per frame in a debug build, and this test's job is to catch a panic, not to look pretty.
for _ in 0..crate::screens::settings::TAB_COUNT {
for _ in 0..12 {
s.handle_menu(MenuEvent::Move(MenuDir::Down));
}
frame(&mut s);
s.handle_menu(MenuEvent::JumpForward);
}
// A narrow window is the case the strip has to shrink for (the pills are laid out from
// measured text, so a too-small width must clamp rather than lay out off-screen).
s.render(surface.canvas(), 640, 400, &fonts, None, None, &pads);
}
/// Render every console scene to PNGs for the eyeball pass (ignored; run with
/// `PF_CONSOLE_DUMP=<dir> cargo test -p pf-console-ui --release -- --ignored dump`).
/// CPU raster — the SkSL aurora, layers and text all run without a GPU.
@@ -249,26 +208,6 @@ fn dump_console_screens() {
dump(&mut s, 3, 25, "02-transition", true);
dump(&mut s, 40, 8, "03-settings", true);
// The Interface tab (5 shoulder presses along) leads with the Background row, so this frame
// shows both the strip mid-list and the palette picker…
for _ in 0..5 {
s.handle_menu(MenuEvent::JumpForward);
}
dump(&mut s, 40, 8, "03b-settings-interface", true);
// …and cycling it three times lands on Ember, which is the whole point: the CALM backdrop
// behind these rows recolours live.
for _ in 0..3 {
s.handle_menu(MenuEvent::Confirm);
}
dump(&mut s, 40, 8, "03c-settings-ember", true);
// Back to the brand default and the first tab so the later scenes look like they always did.
for _ in 0..3 {
s.handle_menu(MenuEvent::Confirm);
}
for _ in 0..5 {
s.handle_menu(MenuEvent::JumpBack);
}
// Add Host with the keyboard tray up (keyboard glyph style: no pad).
s.handle_menu(MenuEvent::Back);
dump(&mut s, 40, 8, "_back", true);
+53 -6
View File
@@ -112,12 +112,59 @@ pub(crate) fn drop_shadow(canvas: &Canvas, rect: Rect, corner: f32, k: f32, alph
}
// --- The form backdrop (settings / add-host / pair) --------------------------------------
//
// There isn't one any more. The form screens used to sit on a STATIC deep-indigo field
// drawn here, crossfaded over the launcher's aurora; they now wear the same living mesh at
// `calm = 1` (see `library::mesh_sksl` and `Shell::draw_aurora`), which keeps the glass rows
// on real colour, keeps the console's one backdrop palette-themed everywhere, and means no
// screen in the gamepad UI is ever backed by a still image.
/// The calm backdrop for the form screens — NOT the launcher's aurora (this stays still
/// and quiet), and deliberately not near-black: a deep indigo base plus two soft static
/// glows give the glass rows real color to sit on. A light top/bottom scrim grounds the
/// pinned title and hint bar (the Swift build blurs a tray instead; same job).
pub(crate) fn draw_form_background(canvas: &Canvas, w: f64, h: f64) {
let (wf, hf) = (w as f32, h as f32);
canvas.draw_rect(
Rect::from_wh(wf, hf),
&Paint::new(Color4f::new(0.075, 0.062, 0.150, 1.0), None),
);
// Violet lift top-leading, cooler indigo bottom-trailing — elliptical (window
// aspect) via a unit-radius radial gradient under a scale.
for (cx, cy, color, alpha) in [
(0.26, 0.14, Color4f::new(0.40, 0.31, 0.68, 1.0), 0.9f32),
(0.82, 0.90, Color4f::new(0.20, 0.24, 0.58, 1.0), 0.75),
] {
let mut paint = Paint::default();
let c = Color4f::new(color.r, color.g, color.b, alpha);
paint.set_shader(gradient_shader::radial(
Point::new(0.0, 0.0),
0.78,
gradient_shader::GradientShaderColors::Colors(&[
c.to_color(),
Color4f::new(color.r, color.g, color.b, 0.0).to_color(),
]),
None,
TileMode::Clamp,
None,
None,
));
canvas.save();
canvas.translate((wf * cx, hf * cy));
canvas.scale((wf, hf));
canvas.draw_rect(Rect::from_ltrb(-1.0, -1.0, 1.0, 1.0), &paint);
canvas.restore();
}
let mut scrim = Paint::default();
scrim.set_shader(gradient_shader::linear(
(Point::new(0.0, 0.0), Point::new(0.0, hf)),
gradient_shader::GradientShaderColors::Colors(&[
Color4f::new(0.0, 0.0, 0.0, 0.30).to_color(),
Color4f::new(0.0, 0.0, 0.0, 0.0).to_color(),
Color4f::new(0.0, 0.0, 0.0, 0.0).to_color(),
Color4f::new(0.0, 0.0, 0.0, 0.32).to_color(),
]),
Some(&[0.0, 0.22, 0.74, 1.0][..]),
TileMode::Clamp,
None,
None,
));
canvas.draw_rect(Rect::from_wh(wf, hf), &scrim);
}
/// The loading/connecting spinner: a rotating 270° arc driven by the shell clock.
pub(crate) fn spinner(canvas: &Canvas, cx: f64, cy: f64, r: f64, t: f64) {
+2 -119
View File
@@ -84,9 +84,6 @@ pub(crate) struct MenuList {
bump: Spring,
scroll: f64,
focus: Vec<f64>,
/// Next render, seat the scroll and the focus ease instantly instead of chasing — see
/// [`MenuList::jump_to`].
snap: bool,
}
impl MenuList {
@@ -96,18 +93,9 @@ impl MenuList {
bump: Spring::rest(0.0),
scroll: 0.0,
focus: Vec::new(),
snap: true,
}
}
/// Move the cursor WITHOUT the scroll gliding there. For a tab switch, where the whole
/// row set is replaced: chasing would sweep the viewport through rows that no longer
/// exist, which reads as a glitch rather than as motion.
pub(crate) fn jump_to(&mut self, cursor: usize) {
self.cursor = cursor;
self.snap = true;
}
/// Route a menu event. Up/down move focus (Boundary = recoil), left/right become
/// [`ListMsg::Adjust`], A becomes [`ListMsg::Activate`]. B is the SCREEN's.
pub(crate) fn menu(&mut self, ev: MenuEvent, len: usize) -> (ListMsg, Option<MenuPulse>) {
@@ -148,19 +136,10 @@ impl MenuList {
dt: f64,
active: bool,
) {
if self.snap {
// A replaced row set has no shared history with the old one — start every row's
// focus ease from scratch so the new cursor is simply THERE.
self.focus.clear();
}
self.focus.resize(rows.len(), 0.0);
for (i, f) in self.focus.iter_mut().enumerate() {
let target = if active && i == self.cursor { 1.0 } else { 0.0 };
*f = if self.snap {
target
} else {
approach(*f, target, dt, 0.06)
};
*f = approach(*f, target, dt, 0.06);
}
self.bump.step(0.0, BUMP_K, BUMP_C, dt);
self.bump.settle(0.0, 0.3, 4.0);
@@ -181,11 +160,7 @@ impl MenuList {
// The scroll chases the focused row into the middle band, clamped to content.
let focused_center = tops.get(self.cursor).map_or(0.0, |t| (t + ROW_H / 2.0) * k);
let target = (focused_center - view_h / 2.0).clamp(0.0, (content_h - view_h).max(0.0));
self.scroll = if std::mem::take(&mut self.snap) {
target
} else {
approach(self.scroll, target, dt, 0.08)
};
self.scroll = approach(self.scroll, target, dt, 0.08);
let row_w = (ROW_MAX_W * k).min(f64::from(rect.width()) - 48.0 * k);
let x0 = f64::from(rect.left) + (f64::from(rect.width()) - row_w) / 2.0;
@@ -297,98 +272,6 @@ impl MenuList {
}
}
// --- Tab strip ---------------------------------------------------------------------------
/// The strip's design height, including the air under it before the first row.
pub(crate) const TAB_STRIP_H: f64 = 46.0;
/// The horizontal section switcher above a menu list. Purely presentational — the SCREEN
/// owns which tab is selected and what the shoulders do; this draws the pills and slides
/// one highlight between them, so switching sections reads as travel rather than a swap.
pub(crate) struct TabStrip {
/// Chased highlight geometry `(x, width)` in device px. `None` until the first render,
/// so a freshly opened screen doesn't animate its highlight in from x = 0.
indicator: Option<(f64, f64)>,
}
impl TabStrip {
pub(crate) fn new() -> TabStrip {
TabStrip { indicator: None }
}
/// Draw the pills centered in `rect`'s top band. Returns nothing — the caller already
/// knows the band is [`TAB_STRIP_H`] tall.
#[allow(clippy::too_many_arguments)] // the crate's render signature, same as MenuList's
pub(crate) fn render(
&mut self,
canvas: &Canvas,
rect: Rect,
labels: &[&str],
selected: usize,
fonts: &Fonts,
k: f64,
dt: f64,
) {
if labels.is_empty() {
return;
}
let size = 13.0 * k;
let pad_x = 13.0 * k;
let gap = 7.0 * k;
let pill_h = 30.0 * k;
let widths: Vec<f64> = labels
.iter()
.map(|l| f64::from(fonts.measure(l, W::SemiBold, size)) + 2.0 * pad_x)
.collect();
let total: f64 = widths.iter().sum::<f64>() + gap * (labels.len() - 1) as f64;
let mut x = f64::from(rect.left) + (f64::from(rect.width()) - total) / 2.0;
let top = f64::from(rect.top) + 2.0 * k;
// Where the highlight wants to be, then the eased position it actually draws at.
let sel = selected.min(labels.len() - 1);
let target = (
x + widths[..sel].iter().sum::<f64>() + gap * sel as f64,
widths[sel],
);
let (ix, iw) = match self.indicator {
None => target,
Some((cx, cw)) => (
approach(cx, target.0, dt, 0.07),
approach(cw, target.1, dt, 0.07),
),
};
self.indicator = Some((ix, iw));
crate::theme::panel(
canvas,
Rect::from_xywh(ix as f32, top as f32, iw as f32, pill_h as f32),
(pill_h / 2.0 / k) as f32,
Some(brand(0.85)),
PanelStroke::Plain(0.22),
k as f32,
);
let baseline = top + pill_h / 2.0 + size * 0.36;
for (i, label) in labels.iter().enumerate() {
// Fade each label toward white by how much the highlight actually covers it, so
// the two labels a sliding highlight passes between light up together.
let pill_x = x;
let overlap = (pill_x + widths[i]).min(ix + iw) - pill_x.max(ix);
let covered = (overlap / widths[i]).clamp(0.0, 1.0) as f32;
let tw = f64::from(fonts.measure(label, W::SemiBold, size));
fonts.draw(
canvas,
label,
pill_x + (widths[i] - tw) / 2.0,
baseline,
W::SemiBold,
size,
white(0.5 + 0.5 * covered),
);
x += widths[i] + gap;
}
}
}
/// Middle-of-nowhere helper: drop chars from the FRONT until the tail fits.
fn truncate_head(fonts: &Fonts, text: &str, w: W, size: f64, max_w: f64) -> String {
if f64::from(fonts.measure(text, w, size)) <= max_w {
+3 -14
View File
@@ -1022,21 +1022,10 @@ impl EiState {
// Track held state on the wire codes so `release_all` can undo it at
// session end (vanished clients must not leave anything latched).
match ev.kind {
// Track the code we ACTUALLY INJECTED, not the raw wire code.
//
// Injection truncates (`vk_to_evdev(ev.code as u8)`), so 0x41, 0x141, 0x241 … all
// press the same key — but this list stored the full 32 bits, so a KeyUp for 0x41
// never matched the entry a KeyDown for 0x141 left behind. A client sending
// distinct high bytes therefore appended entries that could never be removed, to a
// `Vec` scanned linearly on every keystroke, for the lifetime of the injector
// thread — which outlives the session (2026-08-05 review L-4). Tracking the
// truncated code makes the list correct AND bounds it at 256 entries by
// construction. `release_all` re-injects through the same truncation, so the
// release path is unchanged.
InputKind::KeyDown if !self.held_keys.contains(&(ev.code & 0xff)) => {
self.held_keys.push(ev.code & 0xff);
InputKind::KeyDown if !self.held_keys.contains(&ev.code) => {
self.held_keys.push(ev.code);
}
InputKind::KeyUp => self.held_keys.retain(|&c| c != ev.code & 0xff),
InputKind::KeyUp => self.held_keys.retain(|&c| c != ev.code),
InputKind::MouseButtonDown if !self.held_buttons.contains(&ev.code) => {
self.held_buttons.push(ev.code);
}
+17 -92
View File
@@ -70,64 +70,11 @@ pub fn create_private_dir(dir: &std::path::Path) -> std::io::Result<()> {
{
let r = std::fs::create_dir_all(dir);
#[cfg(windows)]
restrict_dir_to_system_admins(dir, first_hardening_of(dir));
restrict_dir_to_system_admins(dir);
r
}
}
/// Whether this is the first hardening pass of `dir` in this process — the pass that also does the
/// expensive recursive re-own.
///
/// A planted config dir is planted once, before the host ever starts, so one deep pass at startup
/// closes it; repeating it on every `create_private_dir` call (the library CRUD calls it per write)
/// would re-walk the whole config tree — recordings, art cache — for nothing.
#[cfg(windows)]
fn first_hardening_of(dir: &std::path::Path) -> bool {
use std::collections::HashSet;
use std::sync::{Mutex, OnceLock};
static SEEN: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
SEEN.get_or_init(|| Mutex::new(HashSet::new()))
.lock()
.map(|mut s| s.insert(dir.to_path_buf()))
.unwrap_or(false)
}
/// Re-apply the secret-file DACL to a file that **already exists** — including re-owning it to
/// Administrators.
///
/// [`write_secret_file`] hardens what it writes, but a file that was planted before the host first
/// ran was never written by us: it is owned by whoever created it, and an owner always retains
/// `WRITE_DAC`, so re-ACLing without re-owning leaves them able to put their access straight back.
/// Used on startup for `host.env`, whose contents become the SYSTEM service's environment and
/// command line (2026-08-05 review H-4). Best-effort and never fatal.
#[cfg(windows)]
pub fn restrict_existing_secret_file(path: &std::path::Path) {
if !path.exists() {
return;
}
let icacls = icacls_path();
let _ = std::process::Command::new(&icacls)
.arg(path.as_os_str())
.args(["/setowner", "*S-1-5-32-544"]) // BUILTIN\Administrators
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
restrict_to_system_admins(path);
}
/// No-op off Windows: POSIX modes are set at creation by [`write_secret_file`] and a config dir a
/// non-root user pre-created is not a privilege boundary the way `%ProgramData%` is.
#[cfg(not(windows))]
pub fn restrict_existing_secret_file(_path: &std::path::Path) {}
/// `icacls` by absolute path — a privileged service must never resolve it through `PATH`.
#[cfg(windows)]
fn icacls_path() -> String {
std::env::var("SystemRoot")
.map(|r| format!("{r}\\System32\\icacls.exe"))
.unwrap_or_else(|_| "icacls".to_string())
}
/// Best-effort Windows DACL lockdown of the config *directory* (the companion to
/// [`restrict_to_system_admins`] for files). The default `%ProgramData%` ACL lets `BUILTIN\Users`
/// create subfolders/files (and become `CREATOR OWNER`), so a non-admin could pre-create the
@@ -139,23 +86,17 @@ fn icacls_path() -> String {
/// are additionally locked to SYSTEM/Admins by [`write_secret_file`]. Hard-coded SIDs
/// (locale-independent) via the absolute `%SystemRoot%` path; never fatal.
#[cfg(windows)]
fn restrict_dir_to_system_admins(dir: &std::path::Path, deep: bool) {
let icacls = icacls_path();
// Reset ownership to Administrators first, so a dir a non-admin may have pre-created can't keep
// OWNER control (an owner always retains WRITE_DAC and can put its access straight back).
//
// `deep` (once per directory per process — see `first_hardening_of`) also re-owns the CONTENTS.
// Re-owning only the directory left every file the attacker had already created still owned by
// them, and therefore still theirs to rewrite, which is half of why the 2026-08-05 review's H-4
// was exploitable end to end. A planted tree is planted once, before the host first runs, so one
// deep pass at startup closes it without re-walking recordings and art cache on every write.
let mut own = std::process::Command::new(&icacls);
own.arg(dir.as_os_str())
.args(["/setowner", "*S-1-5-32-544"]); // BUILTIN\Administrators
if deep {
own.args(["/T", "/C", "/Q"]); // recurse, continue on error, quiet
}
let _ = own
fn restrict_dir_to_system_admins(dir: &std::path::Path) {
let icacls = std::env::var("SystemRoot")
.map(|r| format!("{r}\\System32\\icacls.exe"))
.unwrap_or_else(|_| "icacls".to_string());
// Reset ownership of the directory object to Administrators first, so a dir a non-admin may have
// pre-created can't keep OWNER control (an owner can always rewrite the DACL). No `/T` — re-owning
// the dir itself is what defeats the pre-creation; recursing a large captures tree each call is
// needless churn (secret files are individually owner-locked by `write_secret_file`).
let _ = std::process::Command::new(&icacls)
.arg(dir.as_os_str())
.args(["/setowner", "*S-1-5-32-544"]) // BUILTIN\Administrators
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
@@ -167,13 +108,8 @@ fn restrict_dir_to_system_admins(dir: &std::path::Path, deep: bool) {
"*S-1-5-18:(OI)(CI)(F)", // NT AUTHORITY\SYSTEM
"/grant:r",
"*S-1-5-32-544:(OI)(CI)(F)", // BUILTIN\Administrators
// NO inheritable OWNER RIGHTS (`*S-1-3-4`) here, deliberately. It used to be granted
// `(OI)(CI)(F)`, which handed full control of every child object to whoever owned it —
// so a file a local user created before the hardening ran stayed writable by them even
// after the directory was re-owned (2026-08-05 review H-4, second half). SYSTEM and
// Administrators cover every account that legitimately writes here; a non-elevated
// manual run gets read-only config, which is the intended boundary rather than a
// regression — this directory drives command execution as SYSTEM.
"/grant:r",
"*S-1-3-4:(OI)(CI)(F)", // OWNER RIGHTS
"/grant:r",
"*S-1-5-32-545:(OI)(CI)(RX)", // BUILTIN\Users — read-only (no create/write → no plant)
])
@@ -194,19 +130,6 @@ fn restrict_dir_to_system_admins(dir: &std::path::Path, deep: bool) {
/// Windows (the default `%ProgramData%` ACL is Users-readable). Mirrors the mgmt-token hardening; used
/// for the host private key and the persisted trust stores so a local unprivileged user can neither
/// read the key (impersonation) nor tamper with the paired allow-list (unauthorized pairing).
///
/// **Windows ordering caveat** (2026-08-05 review L-17): this is create-then-`icacls`, not
/// create-with-DACL — `std::fs::OpenOptions` cannot pass a `SECURITY_ATTRIBUTES`, and this crate is
/// `#![forbid(unsafe_code)]` so it cannot call `CreateFileW` itself. The file therefore exists
/// briefly under its INHERITED ACL, and a failed `icacls` is a warning rather than an error.
///
/// What makes that acceptable is the DIRECTORY, and only the directory: every caller writes into
/// the config dir, which [`create_private_dir`] now hardens unconditionally and BEFORE the first
/// read of anything in it (review H-4/M-1), granting `BUILTIN\Users` read-only and no create. The
/// inherited ACL a secret is born with is therefore already SYSTEM/Administrators-only, and the
/// `icacls` below is defence in depth rather than the thing standing between a local user and the
/// host key. Keep that ordering — if the directory hardening is ever moved back after a read, this
/// window becomes real again.
pub fn write_secret_file(path: &std::path::Path, contents: &[u8]) -> std::io::Result<()> {
use std::io::Write;
let mut opts = std::fs::OpenOptions::new();
@@ -237,7 +160,9 @@ pub fn write_secret_file(path: &std::path::Path, contents: &[u8]) -> std::io::Re
/// `PATH`). Never fatal — on failure the file is simply left at the inherited ACL (today's behaviour).
#[cfg(windows)]
fn restrict_to_system_admins(path: &std::path::Path) {
let icacls = icacls_path();
let icacls = std::env::var("SystemRoot")
.map(|r| format!("{r}\\System32\\icacls.exe"))
.unwrap_or_else(|_| "icacls".to_string());
let status = std::process::Command::new(icacls)
.arg(path.as_os_str())
.args([
+7 -16
View File
@@ -1049,24 +1049,15 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
.as_ref()
.is_some_and(|cap| cap.captured() && cap.desktop());
chan.pump(c, &mouse, desktop_active, fit_scale);
// §8 mid-stream render flip: tell the host who renders the pointer whenever the
// local model changes. The host may composite one ONLY while we hold a grabbed,
// hidden pointer — the capture model, engaged — because that is the one state
// with no local cursor on screen. Note this is deliberately NOT `desktop_active`:
// a RELEASED pointer leaves the ordinary window cursor visible over the video,
// and a host-composited pointer then sits UNDER it as a second cursor that never
// moves (released forwards no motion), which reads on glass as a frozen
// duplicate. Released therefore counts as "we draw it" — the host stops
// compositing and keeps forwarding shape/state, so re-engaging is seamless.
// §8 mid-stream render flip: tell the host who renders the pointer whenever
// the local model changes. Desktop-active = we draw it (host excludes +
// forwards); anything else — the capture model OR a released pointer — the
// host composites it into the video (full fidelity, the pre-channel look).
// One edge-detected reconciler covers the chord, the M3 auto-flip, and
// engage/release alike.
let client_draws = match st.capture.as_ref() {
Some(cap) => !cap.captured() || cap.desktop(),
None => true,
};
if chan.negotiated() && st.sent_client_draws != Some(client_draws) {
st.sent_client_draws = Some(client_draws);
let _ = c.set_cursor_render(client_draws);
if chan.negotiated() && st.sent_client_draws != Some(desktop_active) {
st.sent_client_draws = Some(desktop_active);
let _ = c.set_cursor_render(desktop_active);
}
}
// M3 — host-driven mode flip: `relative_hint` set = a host app grabbed/hid the
-5
View File
@@ -66,11 +66,6 @@ pf-driver-proto = { path = "../pf-driver-proto" }
bytemuck = { version = "1.19", features = ["derive"] }
windows = { version = "0.62", features = [
"Win32_Foundation",
# The single-instance mutex is created with an explicit SDDL DACL and its owner is checked, so
# a lower-privileged process (the LocalService plugin runner) can neither open it nor squat the
# name unnoticed — see manager/instance.rs (security-review 2026-08-05 L-16).
"Win32_Security",
"Win32_Security_Authorization",
"Win32_Devices_DeviceAndDriverInstallation",
"Win32_Devices_Display",
"Win32_Graphics_Gdi",
@@ -2465,18 +2465,11 @@ pub fn ei_socket_file() -> std::path::PathBuf {
crate::with_env_lock(pf_paths::gamescope_ei_socket_file)
}
/// Does this resolved launch command start the Steam **client**? Such a launch needs Steam's single
/// instance free before a dedicated spawn (B1), and wants gamescope's `--steam` integration on.
/// Pure + unit-tested.
///
/// The test is the first token, NOT the presence of a `steam://` URI. A `steam_ui` launcher entry
/// (design D4) resolves to a bare `steam -gamepadui` / `steam` with no URI at all, and it is *more*
/// exposed to the single-instance problem than a game launch is, not less: on a box that autologged
/// into game mode, the nested second Steam would see the first and exit, taking the spawn down with
/// it. A URI-gated check would silently skip both the instance free and `--steam` for exactly the
/// launch that most needs them.
/// Does this resolved launch command start Steam (`steam … steam://…`)? Such a launch needs Steam's
/// single instance free before a dedicated spawn (B1). Pure + unit-tested.
fn is_steam_launch(cmd: &str) -> bool {
cmd.split_whitespace().next() == Some("steam")
let mut it = cmd.split_whitespace();
it.next() == Some("steam") && cmd.contains("steam://")
}
/// Shape a resolved launch command for a bare-spawn gamescope session. A Steam URI launch
@@ -2872,13 +2865,7 @@ mod tests {
assert!(is_steam_launch("steam -silent steam://rungameid/570"));
assert!(!is_steam_launch("vkcube"));
assert!(!is_steam_launch("lutris lutris:rungameid/42"));
// A `steam_ui` LAUNCHER entry (design D4) carries no URI, and must still count: it needs the
// single instance freed (B1) and gamescope's `--steam` mode on. Gating on `steam://` would
// have skipped both for the one launch that is Big Picture itself.
assert!(is_steam_launch("steam -gamepadui"));
assert!(is_steam_launch("steam"));
// A command that merely mentions steam elsewhere is not a Steam client launch.
assert!(!is_steam_launch("mygame --steam-overlay"));
assert!(!is_steam_launch("steam -bigpicture")); // no URI = not a game launch
}
#[test]
@@ -2904,13 +2891,6 @@ mod tests {
shape_dedicated_command("steam -bigpicture"),
"steam -bigpicture"
);
// The `steam_ui` launcher entries (design D4) pass through untouched — the shaping only ever
// fires on a `steam://` game launch, so there is no way to end up with `-gamepadui` twice.
assert_eq!(
shape_dedicated_command("steam -gamepadui"),
"steam -gamepadui"
);
assert_eq!(shape_dedicated_command("steam"), "steam");
}
#[test]
@@ -3,7 +3,6 @@
//! `IOCTL_CLEAR_ALL` and razing the live host's monitors mid-stream.
use super::*;
use windows::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES};
/// The held single-instance mutex (`None` until claimed). Process-global — not per-manager — so the
/// serve path can claim it EAGERLY at startup, before any session opens the backend: the claim is
@@ -41,40 +40,16 @@ fn acquire_single_instance() -> Result<OwnedHandle> {
machine refusing to touch the driver (a second manager's startup CLEAR_ALL would raze \
the live host's monitors mid-stream). Stop the other instance (e.g. `punktfunk-host \
service stop`) first.";
// A name in `Global\` is creatable by ANY principal holding SeCreateGlobalPrivilege — which
// includes the LocalService account the plugin runner is forced to (plugins.rs). With `None`
// security attributes this object took the DACL from the creating token's default, and a
// squatter who got there first (creating the name with a DACL that denies SYSTEM) permanently
// and silently disabled every virtual-display session: the host lands in the ACCESS_DENIED arm
// below and reports a perfectly reasonable "another instance is managing the driver", which
// sends the operator hunting a process that does not exist (2026-08-05 review L-16).
//
// Two changes: create with an EXPLICIT DACL so lesser principals cannot open ours, and check
// the OWNER of a name that already exists so a squat is reported as a squat.
let sd = security_descriptor()?;
let sa = SECURITY_ATTRIBUTES {
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
lpSecurityDescriptor: sd.0,
bInheritHandle: false.into(),
};
// SAFETY: plain FFI create of a named mutex; `sa` (and the descriptor it points at) outlives
// the call, the returned handle (checked) is solely owned by the `OwnedHandle`, and
// `GetLastError` is read immediately after the create — the documented ERROR_ALREADY_EXISTS
// protocol for pre-existing named objects.
// SAFETY: plain FFI create of a named mutex; the returned handle (checked) is solely owned by
// the `OwnedHandle`, and `GetLastError` is read immediately after the create — the documented
// ERROR_ALREADY_EXISTS protocol for pre-existing named objects.
unsafe {
let h = match CreateMutexW(Some(&sa), false, w!("Global\\punktfunk-vdisplay-manager")) {
let h = match CreateMutexW(None, false, w!("Global\\punktfunk-vdisplay-manager")) {
Ok(h) => h,
// The name exists but its creator's DACL denies this token the implicit OPEN (the SCM
// service creates it as SYSTEM; a second elevated-admin host lands here instead of in
// the ALREADY_EXISTS branch — validated on-glass). Legitimately that means an instance
// is live; it is ALSO exactly what a squat looks like, so say both.
Err(e) if e.code().0 == 0x8007_0005u32 as i32 => anyhow::bail!(
"{IN_USE}\n\nIf no other punktfunk-host is running, the name \
`Global\\punktfunk-vdisplay-manager` has been SQUATTED by another process any \
account with SeCreateGlobalPrivilege can create it first and deny us access, \
which disables virtual-display streaming until that process exits. Find the \
holder with Sysinternals `handle.exe -a punktfunk-vdisplay-manager`."
),
// the ALREADY_EXISTS branch — validated on-glass). Same meaning: an instance is live.
Err(e) if e.code().0 == 0x8007_0005u32 as i32 => anyhow::bail!("{IN_USE}"),
Err(e) => {
return Err(e).context("CreateMutexW(punktfunk-vdisplay single-instance guard)");
}
@@ -82,114 +57,8 @@ fn acquire_single_instance() -> Result<OwnedHandle> {
let already = GetLastError() == ERROR_ALREADY_EXISTS;
let owned = OwnedHandle::from_raw_handle(h.0 as _);
if already {
// We opened an existing object — so its DACL let us in, but that says nothing about
// who created it. If the owner is not SYSTEM/Administrators it is not one of ours.
if let Some(owner) = object_owner_sid(h) {
if !is_privileged_sid(&owner) {
anyhow::bail!(
"the pf-vdisplay single-instance name is held by a NON-ADMINISTRATIVE \
process (owner SID {owner}) this is not another punktfunk-host, it is a \
squat on `Global\\punktfunk-vdisplay-manager`, and it blocks all \
virtual-display streaming while it is held."
);
}
}
anyhow::bail!("{IN_USE}");
}
Ok(owned)
}
}
/// `D:P(A;;GA;;;SY)(A;;GA;;;BA)` — a protected DACL (no inheritance) granting Full to SYSTEM and
/// BUILTIN\Administrators, and to nobody else. Everything that legitimately manages pf-vdisplay is
/// one of those two; a LocalService plugin runner is neither, so it can no longer open our object.
fn security_descriptor() -> Result<LocalSd> {
use windows::Win32::Security::Authorization::ConvertStringSecurityDescriptorToSecurityDescriptorW;
use windows::Win32::Security::Authorization::SDDL_REVISION_1;
let mut psd = PSECURITY_DESCRIPTOR::default();
// SAFETY: the SDDL literal is NUL-terminated (`w!`), and `psd` is a live out-param whose
// allocation is taken over by `LocalSd` below.
unsafe {
ConvertStringSecurityDescriptorToSecurityDescriptorW(
w!("D:P(A;;GA;;;SY)(A;;GA;;;BA)"),
SDDL_REVISION_1,
&mut psd,
None,
)
}
.context("build the pf-vdisplay single-instance security descriptor")?;
Ok(LocalSd(psd.0))
}
/// Owns a `LocalAlloc`'d security descriptor and frees it on drop.
struct LocalSd(*mut core::ffi::c_void);
impl Drop for LocalSd {
fn drop(&mut self) {
if !self.0.is_null() {
// SAFETY: the pointer came from ConvertStringSecurityDescriptorToSecurityDescriptorW,
// which documents LocalFree as the matching deallocation.
unsafe {
let _ = windows::Win32::Foundation::LocalFree(Some(
windows::Win32::Foundation::HLOCAL(self.0),
));
}
self.0 = std::ptr::null_mut();
}
}
}
/// The owner SID of a kernel object, as an SDDL string. `None` when it cannot be read (the handle
/// lacks READ_CONTROL) — treated as "unknown", never as "fine".
fn object_owner_sid(h: HANDLE) -> Option<String> {
use windows::Win32::Foundation::{LocalFree, HLOCAL};
use windows::Win32::Security::Authorization::{
ConvertSidToStringSidW, GetSecurityInfo, SE_KERNEL_OBJECT,
};
use windows::Win32::Security::{OWNER_SECURITY_INFORMATION, PSID};
let mut owner = PSID::default();
let mut sd = PSECURITY_DESCRIPTOR::default();
// SAFETY: `h` is the live mutex handle; the out-params are live locals; `sd` is the single
// allocation and is LocalFree'd below.
let rc = unsafe {
GetSecurityInfo(
h,
SE_KERNEL_OBJECT,
OWNER_SECURITY_INFORMATION,
Some(&mut owner),
None,
None,
None,
Some(&mut sd),
)
};
let out = if rc.is_ok() && !owner.is_invalid() {
let mut sid_str = windows::core::PWSTR::null();
// SAFETY: `owner` points into `sd` and is a valid SID; `sid_str` is a live out-param whose
// LocalAlloc'd string is freed immediately below.
unsafe {
if ConvertSidToStringSidW(owner, &mut sid_str).is_ok() && !sid_str.is_null() {
let text = sid_str.to_string().unwrap_or_default();
let _ = LocalFree(Some(HLOCAL(sid_str.0 as _)));
Some(text)
} else {
None
}
}
} else {
None
};
// SAFETY: `sd` is the LocalAlloc'd descriptor GetSecurityInfo returned (null when it failed,
// which LocalFree tolerates).
unsafe {
let _ = LocalFree(Some(HLOCAL(sd.0)));
}
out
}
/// SYSTEM, BUILTIN\Administrators, or a member of the Administrators-owned set — the principals a
/// legitimate pf-vdisplay manager runs as.
fn is_privileged_sid(sid: &str) -> bool {
matches!(sid, "S-1-5-18" | "S-1-5-32-544") || sid.starts_with("S-1-5-80-") // service SIDs
}
-5
View File
@@ -18,11 +18,6 @@ parse_deps = false
# undefined and the C harness fails to compile: the Apple batched recv (transport/udp.rs
# `recvmsg_x` + `MsghdrX`) and the Android bionic mmsg bindings (`android_mmsg` module).
exclude = ["MsghdrX", "recvmsg_x", "mmsghdr", "sendmmsg", "recvmmsg"]
# Reached by no exported SIGNATURE, so cbindgen's sweep misses it — but a C embedder needs the
# vocabulary: `punktfunk_connection_end_reason` writes one of these as a bare byte (deliberately,
# so the JNI/Swift sides can marshal a `u8` rather than an enum), which without this would leave
# the header documenting names it never defines.
include = ["PunktfunkEndReason"]
[export.rename]
"InputEvent" = "PunktfunkInputEvent"
-39
View File
@@ -2273,45 +2273,6 @@ pub unsafe extern "C" fn punktfunk_connection_audio_channels(
})
}
/// WHY this session ended: `*out` receives a [`PunktfunkEndReason`] byte
/// (`PUNKTFUNK_END_REASON_*`). The return status reports only whether the handle was usable.
///
/// Read it once a plane has returned [`PunktfunkStatus::Closed`] (or the embedder's own
/// end-of-session signal fired); before that it reads `NONE`. It latches, so it is still readable
/// while the connection is torn down, and a client that never calls it behaves exactly as it did
/// before this existed.
///
/// **Most endings are not failures.** Before this, a client had no way to tell a player quitting
/// their game from a host falling off the network, so every client wrote one message for all of
/// them and every client chose an error. Use `LOCAL`/`GAME_EXITED`/`HOST_ENDED` to stay quiet (and
/// `GAME_EXITED` to return to the library the title was launched from), and keep the alarming copy
/// for `HOST_ERROR` and `LOST`.
///
/// Treat an unrecognized value as `NONE` — this crosses an ABI and the core may be newer than you.
///
/// # Safety
/// `c` is a valid connection handle; `out` is NULL or writable for one `u8`.
#[cfg(feature = "quic")]
#[no_mangle]
pub unsafe extern "C" fn punktfunk_connection_end_reason(
c: *mut PunktfunkConnection,
out: *mut u8,
) -> PunktfunkStatus {
guard(|| {
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
// has not yet freed, or null, which `as_ref` reports as `None` and the `match` handles.
let c = match unsafe { c.as_ref() } {
Some(c) => c,
None => return PunktfunkStatus::NullPointer,
};
if !out.is_null() {
// SAFETY: `out` is non-null and the caller guarantees it is writable for one `u8`.
unsafe { *out = c.inner.end_reason() as u8 };
}
PunktfunkStatus::Ok
})
}
/// One decoded audio frame from [`punktfunk_connection_next_audio_pcm`]: interleaved 32-bit
/// float PCM at 48 kHz, in the canonical wire channel order `FL FR FC LFE RL RR SL SR` (the
/// first `channels` of it). `samples` points at `frame_count * channels` floats and borrows
-120
View File
@@ -159,17 +159,6 @@ const CAP_REPROBE_WINDOWS_MAX: u32 = 128;
/// choke again at the same place, and only backoffs at a climbed-to rate can agree within the
/// band (a cascade's second backoff sits at ×0.7 of the first: outside it by construction).
const DECODE_CAP_SIMILAR_DIV: u32 = 8;
/// A deciding window that DELIVERED under `current / STARVED_DELIVERY_DIV` is STARVED: the
/// stream barely flowed (a host-side capture stall, an outage, a mid-window pause), so whatever
/// distress the window carries — a flush, a keyframe-ask burst — is starvation-shaped, not
/// rate-shaped, and the decoder decoded almost nothing at the nominal rate. Such a window may
/// still back off (real damage deserves the safe response) but must never be a decode-knee
/// sample: latching `current_kbps` off a starved window teaches a phantom decoder cap at
/// whatever rate the stall interrupted (the periodic-capture-stall field case: every 5 s cycle
/// offers another pair of "backoffs" at the same rate — a bogus latch that then fights the
/// re-probe ladder for minutes). Deliberately far below the ×¾ utilization bar climbs require:
/// the band between them is ambiguous and keeps today's behavior.
const STARVED_DELIVERY_DIV: u32 = 4;
/// Rolling window (in 750 ms report windows, ~30 s) whose minimum mean is the OWD baseline.
/// Long enough to remember the uncongested floor, short enough to follow genuine path changes.
const BASELINE_WINDOWS: usize = 40;
@@ -708,10 +697,6 @@ impl BitrateController {
|| self.streak_decode_windows >= BAD_WINDOWS_TO_DECREASE
|| (recovery_kf >= RECOVERY_KF_BAD && loss_ppm < HEAVY_LOSS_PPM)
|| (flushed && (decode_bad || decode_mean_us.is_none()));
// Starved deciding window (see [`STARVED_DELIVERY_DIV`]): the stream barely flowed,
// so the window says nothing about what the decoder can hold at this rate.
let starved =
(actual_kbps as u64) * (STARVED_DELIVERY_DIV as u64) < self.current_kbps as u64;
if !self.climb_since_backoff {
// Still draining the previous backoff: the host acks a ×0.7 request in ~100 ms,
// so this window's rate is one the decoder never choked at while keeping up —
@@ -723,17 +708,6 @@ impl BitrateController {
"adaptive bitrate: backoff without an intervening climb — draining the \
previous choke, not a knee sample"
);
} else if starved {
// Same "not a knee sample either way" treatment as the draining arm: neither
// latch against a starved window nor let it erase the reference a real knee
// set — the next genuine choke at that rate must still find its pair.
tracing::debug!(
at_kbps = self.current_kbps,
actual_kbps,
reference_kbps = self.decode_backoff_kbps,
"adaptive bitrate: backoff in a starved window (delivery a fraction of \
the target) starvation-shaped distress, not a knee sample"
);
} else if decode_evidence {
let rate = self.current_kbps;
let similar = self.decode_backoff_kbps > 0
@@ -2110,100 +2084,6 @@ mod tests {
rate - rate / 16
}
/// One capture-stall-shaped window at the current rate: almost nothing delivered
/// (current/10), nothing decoded, no loss — but a jump-to-live flush and a keyframe-ask
/// storm (the stall edge's damage signature). SEVERE, so it backs off; STARVED, so it must
/// never be a knee sample.
fn stall_choke(c: &mut BitrateController, start: Instant, tick: &mut u32) -> Option<u32> {
*tick += 2;
let r = c.on_window(
ticks(start, *tick),
0,
0,
None,
None,
None,
c.current_kbps / 10,
true,
RECOVERY_KF_SEVERE,
);
*tick += 1;
r
}
#[test]
fn capture_stall_windows_never_latch_a_decode_cap() {
// The periodic-capture-stall field case (RDNA4 standby-sink, 5 s cycle): every stall
// edge offers another flush + kf-storm "backoff" at the SAME rate — without the starved
// guard that pair latches a phantom decoder knee at whatever rate the display driver
// happened to interrupt, and the session then fights the re-probe ladder for minutes.
let mut c = BitrateController::new(240_000);
c.set_ceiling(900_000);
let start = Instant::now();
let mut t = 0;
for _ in 0..4 {
calm_window(&mut c, ticks(start, t));
t += 1;
}
climb_to(&mut c, start, &mut t, 400_000);
let at = c.current_kbps;
let r1 = stall_choke(&mut c, start, &mut t).expect("stall damage still backs off");
assert!(
c.decode_cap_kbps.is_none(),
"one starved window must not latch"
);
assert_eq!(
c.decode_backoff_kbps, 0,
"a starved window is not a knee sample — no reference recorded"
);
c.on_ack(r1);
climb_to(&mut c, start, &mut t, at - at / DECODE_CAP_SIMILAR_DIV);
let r2 = stall_choke(&mut c, start, &mut t).expect("second stall edge backs off too");
c.on_ack(r2);
assert!(
c.decode_cap_kbps.is_none(),
"a starved pair at the same rate must not latch a phantom knee"
);
}
#[test]
fn starved_window_preserves_the_knee_reference() {
// A REAL knee sample, then a stall edge, then the genuine re-climb choke: the starved
// window in the middle must neither latch nor ERASE the reference the real choke set —
// the genuine pair must still find each other around it.
let mut c = BitrateController::new(500_000);
c.set_ceiling(900_000);
let start = Instant::now();
let mut t = 0;
for _ in 0..4 {
calm_window(&mut c, ticks(start, t));
t += 1;
}
let knee = c.current_kbps;
let r1 = choke(&mut c, start, &mut t).expect("real choke backs off");
assert_eq!(
c.decode_backoff_kbps, knee,
"real choke records the reference"
);
c.on_ack(r1);
climb_to(&mut c, start, &mut t, knee - knee / DECODE_CAP_SIMILAR_DIV);
let r2 = stall_choke(&mut c, start, &mut t).expect("stall edge backs off");
assert_eq!(
c.decode_backoff_kbps, knee,
"the starved window must not erase the real reference"
);
assert!(c.decode_cap_kbps.is_none(), "and must not latch against it");
c.on_ack(r2);
climb_to(&mut c, start, &mut t, knee - knee / DECODE_CAP_SIMILAR_DIV);
let rate = c.current_kbps;
choke(&mut c, start, &mut t).expect("genuine re-climb choke backs off");
assert_eq!(
c.decode_cap_kbps,
Some(rate - rate / 16),
"the genuine pair still latches around the starved interruption"
);
}
#[test]
fn decode_cap_latches_when_the_reclimb_chokes_at_the_same_knee() {
// The 1440p120 field sawtooth: a decoder knee (~500 Mbps) well under the (inflated)
-115
View File
@@ -110,91 +110,6 @@ pub struct MicUplinkStats {
/// the control task is wedged, which callers treat as a closed session.
const CTRL_QUEUE: usize = 32;
/// Why a session ended — [`NativeClient::end_reason`], and `punktfunk_connection_end_reason` on the
/// C surface.
///
/// The distinction that matters to a UI is **normal vs alarming**, and it is not a spectrum: a
/// player quitting their game and a host falling off the network both arrive as "the session
/// ended", and a client with no way to separate them has to word all of them the same. Every client
/// worded them as failures.
///
/// Ordered loosely from "the user did this on purpose" to "something went wrong". Values are part
/// of the C ABI: append only, never renumber.
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PunktfunkEndReason {
/// Not ended (or ended before a reason could be observed). Also what an unknown future value
/// decodes to, so an older client reading a newer core degrades to "no opinion".
None = 0,
/// **This client** closed the session — the user pressed stop, or the handle was dropped.
/// Nothing to report: the UI already knows, it initiated it.
Local = 1,
/// The host's launched game exited ([`crate::quic::APP_EXITED_CLOSE_CODE`]). A normal finish,
/// and the one reason a launcher client can act on: go back to the library the title was
/// launched from rather than all the way out to host selection.
GameExited = 2,
/// The host ended the session cleanly and deliberately — an operator "End" in the console, or
/// the session simply finishing. Normal; say so plainly or say nothing.
HostEnded = 3,
/// The host closed reporting a failure of its own. Worth showing, and the host's log has the
/// detail.
HostError = 4,
/// The connection died rather than being closed: idle timeout, reset, the network going away.
/// This — and only this — is the "the host may be asleep, wake it" case.
Lost = 5,
}
impl PunktfunkEndReason {
/// Decode the wire/ABI byte. Unknown values become [`Self::None`] rather than panicking: this
/// crosses an ABI where the writer may be newer than the reader.
pub fn from_u8(v: u8) -> Self {
match v {
1 => Self::Local,
2 => Self::GameExited,
3 => Self::HostEnded,
4 => Self::HostError,
5 => Self::Lost,
_ => Self::None,
}
}
/// Whether this ending is an ordinary outcome rather than something to alarm the user about.
///
/// The single question nearly every client actually asks. `Local`, `GameExited` and `HostEnded`
/// are all things that were *meant* to happen; only a host-side failure or a dead connection
/// are not. [`Self::None`] counts as normal — no evidence of trouble is not evidence of it.
pub fn is_normal(self) -> bool {
!matches!(self, Self::HostError | Self::Lost)
}
}
#[cfg(feature = "quic")]
impl From<&quinn::ConnectionError> for PunktfunkEndReason {
/// Classify the QUIC close.
///
/// Only two application codes ever arrive from a host at session end: `APP_EXITED` when the
/// game it launched quit, and the teardown's own `0` (clean) / `1` (the session returned an
/// error) from `native.rs`. Anything else with an application code is a deliberate host-side
/// close we do not have a name for, which is still closer to "the host ended it" than to a
/// dead link — but a code we have never issued is more likely a fault than a courtesy, so it
/// lands in `HostError` where it will at least be visible.
fn from(e: &quinn::ConnectionError) -> Self {
match e {
quinn::ConnectionError::LocallyClosed => Self::Local,
quinn::ConnectionError::ApplicationClosed(ac) => {
match u32::try_from(u64::from(ac.error_code)) {
Ok(crate::quic::APP_EXITED_CLOSE_CODE) => Self::GameExited,
Ok(0) => Self::HostEnded,
_ => Self::HostError,
}
}
// TimedOut, Reset, VersionMismatch, TransportError, CidsExhausted, and the peer's
// transport-level close: the link failed, nobody said goodbye.
_ => Self::Lost,
}
}
}
pub struct NativeClient {
// Each plane's receiver sits behind its own mutex so `NativeClient` is `Sync` and Rust
// embedders can share one `Arc<NativeClient>` across their plane threads (the same
@@ -265,9 +180,6 @@ pub struct NativeClient {
/// Speed-test accumulator, shared with the data-plane pump + control task.
probe: Arc<Mutex<ProbeState>>,
shutdown: Arc<AtomicBool>,
/// A [`PunktfunkEndReason`] as `u8`, latched with `shutdown` — see
/// [`NativeClient::end_reason`].
end_reason: Arc<AtomicU8>,
/// Deliberate-quit flag: [`NativeClient::disconnect_quit`] sets it, so the worker closes the QUIC
/// connection with [`crate::quic::QUIT_CLOSE_CODE`] (a user "stop") instead of code 0 — telling the
/// host to skip the keep-alive linger. A plain drop leaves it false → an unwanted-disconnect close.
@@ -536,7 +448,6 @@ impl NativeClient {
std::sync::mpsc::sync_channel::<crate::quic::CursorState>(CURSOR_STATE_QUEUE);
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<Negotiated>>();
let shutdown = Arc::new(AtomicBool::new(false));
let end_reason = Arc::new(AtomicU8::new(PunktfunkEndReason::None as u8));
let quit = Arc::new(AtomicBool::new(false));
let mode_slot = Arc::new(std::sync::Mutex::new(mode));
let probe = Arc::new(Mutex::new(ProbeState::default()));
@@ -552,7 +463,6 @@ impl NativeClient {
let host = host.to_string();
let frame_chan_w = frame_chan.clone();
let shutdown_w = shutdown.clone();
let end_reason_w = end_reason.clone();
let quit_w = quit.clone();
let mode_slot_w = mode_slot.clone();
let probe_w = probe.clone();
@@ -628,7 +538,6 @@ impl NativeClient {
clip_cmd_rx,
ready_tx,
shutdown: shutdown_w,
end_reason: end_reason_w,
quit: quit_w,
mode_slot: mode_slot_w,
probe: probe_w,
@@ -682,7 +591,6 @@ impl NativeClient {
host_caps: negotiated.host_caps,
probe,
shutdown,
end_reason,
quit,
worker: Some(worker),
frames_dropped,
@@ -901,29 +809,6 @@ impl NativeClient {
self.shutdown.load(Ordering::SeqCst)
}
/// WHY the session ended — see [`PunktfunkEndReason`].
///
/// A refinement of [`is_session_ended`](Self::is_session_ended), never a substitute: it stays
/// [`PunktfunkEndReason::None`] until that is true, and every client that ignores it behaves
/// exactly as it did before this existed.
///
/// What it is FOR: **most endings are not failures.** A client that cannot tell them apart has
/// to pick one wording for all of them, and every such client picked an error — "Session ended
/// by <host>", "Connection lost — the host may be asleep" — including when the player quit the
/// game themselves. This is the discriminator that lets each client stay quiet for a normal
/// finish, return to its library when a launched game exits, and reserve the alarming copy for
/// an ending that actually deserves it.
///
/// Latches, so it is still readable while the connection is being torn down.
pub fn end_reason(&self) -> PunktfunkEndReason {
PunktfunkEndReason::from_u8(self.end_reason.load(Ordering::SeqCst))
}
/// Shorthand for the single most actionable reason: the host's launched game exited.
pub fn ended_because_game_exited(&self) -> bool {
self.end_reason() == PunktfunkEndReason::GameExited
}
/// Register the calling thread as latency-critical so a later
/// [`hot_thread_ids`](Self::hot_thread_ids) includes it. An embedder calls this from its own
/// plane threads (e.g. the Android client's decode + audio threads) to fold them into the same
+2 -8
View File
@@ -65,7 +65,6 @@ pub(super) async fn run_pump(args: WorkerArgs) {
clip_cmd_rx,
ready_tx,
shutdown,
end_reason,
quit,
mode_slot,
probe,
@@ -195,17 +194,12 @@ pub(super) async fn run_pump(args: WorkerArgs) {
clip_cmd_rx,
));
// Watch for connection close → stop the pump, and classify WHY.
// Watch for connection close → stop the pump.
{
let shutdown = shutdown.clone();
let end_reason = end_reason.clone();
let conn = conn.clone();
tokio::spawn(async move {
let why = conn.closed().await;
// Latch the reason BEFORE `shutdown`: the two are observed by different threads, and a
// client that reacts to the shutdown flag must never find the reason still unset.
let reason = crate::client::PunktfunkEndReason::from(&why);
end_reason.store(reason as u8, Ordering::SeqCst);
conn.closed().await;
shutdown.store(true, Ordering::SeqCst);
});
}
@@ -68,9 +68,6 @@ pub(crate) struct WorkerArgs {
pub(crate) clip_cmd_rx: tokio::sync::mpsc::UnboundedReceiver<ClipCommand>,
pub(crate) ready_tx: std::sync::mpsc::Sender<Result<Negotiated>>,
pub(crate) shutdown: Arc<AtomicBool>,
/// A [`crate::client::PunktfunkEndReason`] as `u8`, classified from the connection's close and
/// latched alongside `shutdown` (see [`NativeClient::end_reason`]).
pub(crate) end_reason: Arc<AtomicU8>,
/// Deliberate-quit flag (see [`NativeClient::quit`]): the worker closes with the quit code if set.
pub(crate) quit: Arc<AtomicBool>,
pub(crate) mode_slot: Arc<std::sync::Mutex<Mode>>,
+1 -8
View File
@@ -138,14 +138,7 @@ pub use stats::Stats;
/// capability-gated end to end: the wire grows a new datagram tag (0xD1) an old client never
/// receives (double-gated caps), a new 0xCD kind (0x06, dropped as unknown by old clients) and
/// arrival flag bits 8/9 sent only toward a capable host, so [`WIRE_VERSION`] is unchanged.
/// v17: added `punktfunk_connection_end_reason` + the `PUNKTFUNK_END_REASON_*` vocabulary — asks,
/// once a session has ended, WHY: this client closed it, the host's launched game exited (its close
/// carried [`quic::APP_EXITED_CLOSE_CODE`], which the host has sent since long before this bump
/// with nothing consuming it), the host ended it cleanly, the host reported a failure, or the
/// connection was simply lost. Purely a read of state the core already had: no new call is required
/// of an embedder, a client that never calls it is unchanged, and the host sends exactly the same
/// bytes either way, so [`WIRE_VERSION`] is unchanged.
pub const ABI_VERSION: u32 = 17;
pub const ABI_VERSION: u32 = 16;
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
+16 -92
View File
@@ -52,24 +52,6 @@ const EXIT_CONFIRM: Duration = Duration::from_secs(3);
const SHIM_WINDOW: Duration = Duration::from_secs(5);
/// How long a game gets to close on its own after a polite request, before it is killed outright.
const TERM_GRACE: Duration = Duration::from_secs(10);
/// How long [`crate::procscan::running_hint`] may hold off the exit once the game's processes have
/// all gone.
///
/// The hint is a tie-breaker for a scan that momentarily cannot see the game — a launcher re-execing,
/// an engine relaunching itself into a new pid — and those gaps are over in seconds, an order of
/// magnitude inside this window. Past it, a game nothing can find is gone whatever the hint says.
///
/// **Bounded because the hint's backing state is not guaranteed to be truthful.** Windows reads
/// Steam's per-app `Running` registry flag, which Steam leaves set whenever it does not cleanly
/// observe the exit (Steam crashed or was closed first, the game re-parented, a launcher appid stays
/// set) — and `steam_running_hint` believes the first hive that says so, including a stale one left
/// in another profile. An UNBOUNDED veto turns that into a session that never ends on its own: the
/// console shows the game running for as long as the host does, `session_on_game_exit` never fires,
/// and only a manual "End" gets the stream back (field report 2026-08-06, Windows host 0.24.0).
///
/// Ending a moment too early is the cheaper failure: the stream drops while the game lives (the user
/// reconnects, and `finish` never kills anything). Ending never is the bug above.
const VETO_LIMIT: Duration = Duration::from_secs(30);
/// A child process the host spawned for a launch, and what may safely be signalled for it.
#[derive(Clone, Copy, Debug)]
@@ -558,59 +540,29 @@ fn watch(shared: Arc<LeaseShared>, mut child: Option<std::process::Child>, on_ex
gone_since = None;
vetoed = false;
shared.last_seen_ms.store(now_ms(), Ordering::Relaxed);
} else {
// How long the game's processes have been CONTINUOUSLY absent. Deliberately not reset by
// the veto below — letting it run on is exactly what bounds the veto.
let gone_for = gone_since.get_or_insert_with(Instant::now).elapsed();
if gone_for >= EXIT_CONFIRM {
// Last check before ending a session: does anything outside the process scan still
// think the game is up? Only a veto, never a reason to call it running — see
// `procscan::running_hint`.
let hint_running = crate::procscan::running_hint(&shared.spec) == Some(true);
if !exit_confirmed(gone_for, hint_running) {
if !vetoed {
vetoed = true;
tracing::info!(
title = %shared.game.title,
veto_limit_s = VETO_LIMIT.as_secs(),
"no game processes found, but its launcher still reports it running — \
holding off on ending the session"
);
}
} else {
if hint_running {
// The veto outlived its usefulness: nothing this scan can see has existed
// for VETO_LIMIT, so the launcher's opinion is stale, not early.
tracing::warn!(
title = %shared.game.title,
gone_for_s = gone_for.as_secs(),
"its launcher still reports the game running, but nothing of it has \
been on the box for {}s treating that as a stale flag and ending \
the session",
VETO_LIMIT.as_secs()
);
}
finish(&shared, &on_exit, "the game exited");
return;
} else if gone_since.get_or_insert_with(Instant::now).elapsed() >= EXIT_CONFIRM {
// Last check before ending a session: does anything outside the process scan still think
// the game is up? Only a veto, never a reason to call it running — see
// `procscan::running_hint`. The failure mode of honoring it is a stream that stays up.
if crate::procscan::running_hint(&shared.spec) == Some(true) {
if !vetoed {
vetoed = true;
tracing::info!(
title = %shared.game.title,
"no game processes found, but its launcher still reports it running — not \
ending the session"
);
}
gone_since = None;
} else {
finish(&shared, &on_exit, "the game exited");
return;
}
}
std::thread::sleep(POLL);
}
}
/// Whether a game nothing can find any more counts as exited: absent for at least [`EXIT_CONFIRM`],
/// and either unopposed or absent long enough that the opposition ([`crate::procscan::running_hint`]
/// saying `Some(true)`) has been overruled by [`VETO_LIMIT`].
///
/// Split out of the watch loop because it is the one rule in this file whose *bound* is the fix:
/// the loop itself polls a live process table and cannot be unit-tested, which is how an unbounded
/// veto shipped. Pure, so the table below is the whole contract.
#[cfg(any(target_os = "linux", windows))]
fn exit_confirmed(gone_for: Duration, hint_running: bool) -> bool {
gone_for >= EXIT_CONFIRM && (!hint_running || gone_for >= VETO_LIMIT)
}
/// Record the exit and, unless the host itself ended the game, run the session-ending action.
#[cfg(any(target_os = "linux", windows))]
fn finish(shared: &Arc<LeaseShared>, on_exit: &OnExit, why: &str) {
@@ -1085,34 +1037,6 @@ mod tests {
.any(|(s, _)| s.game.id.as_deref() == Some(id))
}
/// The exit rule, including the thing that was missing: the veto ENDS.
///
/// Field 2026-08-06 (Windows 0.24.0): Steam's per-app `Running` flag was left set after the game
/// exited, the watcher honoured it on every pass and reset its own confirm window each time, so
/// the game read as running for the life of the host and the stream never auto-ended. The last
/// case below is that regression.
#[cfg(any(target_os = "linux", windows))]
#[test]
fn the_launcher_veto_expires_instead_of_pinning_a_session_open() {
let brief = EXIT_CONFIRM / 2;
let confirmed = EXIT_CONFIRM + Duration::from_secs(1);
let long = VETO_LIMIT + Duration::from_secs(1);
// Too early to call it either way — a process swap is still plausible.
assert!(!exit_confirmed(brief, false));
assert!(!exit_confirmed(brief, true));
// Gone past the confirm window with nothing objecting: exited.
assert!(exit_confirmed(confirmed, false));
// Same, but the launcher objects — that is what the veto is FOR, so hold off.
assert!(!exit_confirmed(confirmed, true));
// …and this is the bound. Still objecting, but nothing of the game has existed for
// VETO_LIMIT, so the objection is stale and the session ends anyway.
assert!(exit_confirmed(long, true));
assert!(exit_confirmed(long, false));
// (The middle two cases together also pin VETO_LIMIT > EXIT_CONFIRM: a veto that did not
// outlast the window it overrides could never hold anything off in the first place.)
}
#[test]
fn kind_follows_what_the_launch_gave_us() {
// Nested wins over everything: the display layer owns the lifetime.
@@ -245,19 +245,6 @@ mod tests {
}
}
/// The migration invariant D2 exists to protect. Moonlight caches app ids (and users pin them),
/// and the id is derived from the LIBRARY ID alone — so a title moving from the in-host scanner
/// to a claimed plugin entry keeps its GameStream id iff the library id is byte-identical. This
/// pins that the claimed shape is that shape, and that an unclaimed one would NOT have been.
#[test]
fn a_claimed_plugin_entry_keeps_the_scanners_gamestream_id() {
// What the built-in scanner produced, and what the steam plugin produces once it claims.
assert_eq!(stable_app_id("steam:440"), stable_app_id("steam:440"));
// The same title reconciled WITHOUT a claim gets an opaque `custom:` id — a different app
// id, i.e. exactly the breakage the claim prevents.
assert_ne!(stable_app_id("steam:440"), stable_app_id("custom:9f2c1a"));
}
#[test]
fn append_library_dedups_against_base_ids() {
// A base app whose id happens to fall in the library range must not be clobbered by a library
+2 -10
View File
@@ -26,14 +26,6 @@ impl ServerIdentity {
let dir = config_dir();
let cert_path = dir.join("cert.pem");
let key_path = dir.join("key.pem");
// Harden the directory BEFORE the first read, not only in the branch that generates a new
// identity (2026-08-05 review M-1). Reading first is what made the hardening pointless
// against the attack it was written for: combined with H-4's pre-creatable
// `%ProgramData%\punktfunk`, a local user could plant a cert/key pair and have it adopted
// verbatim as the host's long-lived identity — the QUIC server key, the mgmt-API TLS key and
// the RSA pairing signer all becoming a key the attacker holds. The compromise is permanent:
// this function never regenerates while both files are non-empty.
pf_paths::create_private_dir(&dir).ok();
let (cert_pem, key_pem) = match (
fs::read_to_string(&cert_path),
fs::read_to_string(&key_path),
@@ -43,8 +35,8 @@ impl ServerIdentity {
let (c, k) = generate()?;
// The private key is the trust root for EVERY surface (TLS server cert, pairing
// signing, the QUIC identity clients pin) — write it owner-only (0600 / SYSTEM-only
// DACL) so a local user can't read it and impersonate the host. The dir is already
// 0700 / SYSTEM+Admins from the unconditional hardening above.
// DACL) so a local user can't read it and impersonate the host. The dir is 0700.
pf_paths::create_private_dir(&dir).ok();
pf_paths::write_secret_file(&key_path, k.as_bytes())
.with_context(|| format!("write {}", key_path.display()))?;
// The cert is public (handed to clients), but write it owner-only too for consistency.
+27 -133
View File
@@ -432,124 +432,44 @@ fn flatten_env(ev: &crate::events::HostEvent) -> Vec<(String, String)> {
out
}
/// The sshd/sudoers rule (RFC §9.1): refuse to run a command that references a script/binary which
/// is group/world-writable, or owned by neither the host user nor root — a world-writable hook
/// script is privilege-escalation bait. A bare command name (`systemctl`, `curl`) is left to PATH.
///
/// **This is a hygiene rule, not an authorization gate**, and the distinction matters: it
/// constrains *who owns the file being run*, never *what the command does*. `curl … | sh` and
/// `python3 -c '…'` are unconstrained by construction, and `/bin/sh -c '<anything>'` passes because
/// `/bin/sh` is root-owned. Whoever may WRITE a hook already has command execution as the host
/// user — which is why writing them is admin-only. A pass here does not mean "this command is
/// safe", and nothing should be granted on the strength of it.
///
/// It checks EVERY absolute-path token, not just the first (2026-08-05 review L-12). Looking only
/// at `cmd.split_whitespace().next()` meant `bash /opt/x/hook.sh`, `sh -c /tmp/x` and any quoted
/// path skipped the check entirely — so the interpreter was vetted and the script it ran was not,
/// which is backwards: the script is the part an attacker can plant.
/// The sshd/sudoers rule (RFC §9.1): when the command's first token is a path to an existing
/// file, refuse to run it unless it is owned by the host user (or root) and not
/// group/world-writable — a world-writable hook script is privilege escalation bait. A bare
/// command name (`systemctl`, `curl`) is left to PATH.
#[cfg(unix)]
fn exec_path_check(cmd: &str) -> Result<(), String> {
use std::os::unix::fs::MetadataExt;
if cmd.split_whitespace().next().is_none() {
let Some(first) = cmd.split_whitespace().next() else {
return Err("empty command".into());
};
if !first.starts_with('/') {
return Ok(());
}
let meta = match std::fs::metadata(first) {
Ok(m) => m,
Err(_) => return Ok(()), // not an existing file — the shell will report it
};
if !meta.is_file() {
return Ok(());
}
// SAFETY: geteuid has no preconditions and touches no memory.
let euid = unsafe { libc::geteuid() };
for raw in cmd.split_whitespace() {
// Tolerate the quoting a hand-written command line carries — a path that is absolute only
// after unquoting is exactly as plantable as a bare one.
let token = raw.trim_matches(|c| c == '"' || c == '\'');
if !token.starts_with('/') {
continue;
}
let meta = match std::fs::metadata(token) {
Ok(m) => m,
Err(_) => continue, // not an existing file — the shell will report it
};
if !meta.is_file() {
continue;
}
if meta.uid() != euid && meta.uid() != 0 {
return Err(format!(
"{token} is owned by uid {} (host runs as uid {euid}) — hook scripts must be \
owned by the operator or root",
meta.uid()
));
}
if meta.mode() & 0o022 != 0 {
return Err(format!(
"{token} is group/world-writable (mode {:o}) — chmod go-w it first",
meta.mode() & 0o7777
));
}
if meta.uid() != euid && meta.uid() != 0 {
return Err(format!(
"{first} is owned by uid {} (host runs as uid {euid}) — hook scripts must be \
owned by the operator or root",
meta.uid()
));
}
if meta.mode() & 0o022 != 0 {
return Err(format!(
"{first} is group/world-writable (mode {:o}) — chmod go-w it first",
meta.mode() & 0o7777
));
}
Ok(())
}
/// Whether this process is running as `NT AUTHORITY\SYSTEM` (S-1-5-18) — i.e. as the SCM service
/// rather than as the operator's own console process.
///
/// Used to decide whether the in-process hook fallback is acceptable: as the operator it is the
/// privilege they already have, as SYSTEM it is an elevation the hook contract forbids
/// (2026-08-05 review L-13). Fails CLOSED — an unreadable token is treated as SYSTEM, because the
/// consequence of guessing wrong in that direction is a skipped hook, and in the other direction
/// it is a SYSTEM command.
#[cfg(windows)]
fn running_as_system() -> bool {
use windows::Win32::Foundation::HANDLE;
use windows::Win32::Security::{
CreateWellKnownSid, EqualSid, GetTokenInformation, TokenUser, WinLocalSystemSid, PSID,
SECURITY_MAX_SID_SIZE, TOKEN_QUERY, TOKEN_USER,
};
use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
let mut token = HANDLE::default();
// SAFETY: pseudo-handle from GetCurrentProcess; `token` is a live out-param.
if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) }.is_err() {
return true; // fail closed
}
let mut buf = [0u8; 256];
let mut len = 0u32;
// SAFETY: `buf` is a writable local of the length passed; `len` is a live out-param.
let got = unsafe {
GetTokenInformation(
token,
TokenUser,
Some(buf.as_mut_ptr().cast()),
buf.len() as u32,
&mut len,
)
};
// SAFETY: the token handle came from OpenProcessToken and is not used after this.
unsafe {
let _ = windows::Win32::Foundation::CloseHandle(token);
}
if got.is_err() {
return true; // fail closed
}
let mut system = [0u8; SECURITY_MAX_SID_SIZE as usize];
let mut cb = system.len() as u32;
// SAFETY: the buffer is SECURITY_MAX_SID_SIZE, the documented maximum SID size.
if unsafe {
CreateWellKnownSid(
WinLocalSystemSid,
None,
Some(PSID(system.as_mut_ptr().cast())),
&mut cb,
)
}
.is_err()
{
return true; // fail closed
}
// SAFETY: `buf` holds a TOKEN_USER written by GetTokenInformation; its `User.Sid` points into
// the same buffer, and both SIDs are valid for this comparison.
unsafe {
let tu = &*(buf.as_ptr() as *const TOKEN_USER);
EqualSid(tu.User.Sid, PSID(system.as_mut_ptr().cast())).is_ok()
}
}
#[cfg(not(unix))]
fn exec_path_check(_cmd: &str) -> Result<(), String> {
// Windows: hooks.json lives in the SYSTEM/Admins-DACL'd config dir and the command runs in
@@ -660,33 +580,7 @@ fn run_hook_process(
// report "ran" (prep `undo`s stay armed).
true
}
Err(e) if running_as_system() => {
// NO in-process fallback when we are SYSTEM.
//
// `spawn_in_active_session` fails whenever there is no interactive user — pre-login, at
// boot, on a logged-off box — and the fallback below then ran the operator's command
// line through `cmd.exe /C` IN THIS PROCESS. As the SCM service that process is
// LocalSystem, so a hook the module contract promises runs "in the interactive session,
// never SYSTEM" quietly became a SYSTEM command, at the exact moments nobody is watching
// the screen, with no ownership check on the script (`exec_path_check` is a no-op on
// Windows) — 2026-08-05 review L-13.
//
// Refusing is the honest behaviour: the contract says these run as the user, and if
// there is no user there is nothing to run them as. A hook that must run without a
// logged-in user belongs in a service, not here.
tracing::warn!(
cmd = %cmd,
error = %format!("{e:#}"),
"hook SKIPPED: no interactive user session to run it in, and this host is SYSTEM — \
hooks run as the logged-in user by design and are never elevated to SYSTEM"
);
let _ = std::fs::remove_file(&json_path);
false
}
Err(e) => {
// Not SYSTEM (a hand-run `punktfunk-host serve` in the operator's own console): running
// in-process is the same privilege the operator already has, which is the whole trust
// model for hooks.
tracing::debug!(error = %format!("{e:#}"),
"interactive-session spawn unavailable — running hook in-console");
let mut ok = false;
+6 -54
View File
@@ -15,7 +15,7 @@
pub(crate) use anyhow::{Context, Result};
pub(crate) use serde::{Deserialize, Serialize};
pub(crate) use sha2::{Digest, Sha256};
pub(crate) use std::collections::{BTreeMap, HashSet};
pub(crate) use std::collections::HashSet;
pub(crate) use std::path::{Path, PathBuf};
pub(crate) use std::time::{SystemTime, UNIX_EPOCH};
pub(crate) use utoipa::ToSchema;
@@ -136,29 +136,6 @@ impl GameMeta {
}
}
/// What a library entry *is* — an ordinary title, or the launcher application itself (Steam Big
/// Picture, Heroic, Playnite fullscreen). Purely a presentation hint: a launcher entry launches,
/// leases and lists exactly like a game (design D4), and clients that don't know the field render it
/// as a plain tile. Serde-default `game` and skip-serialized when default, so the wire is unchanged
/// for every entry that doesn't opt in.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum GameRole {
/// An ordinary title.
#[default]
Game,
/// The launcher application itself.
Launcher,
}
impl GameRole {
/// Whether this is the serde default (`game`) — the `skip_serializing_if` predicate that keeps
/// the field off the wire for the overwhelming majority of entries.
pub(crate) fn is_game(&self) -> bool {
matches!(self, Self::Game)
}
}
/// One title in the unified library, regardless of which store it came from.
#[derive(Clone, Debug, Serialize, ToSchema)]
pub struct GameEntry {
@@ -170,9 +147,6 @@ pub struct GameEntry {
pub store: String,
pub title: String,
pub art: Artwork,
/// Whether this entry is a game or the launcher itself — see [`GameRole`].
#[serde(default, skip_serializing_if = "GameRole::is_game")]
pub role: GameRole,
/// How the host would launch it, when known.
#[serde(skip_serializing_if = "Option::is_none")]
pub launch: Option<LaunchSpec>,
@@ -254,26 +228,12 @@ impl ArtKind {
}
}
/// The full library: every *enabled* source's titles merged + the custom entries, sorted by title.
///
/// Two independent gates run here, both at READ time so neither ever mutates stored state:
///
/// * **The operator's source toggles** (`scanners.rs`, persisted as a disabled-set in
/// `library-scanners.json`) hide a source's titles from every surface — this grid, native clients,
/// `/applist`, and launch resolution. They apply to built-in scanners *and* to plugin sources,
/// which is what lets one toggle keep working verbatim across the whole migration: the ids match
/// (provider id = claimed store id = old scanner id).
/// * **Store claims** (D2): while a library plugin holds a store's claim, the matching built-in
/// scanner is skipped so the two never double-list the same titles during the bridge releases.
/// Removing the plugin releases the claim and the built-in comes straight back.
///
/// The user-curated custom store is not a source and always contributes.
/// The full library: every *enabled* store's titles merged + the custom entries, sorted by title.
/// The operator's scanner toggles (`scanners.rs`) gate each installed-store provider; the custom
/// store is not a scanner and always contributes.
pub fn all_games() -> Vec<GameEntry> {
let off = disabled_scanners();
let claimed = claimed_stores();
// A built-in scanner runs when the operator hasn't disabled it AND no plugin has claimed its
// store out from under it.
let on = |id: &str| !off.contains(id) && !claimed.contains_key(id);
let on = |id: &str| !off.contains(id);
let mut games = Vec::new();
if on("steam") {
games.extend(SteamProvider.list());
@@ -302,15 +262,7 @@ pub fn all_games() -> Vec<GameEntry> {
games.extend(XboxProvider.list());
}
}
// Stored entries: manual ones always contribute; a provider's are subject to the same source
// toggle a built-in scanner is (WP2.6). The plugin may keep reconciling while it is off — the
// entries stay stored and simply aren't surfaced, exactly like a disabled scanner's titles.
games.extend(
load_custom()
.into_iter()
.filter(|e| !source_id_for(e).is_some_and(|src| off.contains(src)))
.map(GameEntry::from),
);
games.extend(load_custom().into_iter().map(GameEntry::from));
games.sort_by_key(|g| g.title.to_lowercase());
games
}
+35 -491
View File
@@ -147,275 +147,45 @@ pub(crate) fn fetch_image(url: &str) -> Option<(Vec<u8>, String)> {
/// A stored [`Artwork`] value that is a **local filesystem path** to an image on the host — as
/// opposed to an `http(s)`/`data:` URL or an already-relative host proxy path. Provider plugins that
/// run on the host (the Playnite sync plugin, and every library scanner plugin) set these: the
/// reconcile payload stays tiny (paths, not inlined bytes, so it scales to thousands of titles) and
/// the host serves the bytes through the art proxy, exactly like Steam's cache art.
///
/// Four accepted shapes:
/// * `file://…` — the **documented plugin contract** ([`file_url_to_path`]), unambiguous on every
/// platform, and what `@punktfunk/plugin-kit/library` emits.
/// * `C:\…` / `C:/…` drive-absolute and `\\server\share` UNC — Windows bare paths, kept for
/// Playnite back-compat (it predates the `file://` contract).
/// * POSIX absolute (`/home/u/covers/x.jpg`) — Lutris covers and Steam's `librarycache`.
///
/// The POSIX widening is why the two `/`-leading shapes the **host itself emits** must be excluded
/// explicitly: its own art-proxy path (`/api/v1/library/art/…`, which [`proxy_local_art`] writes and
/// which must survive a second pass unchanged) and a protocol-relative URL (`//cdn/…`, what GOG's and
/// Microsoft's catalogs return — see [`abs_url`]). Mistaking either for a file would break the proxy
/// round-trip or silently drop CDN art.
/// run on the host (e.g. the Playnite sync plugin) set these: the reconcile payload stays tiny
/// (paths, not inlined bytes, so it scales to thousands of titles) and the host serves the bytes
/// through the art proxy, exactly like Steam's cache art. Windows-shaped only (`C:\…`, `C:/…`, or a
/// `\\server\share` UNC) — Playnite, the only local-art provider, is Windows-only, and this keeps the
/// check from ever mistaking the `/api/…` proxy path (or a POSIX abs path) for a local file.
pub fn is_local_art_path(v: &str) -> bool {
if v.starts_with("http://") || v.starts_with("https://") || v.starts_with("data:") {
return false;
}
if v.starts_with("file://") {
return true;
}
let b = v.as_bytes();
// Windows drive-absolute (`C:\…`, `C:/…`) or UNC (`\\server\share`).
if (b.len() >= 3 && b[1] == b':' && (b[2] == b'\\' || b[2] == b'/')) || v.starts_with("\\\\") {
return true;
}
// POSIX absolute, minus the host's own `/`-leading shapes (see the doc comment).
v.starts_with('/') && !v.starts_with("//") && !v.starts_with("/api/")
}
/// Turn a `file://` art value into a plain filesystem path, percent-decoding it. The kit emits
/// properly encoded URLs (`file:///home/u/My%20Cover.jpg`); a raw path that happens to contain no
/// `%` round-trips either way, which keeps hand-written plugin payloads working.
///
/// `file:///home/u/c.jpg` → `/home/u/c.jpg`; `file:///C:/covers/c.jpg` → `C:/covers/c.jpg` (Windows
/// drive letters arrive after the empty authority's slash); a NON-empty authority
/// (`file://nas/share/c.jpg`) is a UNC reference → `\\nas\share\c.jpg`. Anything without the prefix
/// is returned untouched.
fn file_url_to_path(v: &str) -> std::borrow::Cow<'_, str> {
use std::borrow::Cow;
let Some(rest) = v.strip_prefix("file://") else {
return Cow::Borrowed(v);
};
let decoded = percent_decode(rest);
match decoded.strip_prefix('/') {
// `file:///…` — the empty-authority form. A Windows drive letter (`/C:/…`) loses the slash;
// a POSIX path keeps it.
Some(after) if after.as_bytes().get(1) == Some(&b':') => Cow::Owned(after.to_string()),
Some(_) => Cow::Owned(decoded),
// `file://server/share/…` — a UNC path in URL clothing.
None => Cow::Owned(format!("\\\\{}", decoded.replace('/', "\\"))),
}
}
/// Percent-decode `%XX` escapes. Invalid escapes are left verbatim (a bare `%` in a real path is far
/// likelier than a malformed URL from our own kit), and the result is only ever used as a path that
/// must then exist as a regular file — so a wrong decode degrades to "no art", never to a wrong read.
fn percent_decode(s: &str) -> String {
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
if b[i] == b'%' && i + 2 < b.len() {
let hex = |c: u8| (c as char).to_digit(16);
if let (Some(hi), Some(lo)) = (hex(b[i + 1]), hex(b[i + 2])) {
out.push((hi * 16 + lo) as u8);
i += 3;
continue;
}
}
out.push(b[i]);
i += 1;
}
String::from_utf8(out).unwrap_or_else(|_| s.to_string())
}
/// The filesystem roots the art proxy is allowed to read from.
///
/// The proxy runs in the **host process** — LocalSystem on Windows — and both the path and the
/// read-back are reachable from the plugin lane, which runs as the much weaker LocalService. Without
/// a root, "serve this entry's cover" is "read any file on the box as SYSTEM" (2026-08-05 review
/// H-2): `mgmt-token`, `key.pem`, the SAM hive. So the value is confined here, at the one place
/// bytes are read, rather than trusted because of where it was written.
///
/// Default: the users base (`C:\Users`), which is where every launcher keeps its art cache —
/// Playnite, the only local-art provider, stores covers under `%APPDATA%\Playnite`. Derived from
/// `%PUBLIC%`'s parent because the host runs as SYSTEM, whose own `%USERPROFILE%` is
/// `…\config\systemprofile` and tells us nothing about where the operator's launchers live.
/// `PUNKTFUNK_LIBRARY_ART_ROOTS` (`;`-separated) replaces the default for an operator whose library
/// is on another drive.
fn art_roots() -> Vec<PathBuf> {
if let Some(configured) = std::env::var_os("PUNKTFUNK_LIBRARY_ART_ROOTS") {
return std::env::split_paths(&configured)
.filter(|p| !p.as_os_str().is_empty())
.collect();
}
let mut roots = Vec::new();
// `%PUBLIC%` is `C:\Users\Public` on every supported Windows; its parent is the users base.
if let Some(public) = std::env::var_os("PUBLIC") {
if let Some(base) = PathBuf::from(public).parent() {
roots.push(base.to_path_buf());
}
}
if roots.is_empty() {
if let Some(drive) = std::env::var_os("SystemDrive") {
roots.push(PathBuf::from(drive).join("Users"));
}
}
// POSIX: the user's home, which is the exact analogue of the Windows users base above — and
// where every launcher this host reads art from actually keeps it. Steam's
// `appcache/librarycache` and `userdata/<id>/config/grid`, Lutris's `coverart`/`banners` (both
// the `~/.local/share` and `~/.cache` copies), Heroic's caches, and all three Flatpak
// `~/.var/app/…` variants are under it.
//
// Needed because `is_local_art_path` now classifies POSIX absolute paths as local art (the
// extracted Lutris/Steam plugins emit them). Before that widening this list was legitimately
// empty here: the only local-art provider was Playnite, which is Windows-only, so nothing on a
// POSIX host was ever classified local and the confinement had nothing to confine. Leaving it
// empty now would not be "secure by default" — it would silently serve no plugin art at all.
//
// Breadth matches what Windows already ships, and it is not the load-bearing control: a value
// still has to carry an image extension, canonicalize to a real regular file inside a root,
// sit outside the host config dir, and CONTAIN image bytes. `PUNKTFUNK_LIBRARY_ART_ROOTS`
// narrows or relocates this for a library that lives elsewhere.
#[cfg(not(windows))]
if let Some(home) = std::env::var_os("HOME") {
let home = PathBuf::from(home);
if !home.as_os_str().is_empty() {
roots.push(home);
}
}
roots
}
/// Whether `path` resolves inside one of [`art_roots`] and outside the host config dir.
///
/// Canonicalizes first, so a junction/symlink pointing out of the root is resolved before the
/// containment test rather than after it. The config-dir exclusion is unconditional — it holds even
/// if an operator's `PUNKTFUNK_LIBRARY_ART_ROOTS` were to contain it — because that directory is
/// where every host secret lives.
fn art_path_is_confined(path: &Path) -> bool {
// A UNC value (`\\attacker\share\a.png`) is refused outright: reading it would coerce the host's
// machine account into outbound SMB authentication to a peer of the caller's choosing.
if path.to_string_lossy().starts_with(r"\\") {
return false;
}
let Ok(real) = path.canonicalize() else {
return false;
};
if let Ok(config) = pf_paths::config_dir().canonicalize() {
if real.starts_with(&config) {
return false;
}
}
art_roots()
.iter()
.filter_map(|r| r.canonicalize().ok())
.any(|root| real.starts_with(&root))
}
/// Sniff an image container from its leading bytes → the content type to serve. `None` for anything
/// that is not a recognized image.
///
/// The proxy serves what the bytes ARE, not what the extension claims, and refuses to serve at all
/// when they are not an image — which is what keeps an extensionless secret like `mgmt-token` (or a
/// `key.pem` renamed `cover.png`) from being returned as `application/octet-stream`.
fn sniff_image_type(bytes: &[u8]) -> Option<&'static str> {
let starts = |sig: &[u8]| bytes.starts_with(sig);
if starts(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) {
return Some("image/png");
}
if starts(&[0xFF, 0xD8, 0xFF]) {
return Some("image/jpeg");
}
if starts(b"GIF87a") || starts(b"GIF89a") {
return Some("image/gif");
}
if starts(b"RIFF") && bytes.len() >= 12 && &bytes[8..12] == b"WEBP" {
return Some("image/webp");
}
if starts(b"BM") {
return Some("image/bmp");
}
if starts(&[0x00, 0x00, 0x01, 0x00]) {
return Some("image/x-icon");
}
// TGA has no magic number. Validate the fixed header fields instead (colour-map type is 0/1,
// image type is one of the six defined codes) — enough that no plausible secret passes.
if bytes.len() >= 18
&& matches!(bytes[1], 0 | 1)
&& matches!(bytes[2], 0 | 1 | 2 | 3 | 9 | 10 | 11)
{
return Some("image/x-tga");
}
None
}
/// Whether a local art path is servable at all: known image extension, inside an allowed root. The
/// write-time half of the art confinement — [`validate_art_paths`] refuses to persist a value this
/// rejects, so an out-of-root path never reaches the catalog in the first place, and
/// [`local_art_bytes`] re-checks at read time so an entry written before this existed is still safe.
pub fn art_path_is_servable(value: &str) -> bool {
let p = Path::new(value);
let ext_ok = p
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())
.is_some_and(|e| {
matches!(
e.as_str(),
"jpg" | "jpeg" | "png" | "webp" | "gif" | "bmp" | "ico" | "tga"
)
});
ext_ok && art_path_is_confined(p)
}
/// Reject any **local-file** art value that the proxy would refuse to serve, so an unservable path
/// (out of root, not an image, a UNC share) can never be persisted. URLs and already-proxied paths
/// are not this function's business and pass through. `Err` carries the offending field name.
pub fn validate_art_paths(art: &Artwork) -> Result<(), String> {
for (field, value) in [
("portrait", &art.portrait),
("hero", &art.hero),
("logo", &art.logo),
("header", &art.header),
] {
let Some(v) = value.as_deref() else { continue };
if is_local_art_path(v) && !art_path_is_servable(v) {
return Err(format!(
"art.{field}: local art must be an image file (jpg/png/webp/gif/bmp/ico/tga) inside \
an allowed art root set PUNKTFUNK_LIBRARY_ART_ROOTS if the library lives \
elsewhere, or send an http(s) URL instead"
));
}
}
Ok(())
(b.len() >= 3 && b[1] == b':' && (b[2] == b'\\' || b[2] == b'/')) || v.starts_with("\\\\")
}
/// Read a local image file into `(bytes, content-type)` for the art proxy. `None` if it isn't an
/// existing regular file, is empty, exceeds 16 MiB (a cover never approaches that; the cap bounds
/// host memory), resolves outside the allowed art roots ([`art_path_is_confined`]), or does not
/// actually contain an image ([`sniff_image_type`]).
///
/// This is the single place local art bytes are read — the mgmt art proxy and the GameStream
/// `/appasset` proxy both land here — so the confinement holds for every caller.
///
/// A `file://` value is converted to a path FIRST ([`file_url_to_path`]), so the confinement check
/// and the read see the same decoded path. Ordering matters: percent-decoding before
/// canonicalization is what stops a `%2e%2e` escape being invisible to the traversal check.
/// existing regular file, is empty, or exceeds 16 MiB (a cover never approaches that; the cap bounds
/// host memory). Content-type is guessed from the extension.
pub fn local_art_bytes(path: &str) -> Option<(Vec<u8>, String)> {
let path = file_url_to_path(path);
if !art_path_is_servable(&path) {
tracing::debug!(
path = %path,
"art proxy: refusing a path outside the allowed art roots"
);
return None;
}
let p = std::path::Path::new(&*path);
let p = std::path::Path::new(path);
let meta = std::fs::metadata(p).ok()?;
if !meta.is_file() || meta.len() == 0 || meta.len() > 16 * 1024 * 1024 {
return None;
}
let bytes = std::fs::read(p).ok()?;
// Serve what the bytes ARE. A file that is not an image is not served at all.
let ctype = sniff_image_type(&bytes)?;
Some((bytes, ctype.to_string()))
let ctype = match p
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())
.as_deref()
{
Some("jpg" | "jpeg") => "image/jpeg",
Some("png") => "image/png",
Some("webp") => "image/webp",
Some("gif") => "image/gif",
Some("bmp") => "image/bmp",
Some("ico") => "image/x-icon",
Some("tga") => "image/x-tga",
_ => "application/octet-stream",
}
.to_string();
Some((std::fs::read(p).ok()?, ctype))
}
/// Resolve one art value to bytes for the Moonlight `/appasset` proxy: a local host file
@@ -451,22 +221,9 @@ pub fn proxy_local_art(id: &str, art: &mut Artwork) {
/// `(bytes, content-type)`. Resolves the id against the host's OWN library. Blocking — call off the
/// async runtime (e.g. `spawn_blocking`).
pub fn fetch_box_art(id: &str) -> Option<(Vec<u8>, String)> {
// Same resolution order as the management art proxy (WP1.2): the stored catalog first, for ANY
// id, so a library plugin's entries resolve without the warmer knowing its store.
if let Some(entry) = entry_for_library_id(id) {
return [
ArtKind::Portrait,
ArtKind::Header,
ArtKind::Hero,
ArtKind::Logo,
]
.into_iter()
.filter_map(|kind| art_field(&entry.art, kind))
.find_map(|v| resolve_art_bytes(&v));
}
// Legacy in-host Steam scanner: its `Artwork` fields are relative proxy paths (see `steam_art`)
// the *client* resolves against the host — meaningless to `fetch_image`, which expects an
// absolute URL. Resolve those kinds directly instead of going through the URL fields.
// Steam's `Artwork` fields are now relative proxy paths (see `steam_art`) the *client* resolves
// against the host — meaningless to `fetch_image`, which expects an absolute URL. Resolve
// those kinds directly instead of going through the URL fields.
if let Some(appid) = id
.strip_prefix("steam:")
.and_then(|s| s.parse::<u32>().ok())
@@ -480,7 +237,6 @@ pub fn fetch_box_art(id: &str) -> Option<(Vec<u8>, String)> {
.into_iter()
.find_map(|kind| steam_art_bytes(appid, kind));
}
// The remaining in-host scanners (heroic/lutris/epic/gog/xbox) carry absolute CDN URLs.
let g = all_games().into_iter().find(|g| g.id == id)?;
[g.art.portrait, g.art.header, g.art.hero, g.art.logo]
.into_iter()
@@ -579,60 +335,19 @@ mod tests {
assert!(fetch_image("data:image/png;base64,").is_none());
}
/// The full accept/exclude table (WP1.2). The exclusions are the load-bearing half: two of the
/// three `/`-leading shapes here are emitted by the host ITSELF, so a POSIX rule that swallowed
/// them would break the proxy round-trip and silently drop CDN art.
#[test]
fn local_art_path_detection() {
// Windows-shaped local paths a provider (Playnite) would store.
assert!(is_local_art_path(r"C:\Users\me\cover.jpg"));
assert!(is_local_art_path("C:/Users/me/cover.png"));
assert!(is_local_art_path(r"\\nas\share\art.jpg"));
// The `file://` plugin contract, on both platform shapes.
assert!(is_local_art_path("file:///home/u/covers/x.jpg"));
assert!(is_local_art_path("file:///C:/covers/x.jpg"));
// POSIX absolute — lutris covers, steam librarycache.
assert!(is_local_art_path("/home/u/.cache/lutris/coverart/x.jpg"));
assert!(is_local_art_path("/var/lib/steam/librarycache/570/h.jpg"));
// URLs are NOT local files.
// URLs and the host proxy path are NOT local files.
assert!(!is_local_art_path("https://cdn/x.jpg"));
assert!(!is_local_art_path("http://host/x.jpg"));
assert!(!is_local_art_path("data:image/png;base64,AAAA"));
// …nor is the host's OWN art-proxy path (it must survive a second `proxy_local_art` pass).
assert!(!is_local_art_path(
"/api/v1/library/art/custom:abc/portrait"
));
assert!(!is_local_art_path("/api/v1/library/art/steam:570/hero"));
// …nor a protocol-relative CDN URL (what GOG / the MS catalog return — see `abs_url`).
assert!(!is_local_art_path("//images.gog.com/abc_vertical.jpg"));
// A relative path is not absolute — nothing to serve.
assert!(!is_local_art_path("covers/x.jpg"));
assert!(!is_local_art_path(""));
}
#[test]
fn file_url_converts_to_a_path_and_percent_decodes() {
assert_eq!(file_url_to_path("file:///home/u/c.jpg"), "/home/u/c.jpg");
// Percent-encoded spaces — what a correct URL encoder emits for a real-world cover path.
assert_eq!(
file_url_to_path("file:///home/u/My%20Games/c%2Bx.jpg"),
"/home/u/My Games/c+x.jpg"
);
// Windows drive letters arrive after the empty authority's slash and lose it.
assert_eq!(
file_url_to_path("file:///C:/covers/c.jpg"),
"C:/covers/c.jpg"
);
// A non-empty authority is a UNC reference.
assert_eq!(
file_url_to_path("file://nas/share/c.jpg"),
r"\\nas\share\c.jpg"
);
// Non-`file://` values are returned untouched (bare paths still work).
assert_eq!(file_url_to_path("/home/u/c.jpg"), "/home/u/c.jpg");
assert_eq!(file_url_to_path(r"C:\c.jpg"), r"C:\c.jpg");
// A lone `%` (a legal path character) is not mangled into a decode failure.
assert_eq!(file_url_to_path("file:///home/100%.jpg"), "/home/100%.jpg");
}
#[test]
@@ -656,187 +371,16 @@ mod tests {
);
}
/// A POSIX local cover — the shape the lutris and steam plugins emit — is classified as local
/// art and rewritten to the proxy path. This is the case G4 blocked (Lutris art was inlined as
/// `data:` URLs and blew the 2 MB body limit at 49 covers).
///
/// Deliberately free of filesystem and env: the READ half is confined, and lives in
/// `local_art_bytes_is_confined_and_image_only` so that only ONE test mutates
/// `PUNKTFUNK_LIBRARY_ART_ROOTS` (cargo runs these in parallel threads of one process, so two
/// would race).
#[test]
fn posix_local_art_is_classified_and_proxied() {
let path = if cfg!(windows) {
r"C:\covers\cover.jpg".to_string()
} else {
"/home/u/.cache/lutris/coverart/cover.jpg".to_string()
};
let mut art = Artwork {
portrait: Some(path.clone()),
hero: Some(format!("file://{path}")),
logo: Some("https://cdn/l.png".into()),
header: None,
};
assert!(is_local_art_path(&path));
proxy_local_art("lutris:42", &mut art);
assert_eq!(
art.portrait.as_deref(),
Some("/api/v1/library/art/lutris:42/portrait")
);
assert_eq!(
art.hero.as_deref(),
Some("/api/v1/library/art/lutris:42/hero"),
"a file:// value is local art too"
);
assert_eq!(art.logo.as_deref(), Some("https://cdn/l.png"));
// Re-running the rewrite is a no-op — the emitted proxy path must not be mistaken for a file.
let before = art.portrait.clone();
proxy_local_art("lutris:42", &mut art);
assert_eq!(art.portrait, before);
}
const PNG: &[u8] = &[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 13];
/// The art proxy reads bytes in the HOST process (LocalSystem on Windows) from a path the
/// plugin lane can write — so what it will and will not read IS the security boundary
/// (2026-08-05 review H-2). Confinement, extension, and content are all load-bearing.
#[test]
fn local_art_bytes_is_confined_and_image_only() {
fn local_art_bytes_reads_a_real_file() {
let dir = std::env::temp_dir().join(format!("pf-art-test-{}", std::process::id()));
let outside = std::env::temp_dir().join(format!("pf-art-out-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::create_dir_all(&outside).unwrap();
// Confine the proxy to `dir` for the duration of this test.
std::env::set_var("PUNKTFUNK_LIBRARY_ART_ROOTS", &dir);
// A real image inside the root: served, with the content type SNIFFED from the bytes.
let cover = dir.join("cover.png");
std::fs::write(&cover, PNG).unwrap();
let (bytes, ctype) = local_art_bytes(cover.to_str().unwrap()).expect("reads a real cover");
assert_eq!(bytes, PNG);
let f = dir.join("cover.png");
std::fs::write(&f, [1u8, 2, 3, 4]).unwrap();
let (bytes, ctype) = local_art_bytes(f.to_str().unwrap()).expect("reads file");
assert_eq!(bytes, vec![1, 2, 3, 4]);
assert_eq!(ctype, "image/png");
// A secret is not served, however it is dressed up. This is the H-2 primitive: the plugin
// writes the path, the host reads it as SYSTEM, and `mgmt-token` is full admin.
let secret = dir.join("mgmt-token");
std::fs::write(&secret, b"super-secret-admin-token").unwrap();
assert!(
local_art_bytes(secret.to_str().unwrap()).is_none(),
"an extensionless secret must not be served as application/octet-stream"
);
let disguised = dir.join("mgmt-token.png");
std::fs::write(&disguised, b"super-secret-admin-token").unwrap();
assert!(
local_art_bytes(disguised.to_str().unwrap()).is_none(),
"an image extension must not be enough — the bytes must BE an image"
);
// Outside the configured root: refused even though it is a genuine image.
let elsewhere = outside.join("cover.png");
std::fs::write(&elsewhere, PNG).unwrap();
assert!(
local_art_bytes(elsewhere.to_str().unwrap()).is_none(),
"a path outside every art root must be refused"
);
// …and a path that only *escapes* via traversal is caught, because we canonicalize first.
let traversal = dir
.join("..")
.join(outside.file_name().unwrap())
.join("cover.png");
assert!(
local_art_bytes(traversal.to_str().unwrap()).is_none(),
"`..` out of the root must be refused after canonicalization"
);
assert!(local_art_bytes(dir.join("nope.png").to_str().unwrap()).is_none());
// A directory is not a servable cover — the proxy must never become a directory reader.
assert!(local_art_bytes(dir.to_str().unwrap()).is_none());
// The `file://` plugin contract reaches the SAME bytes through the SAME gate. This is the
// half that matters for the extracted scanners: they emit `file://` values, so if the
// conversion happened after the confinement check the check would be inspecting a string
// that is not the path being read.
let as_url = format!("file://{}", cover.to_str().unwrap());
assert_eq!(
local_art_bytes(&as_url)
.expect("file:// reads the same cover")
.0,
PNG
);
// …and a `file://` value is confined exactly like a bare one — no bypass by spelling.
assert!(
local_art_bytes(&format!("file://{}", elsewhere.to_str().unwrap())).is_none(),
"file:// must not escape the art roots"
);
// Percent-encoded traversal is decoded BEFORE canonicalization, so it cannot hide from the
// `..` check.
assert!(
local_art_bytes(&format!(
"file://{}/%2e%2e/{}/cover.png",
dir.to_str().unwrap(),
outside.file_name().unwrap().to_str().unwrap()
))
.is_none(),
"percent-encoded traversal must be refused"
);
// A UNC path is refused outright (outbound SMB auth coercion), before any filesystem hit.
assert!(!art_path_is_servable(r"\\attacker\share\a.png"));
std::env::remove_var("PUNKTFUNK_LIBRARY_ART_ROOTS");
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&outside);
}
/// Write-time validation refuses what read-time would refuse, so an unservable path never even
/// reaches `library.json`. URLs are none of its business.
#[test]
fn validate_art_paths_rejects_unservable_local_paths() {
let ok = Artwork {
portrait: Some("https://cdn/x.jpg".into()),
hero: Some("data:image/png;base64,AAAA".into()),
logo: Some("/api/v1/library/art/custom:x/logo".into()),
header: None,
};
assert!(validate_art_paths(&ok).is_ok(), "URLs pass through");
let unc = Artwork {
portrait: Some(r"\\attacker\share\a.png".into()),
..Default::default()
};
assert!(
validate_art_paths(&unc).is_err(),
"UNC is refused at write time"
);
let secret = Artwork {
hero: Some(r"C:\ProgramData\punktfunk\mgmt-token".into()),
..Default::default()
};
let err = validate_art_paths(&secret).expect_err("a secret path is refused");
assert!(
err.starts_with("art.hero"),
"the error names the field: {err}"
);
}
#[test]
fn sniff_image_type_recognizes_containers_and_rejects_secrets() {
assert_eq!(sniff_image_type(PNG), Some("image/png"));
assert_eq!(
sniff_image_type(&[0xFF, 0xD8, 0xFF, 0xE0]),
Some("image/jpeg")
);
assert_eq!(sniff_image_type(b"GIF89a...."), Some("image/gif"));
assert_eq!(
sniff_image_type(b"RIFF\0\0\0\0WEBPVP8 "),
Some("image/webp")
);
assert_eq!(sniff_image_type(b"BM\0\0"), Some("image/bmp"));
// The shapes a stolen secret actually has.
assert_eq!(sniff_image_type(b"-----BEGIN PRIVATE KEY-----"), None);
assert_eq!(sniff_image_type(b"9f8a7b6c5d4e3f2a1b0c"), None);
assert_eq!(sniff_image_type(b""), None);
}
}

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