Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
deef5e4382 | ||
|
|
4b514cc07c | ||
|
|
27ceab2f6c | ||
|
|
30bd10e301 | ||
|
|
1df39d9617 | ||
|
|
e4f8c64b9f | ||
|
|
690ff7016b | ||
|
|
6cffe29b13 | ||
|
|
44c87d7ac1 | ||
|
|
9089651406 | ||
|
|
2a2427afc8 | ||
|
|
d237646c66 | ||
|
|
69728b6f4e | ||
|
|
3bb87d260e | ||
|
|
be57587572 | ||
|
|
8f1c34c6bf | ||
|
|
1ef212a78d | ||
|
|
e044f68500 | ||
|
|
8c94e2517e |
+181
-8
@@ -48,7 +48,26 @@ on:
|
||||
# `punktfunk-canary` pacman repo as X.Y.Z-0.<run#> (sorts below the eventual X.Y.Z-1),
|
||||
# tags to `punktfunk` — separate repos, so neither channel can shadow the other.
|
||||
tags: ['v*']
|
||||
# REBUILDING A PUBLISHED RELEASE, because on a rolling distro the ground moves under one.
|
||||
# Arch went FFmpeg 8 -> 9 (every libav soname +1) four minutes before v0.25.0 was tagged, so
|
||||
# the release's punktfunk-host was linked in a builder image that still had 8 and shipped
|
||||
# `libavcodec.so=62-64`. No up-to-date Arch box can satisfy that — and pacman prepares the
|
||||
# whole transaction at once, so it did not merely block our package, it blocked those users'
|
||||
# entire `pacman -Syu`. The repair is a rebuild of the SAME upstream version at a HIGHER
|
||||
# pkgrel; nothing else reaches a box that already has the broken build recorded in its db.
|
||||
# The workflow file at the tag can never carry inputs added after it was tagged, so dispatch
|
||||
# this from `main`: it checks the tag's SOURCE out, publishes to the STABLE repo, and
|
||||
# replaces the release-page assets. Same lever for any future "the distro moved" rebuild.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: 'Rebuild this published release (e.g. v0.25.0) into the stable `punktfunk` repo. Empty = ordinary canary build of the dispatched ref.'
|
||||
required: false
|
||||
default: ''
|
||||
pkgrel:
|
||||
description: 'pkgrel for that rebuild — MUST be above the published one (2, 3, …); a same-pkgrel republish is invisible to pacman. Ignored without release_tag.'
|
||||
required: false
|
||||
default: '2'
|
||||
|
||||
env:
|
||||
REGISTRY: git.unom.io
|
||||
@@ -94,7 +113,52 @@ jobs:
|
||||
}
|
||||
bun --version
|
||||
|
||||
# THE BUILDER'S FFmpeg IS PART OF THE PACKAGE CONTRACT, not merely a build detail.
|
||||
# packaging/arch/PKGBUILD binds punktfunk-host to the exact libav sonames it linked
|
||||
# (`libavcodec.so=63-64` …), so a builder one FFmpeg major behind Arch emits a package
|
||||
# that NOBODY can install — and takes the user's whole `pacman -Syu` down with it, since
|
||||
# pacman prepares the transaction as a unit. That is exactly how v0.25.0 shipped: PR #108
|
||||
# re-keyed this image for FFmpeg 9, the release tag fired four minutes later, and the job
|
||||
# still got the FFmpeg-8 `:latest`. The image is a cache and is allowed to lag — but never
|
||||
# on this one axis. So heal it in-job and shout, instead of building a dead package.
|
||||
# (Runs BEFORE checkout: a stale image should be repaired before anything depends on it.)
|
||||
- name: FFmpeg soname parity with today's Arch (heals a stale builder image)
|
||||
run: |
|
||||
export LC_ALL=C # `Provides` is a localized field name
|
||||
# Piped (never a TTY here) pacman prints each field on ONE line, unwrapped.
|
||||
sonames() { sed -n 's/^Provides *: *//p' | tr ' ' '\n' | grep -E '^lib(av|sw)[a-z]*\.so=' | sort | tr '\n' ' '; }
|
||||
# A SEPARATE --dbpath: this refreshes only a throwaway view of the repos, so the
|
||||
# container's own db never enters the partial-upgrade state a bare `pacman -Sy` leaves.
|
||||
mkdir -p /tmp/pf-archsync
|
||||
if ! pacman -Sy --dbpath /tmp/pf-archsync --logfile /dev/null >/dev/null 2>&1; then
|
||||
echo "::warning::could not refresh the Arch db — skipping the FFmpeg parity check"
|
||||
exit 0
|
||||
fi
|
||||
HAVE="$(pacman -Qi ffmpeg | sonames)"
|
||||
WANT="$(pacman -Si --dbpath /tmp/pf-archsync ffmpeg | sonames)"
|
||||
echo "builder ffmpeg $(pacman -Q ffmpeg | cut -d' ' -f2): $HAVE"
|
||||
echo "arch ffmpeg $(pacman -Si --dbpath /tmp/pf-archsync ffmpeg | sed -n 's/^Version *: *//p'): $WANT"
|
||||
if [ "$HAVE" = "$WANT" ]; then
|
||||
echo "OK: the builder links the FFmpeg every up-to-date Arch box already has"
|
||||
exit 0
|
||||
fi
|
||||
echo "::warning::arch-ci is stale ACROSS AN FFMPEG SONAME BUMP — upgrading it for this run."
|
||||
echo "::warning::Bump the 'refreshed:' date in ci/arch-ci.Dockerfile so the IMAGE carries it."
|
||||
pacman -Syu --noconfirm || true
|
||||
HAVE="$(pacman -Qi ffmpeg | sonames)"
|
||||
if [ "$HAVE" != "$WANT" ]; then
|
||||
echo "::error::builder still links $HAVE while Arch ships $WANT."
|
||||
echo "::error::Building on would publish a package no Arch box can install."
|
||||
exit 1
|
||||
fi
|
||||
echo "healed: builder now links $HAVE"
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# A dispatched release rebuild takes its WORKFLOW from the ref you dispatch (the only
|
||||
# way it can carry inputs the tag predates) and its SOURCE from the tag. Empty string
|
||||
# = checkout's own default, i.e. the triggering ref, for every other trigger.
|
||||
ref: ${{ github.event.inputs.release_tag }}
|
||||
|
||||
# Cache cargo's git dir too, not just the registry: the workspace includes
|
||||
# clients/windows, whose windows-reactor/windows deps are git-pinned — cargo must CLONE
|
||||
@@ -127,12 +191,30 @@ jobs:
|
||||
# Keep the leading `0.` — it is what sorts a canary BELOW the eventual `X.Y.Z-1` stable
|
||||
# release. (A pkgrel is digits+dots only, so `0.` is the only prefix available; raising
|
||||
# it to `1.` would sort canaries ABOVE the release and is not an option.)
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.event.inputs.release_tag }}
|
||||
REBUILD_PKGREL: ${{ github.event.inputs.pkgrel }}
|
||||
run: |
|
||||
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of latest stable)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; R="1"; REPO=punktfunk ;;
|
||||
*) V="$PF_BASE"; R="0.$(printf '%08d' "$GITHUB_RUN_NUMBER")"; REPO=punktfunk-canary ;;
|
||||
esac
|
||||
if [ -n "${RELEASE_TAG:-}" ]; then
|
||||
# Dispatched rebuild of a published release (see the workflow_dispatch note at the
|
||||
# top): same upstream version, higher pkgrel, straight into the stable repo.
|
||||
# ⚠ Keep that pkgrel SINGLE-DIGIT. Gitea's Arch registry picks the version its .db
|
||||
# advertises by STRING order (the same trap the canary zero-padding below exists for),
|
||||
# so "0.25.0-10" sorts BELOW "0.25.0-2" and the rebuild would never be advertised.
|
||||
V="${RELEASE_TAG#v}"
|
||||
R="${REBUILD_PKGREL:-2}"
|
||||
REPO=punktfunk
|
||||
case "$R" in
|
||||
''|*[!0-9.]*) echo "::error::pkgrel '$R' is not digits+dots"; exit 1 ;;
|
||||
1) echo "::error::pkgrel 1 is the published build — a rebuild MUST go up (2, 3, …)"; exit 1 ;;
|
||||
esac
|
||||
else
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; R="1"; REPO=punktfunk ;;
|
||||
*) V="$PF_BASE"; R="0.$(printf '%08d' "$GITHUB_RUN_NUMBER")"; REPO=punktfunk-canary ;;
|
||||
esac
|
||||
fi
|
||||
echo "PF_PKGVER=$V" >> "$GITHUB_ENV"
|
||||
echo "PF_PKGREL=$R" >> "$GITHUB_ENV"
|
||||
echo "REPO=$REPO" >> "$GITHUB_ENV"
|
||||
@@ -235,6 +317,63 @@ jobs:
|
||||
rm -rf dist-gamescope # never cache a failed build (an empty path is not saved)
|
||||
fi
|
||||
|
||||
# THE GATE THIS PIPELINE WAS MISSING. The soname assert above proves the libav dep is
|
||||
# VERSIONED; it cannot prove the version is one that EXISTS. v0.25.0 passed it and still
|
||||
# shipped `libavcodec.so=62-64` to a world that had moved to 63 — every affected user got
|
||||
# "unable to satisfy dependency … required by punktfunk-host", and because pacman prepares
|
||||
# one transaction, their whole system upgrade stopped there. So ask the only question that
|
||||
# matters before publishing: would a real, up-to-date Arch box install this?
|
||||
#
|
||||
# An empty --dbpath is what makes the answer honest. It means "nothing is installed", so
|
||||
# pacman must satisfy every dependency FROM THE REPOS exactly as a user's box does. Checking
|
||||
# against the builder's own installed set instead would let a stale ffmpeg satisfy the stale
|
||||
# bound and hide the break completely — the very illusion that shipped v0.25.0. `--print`
|
||||
# resolves and prints; it downloads nothing and installs nothing. Verified against the real
|
||||
# broken artifact on an ffmpeg-9 box: it reproduces the user-visible failure verbatim.
|
||||
- name: Assert every package installs on an up-to-date Arch box
|
||||
run: |
|
||||
export LC_ALL=C
|
||||
mkdir -p /tmp/pf-instcheck
|
||||
if ! pacman -Sy --dbpath /tmp/pf-instcheck --logfile /dev/null >/dev/null 2>&1; then
|
||||
echo "::error::could not sync the Arch db — cannot prove these packages install"
|
||||
exit 1
|
||||
fi
|
||||
check() { # check FILE -> 0 installable, 1 not (reason on stdout)
|
||||
pacman -U --print --noconfirm --dbpath /tmp/pf-instcheck --logfile /dev/null "$1" 2>&1
|
||||
}
|
||||
ls dist/*.pkg.tar.zst >/dev/null 2>&1 || { echo "::error::nothing in dist/ to check"; exit 1; }
|
||||
rc=0
|
||||
for pkg in dist/*.pkg.tar.zst; do
|
||||
if out="$(check "$pkg")"; then
|
||||
echo "OK $(basename "$pkg") ($(echo "$out" | wc -l) targets resolve)"
|
||||
else
|
||||
rc=1
|
||||
echo "::error::$(basename "$pkg") CANNOT be installed on an up-to-date Arch box:"
|
||||
echo "$out" | sed 's/^/ /'
|
||||
fi
|
||||
done
|
||||
# gamescope stays best-effort, exactly as its build step is: a companion that cannot
|
||||
# install is dropped from the upload with a warning, never a reason to withhold the
|
||||
# packages this workflow exists to publish. (It is also the one package that can be
|
||||
# restored from a cache older than the current Arch snapshot.)
|
||||
for pkg in dist-gamescope/*.pkg.tar.zst; do
|
||||
[ -e "$pkg" ] || continue
|
||||
if out="$(check "$pkg")"; then
|
||||
echo "OK $(basename "$pkg") ($(echo "$out" | wc -l) targets resolve)"
|
||||
else
|
||||
echo "::warning::$(basename "$pkg") is not installable on current Arch — NOT publishing it"
|
||||
echo "$out" | sed 's/^/ /'
|
||||
rm -f "$pkg"
|
||||
fi
|
||||
done
|
||||
if [ "$rc" != 0 ]; then
|
||||
echo "::error::refusing to publish: pacman would reject this on a current box, and a"
|
||||
echo "::error::rejected dependency blocks the user's ENTIRE upgrade, not just punktfunk."
|
||||
echo "::error::Usual cause: the arch-ci builder image lags Arch across a soname bump —"
|
||||
echo "::error::bump 'refreshed:' in ci/arch-ci.Dockerfile, let docker.yml republish it, re-run."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# NOTE deliberately NO sysext image is built or published here: a prebuilt HOST binary on
|
||||
# SteamOS breaks on the next A/B soname bump (and /var — where sysexts live — is
|
||||
# per-partition-set), which is the standing packaging verdict behind the on-device
|
||||
@@ -262,14 +401,48 @@ jobs:
|
||||
done
|
||||
echo "published to $OWNER/arch/$REPO"
|
||||
|
||||
# On a real release, also attach the packages to the unified Gitea Release.
|
||||
- name: Attach packages to the Gitea release (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
# On a real release, also attach the packages to the unified Gitea Release. A dispatched
|
||||
# rebuild attaches to that SAME release object: the release page is a distribution surface
|
||||
# too, and leaving the superseded .pkg.tar.zst sitting on it is one click away from handing
|
||||
# someone the exact break the rebuild exists to fix.
|
||||
- name: Attach packages to the Gitea release (stable tags + release rebuilds)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v') || github.event.inputs.release_tag != ''
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
RELEASE_TAG: ${{ github.event.inputs.release_tag }}
|
||||
run: |
|
||||
. scripts/ci/gitea-release.sh
|
||||
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
|
||||
TAG="${RELEASE_TAG:-$GITHUB_REF_NAME}"
|
||||
RID=$(ensure_release "$TAG" "$TAG" auto)
|
||||
for pkg in dist/*.pkg.tar.zst; do
|
||||
upsert_asset "$RID" "$pkg"
|
||||
done
|
||||
# A rebuild bumps pkgrel, so its FILENAMES differ from the ones already attached, and
|
||||
# upsert_asset only replaces by name — the superseded set would survive untouched.
|
||||
# Drop every pacman asset (and .sha256 sidecar) this upload did not just write.
|
||||
#
|
||||
# ⚠⚠ THIS MUST LIVE IN THE WORKFLOW, NOT IN scripts/ci/gitea-release.sh. The sourced
|
||||
# script comes from the CHECKED-OUT TREE, which on a release rebuild is the OLD TAG —
|
||||
# so it can only ever offer the helpers that existed when that tag was cut. A helper
|
||||
# added for this feature is therefore guaranteed ABSENT in the one code path that
|
||||
# calls it: the first attempt failed with `prune_release_assets: command not found`
|
||||
# after publishing perfectly. Only the workflow file itself is taken from the ref you
|
||||
# dispatch. Same reason a packaging fix made after a tag does NOT reach a rebuild of
|
||||
# that tag — the PKGBUILD is the tag's too.
|
||||
if [ -n "${RELEASE_TAG:-}" ]; then
|
||||
KEEP="$(cd dist && printf '%s ' *.pkg.tar.zst)"
|
||||
# An UNMATCHED glob would come through literally and match nothing in the keep set —
|
||||
# i.e. "delete every pacman asset on the release". Skip entirely instead.
|
||||
case "$KEEP" in *'*'*) KEEP="" ;; esac
|
||||
API="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY"
|
||||
if [ -n "$KEEP" ]; then
|
||||
curl -fsS "$API/releases/$RID/assets" -H "Authorization: token $GITEA_TOKEN" \
|
||||
| python3 -c "import json,sys;k=set(sys.argv[1].split());k|={n+'.sha256' for n in k};print('\n'.join('%s %s'%(a['id'],a['name']) for a in json.load(sys.stdin) if a.get('name','').endswith(('.pkg.tar.zst','.pkg.tar.zst.sha256')) and a['name'] not in k))" "$KEEP" \
|
||||
| while read -r id name; do
|
||||
[ -n "$id" ] || continue
|
||||
echo "dropping superseded release asset: $name"
|
||||
curl -fsS -o /dev/null -X DELETE "$API/releases/$RID/assets/$id" \
|
||||
-H "Authorization: token $GITEA_TOKEN" || true
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -437,6 +437,24 @@ refuses the upgrade instead of bricking the install. All seven libs are listed e
|
||||
`--as-needed` currently drops two: an unlinked soname is left bare by makepkg and satisfied by any
|
||||
ffmpeg, so listing it costs nothing and a future link picks up the bound automatically.
|
||||
|
||||
🛑 **The v0.25.0 Arch packages shipped with that bound pointing at the WRONG FFmpeg — install
|
||||
`punktfunk-host 0.25.0-2` or newer.** The soname fix and the FFmpeg-9 build landed as one merge;
|
||||
the release tag was pushed four minutes later, while the CI builder image was still being
|
||||
rebuilt. arch.yml deliberately runs no `-Syu` ("the image's snapshot IS the build environment"),
|
||||
so the release was linked against FFmpeg 8 and published `libavcodec.so=62-64` — a bound no
|
||||
up-to-date Arch box can satisfy. It fails *safely* (pacman refuses; nothing bricks), but it fails
|
||||
**loudly and broadly**: pacman prepares one transaction, so an unsatisfiable dependency of ours
|
||||
stopped affected users' entire `pacman -Syu`. `0.25.0-2` is the identical source rebuilt against
|
||||
FFmpeg 9. Only Arch was exposed — every other format derives its dependency from the ELF at build
|
||||
time and could not disagree with itself this way.
|
||||
|
||||
Two guards now stand where only a convention did. arch.yml compares the builder's libav
|
||||
`provides` against the live repos before building and `-Syu`s itself if they differ; and no
|
||||
package is published until a **pristine-`--dbpath`** `pacman -U --print` resolves it, which asks
|
||||
"would a real, up-to-date Arch box install this?" instead of "does the builder happen to satisfy
|
||||
it?" — the distinction that let this ship. Keeping `ci/arch-ci.Dockerfile` current is still the
|
||||
cheap path; the guards are the backstop.
|
||||
|
||||
### Linux playback filled the buffer ceiling
|
||||
|
||||
The PipeWire playback callback sized its writes from the mapped buffer's **capacity** — PipeWire's
|
||||
|
||||
+125
-4
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.24.0"
|
||||
"version": "0.25.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -997,7 +997,7 @@
|
||||
"library"
|
||||
],
|
||||
"summary": "List the game library",
|
||||
"description": "Every installed-store title (Steam, read from the host's local files — no Steam API key)\nmerged with the user's custom entries, sorted by title. Artwork fields are URLs the client\nfetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the\nentries a given external provider owns; `?platform=` to one platform (case-insensitive —\ninstalled-store titles are `PC`, custom/provider entries carry whatever was authored).",
|
||||
"description": "Every installed-store title (Steam, read from the host's local files — no Steam API key)\nmerged with the user's custom entries, sorted by title. Artwork fields are URLs the client\nfetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the\nentries a given external provider owns; `?platform=` to one platform (case-insensitive —\ninstalled-store titles are `PC`, custom/provider entries carry whatever was authored).\n\n**The operator's own lane additionally sees the titles they have HIDDEN**, each carrying\n`hidden: true`; every other lane gets them filtered out upstream and cannot tell they exist. The\nconsole needs them to offer \"un-hide\", and it is the only surface that does.",
|
||||
"operationId": "getLibrary",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -1021,13 +1021,13 @@
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Unified library across all stores",
|
||||
"description": "Unified library across all stores (the operator's lane also gets hidden entries, flagged)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/GameEntry"
|
||||
"$ref": "#/components/schemas/OperatorGameEntry"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1301,6 +1301,79 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/library/hidden/{id}": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"library"
|
||||
],
|
||||
"summary": "Hide or un-hide one library title",
|
||||
"description": "Curation, not access control: a hidden title disappears from every play surface — the console\ngrid on a client, native clients, the GameStream app list, and launch resolution — while nothing\nis deleted and un-hiding restores it immediately. The operator's own console still lists it\n(flagged `hidden`) so it can be brought back.\n\nKeyed by the entry's stable `<store>:<external_id>` id, which survives re-scans and reconciles by\nconstruction (D2). The id is **not** validated against the current library on purpose: a title\ncan be legitimately absent at this moment (launcher closed, plugin mid-sync, drive unmounted),\nand refusing the operator's choice in that window would be worse than storing an id that\ncurrently matches nothing. Emits `library.changed` (source = the store) only on a real change.",
|
||||
"operationId": "setLibraryEntryHidden",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"description": "The library entry id (e.g. `steam:70`)",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HiddenToggle"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Stored; the entry's visibility after the call",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HiddenState"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Empty entry id",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Could not persist the settings",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/library/provider/{provider}": {
|
||||
"put": {
|
||||
"tags": [
|
||||
@@ -5553,6 +5626,37 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"HiddenState": {
|
||||
"type": "object",
|
||||
"description": "What `setLibraryEntryHidden` echoes back.",
|
||||
"required": [
|
||||
"id",
|
||||
"hidden"
|
||||
],
|
||||
"properties": {
|
||||
"hidden": {
|
||||
"type": "boolean",
|
||||
"description": "Its visibility after the call."
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "The entry id the call addressed."
|
||||
}
|
||||
}
|
||||
},
|
||||
"HiddenToggle": {
|
||||
"type": "object",
|
||||
"description": "Request body for `setLibraryEntryHidden`.",
|
||||
"required": [
|
||||
"hidden"
|
||||
],
|
||||
"properties": {
|
||||
"hidden": {
|
||||
"type": "boolean",
|
||||
"description": "Whether this title should be hidden from every play surface."
|
||||
}
|
||||
}
|
||||
},
|
||||
"HookEntry": {
|
||||
"type": "object",
|
||||
"description": "One hook: fire `run` and/or `webhook` when an event matching `on` (+ `filter`) occurs.",
|
||||
@@ -6339,6 +6443,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"OperatorGameEntry": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/GameEntry"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"hidden": {
|
||||
"type": "boolean",
|
||||
"description": "The operator hid this title ([`set_entry_hidden`]) — omitted when false, so the shape only\ngrows for entries that actually are hidden."
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": "A library entry plus the operator's own view of it — today, whether they hid it.\n\nA separate type rather than a field on [`GameEntry`] for two reasons. It keeps the visibility\nanswer out of the providers entirely: a store parser has no opinion on what the operator hid, and\nadding `hidden: false` to all eight construction sites would imply it does. More importantly it\nmakes the lane rule a TYPE guarantee instead of a discipline — `GET /library` answers\n`Vec<GameEntry>` on every lane but the operator's, so a hidden entry cannot leak to a paired\nclient by someone forgetting a filter; there is no field there to leak.\n\n`flatten` keeps the wire shape identical to a plain entry with one extra key, so the console\nparses one model either way."
|
||||
},
|
||||
"PairedClient": {
|
||||
"type": "object",
|
||||
"description": "A paired (certificate-pinned) Moonlight client.",
|
||||
|
||||
@@ -19,6 +19,16 @@
|
||||
# 63-64), so it would simply refuse to install rather than start. Re-keying this image is the step
|
||||
# that makes the ffmpeg-9 bump actually reach the package — a Cargo.toml bump alone does nothing
|
||||
# here. Whenever Arch moves to an FFmpeg major, bump the date in the same commit.
|
||||
#
|
||||
# ⚠ AND KNOW WHY THAT WAS NOT ENOUGH: bumping this date only helps once docker.yml has actually
|
||||
# republished the image, and nothing sequences the two workflows. v0.25.0 was tagged four minutes
|
||||
# after the ffmpeg-9 merge, so the release build still pulled the FFmpeg-8 `:latest` and published
|
||||
# a punktfunk-host that no up-to-date Arch box could install — which blocks the user's ENTIRE
|
||||
# `pacman -Syu`, not just our package. arch.yml therefore no longer trusts this image on that one
|
||||
# axis: it compares the builder's libav sonames against the repos before building (and `-Syu`s
|
||||
# itself if they differ), and refuses to publish anything a pristine-db `pacman -U --print` says
|
||||
# is unsatisfiable. This file staying current is still the CHEAP path — those guards are the
|
||||
# backstop, not the plan.
|
||||
FROM docker.io/library/archlinux:base-devel
|
||||
|
||||
# One transaction: the main build/runtime deps (first list) + the gamescope companion's
|
||||
|
||||
@@ -69,11 +69,14 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
// later manual Back out of the library is not undone by a stale value.
|
||||
var reopenLibraryHostId by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// Console (gamepad) mode mirrors the Apple client: the setting AND (a pad is attached OR this is
|
||||
// a TV OR the dev force flag). Flips live as controllers connect/disconnect.
|
||||
// Console (gamepad) mode mirrors the Apple client: the setting AND (its mode says Always OR a
|
||||
// pad is attached OR this is a TV OR the dev force flag). Flips live as controllers
|
||||
// connect/disconnect — unless the mode is Always, where it simply stays.
|
||||
val tv = remember { isTvDevice(context) }
|
||||
val controllerConnected by rememberControllerConnected()
|
||||
val gamepadUi = gamepadUiActive(settings.gamepadUiEnabled, controllerConnected, tv, forceGamepadUi)
|
||||
val gamepadUi = gamepadUiActive(
|
||||
settings.gamepadUiEnabled, settings.gamepadUiMode, controllerConnected, tv, forceGamepadUi,
|
||||
)
|
||||
|
||||
// Publish the live session process-wide, so a `punktfunk://` link that arrives as a SECOND
|
||||
// activity instance (the normal case under `launchMode = standard`) can refuse it before that
|
||||
|
||||
@@ -67,7 +67,7 @@ class GamepadPalette(
|
||||
)
|
||||
|
||||
/**
|
||||
* The twelve shipped palettes: the brand default, five more dark fields, then six pale
|
||||
* The thirteen shipped palettes: the brand default, six more dark fields, then six pale
|
||||
* ones. Cycling order runs dark → light, so stepping the row walks the range one way.
|
||||
*/
|
||||
val ALL = listOf(
|
||||
@@ -77,6 +77,22 @@ class GamepadPalette(
|
||||
ground = Triple(0.075, 0.060, 0.160),
|
||||
accent = Triple(0.525, 0.471, 0.961), light = false,
|
||||
),
|
||||
GamepadPalette(
|
||||
// For OLED and AMOLED panels, where a black pixel is a pixel switched off — no
|
||||
// glow, no power. The first two stops are literally (0,0,0), so the shaded half
|
||||
// of the field is genuinely off rather than "very dark grey", and the ground is
|
||||
// pure black too: the calm mix on the form screens lifts toward nothing. What is
|
||||
// left is a faint indigo→violet ember in the bright corner. The accent stays the
|
||||
// brand violet — focus has to be findable on black.
|
||||
"oled", "OLED",
|
||||
listOf(
|
||||
Triple(0.000, 0.000, 0.000), Triple(0.000, 0.000, 0.000),
|
||||
Triple(0.010, 0.020, 0.100), Triple(0.045, 0.016, 0.115),
|
||||
Triple(0.120, 0.024, 0.130),
|
||||
),
|
||||
ground = Triple(0.0, 0.0, 0.0),
|
||||
accent = Triple(0.525, 0.471, 0.961), light = false,
|
||||
),
|
||||
GamepadPalette(
|
||||
// Deep indigo climbing through violet into a hot magenta.
|
||||
"nebula", "Nebula",
|
||||
|
||||
@@ -665,6 +665,21 @@ internal fun buildSettingsRows(
|
||||
"Turn off to use the touch interface even with a controller connected.",
|
||||
s.gamepadUiEnabled,
|
||||
) { update(s.copy(gamepadUiEnabled = it)) },
|
||||
) + listOfNotNull(
|
||||
// WHEN the switch above takes over. Built only while it is ON: turn the switch off from
|
||||
// this very screen and the row under the cursor would otherwise be one deciding nothing,
|
||||
// on a screen that is itself about to disappear.
|
||||
if (s.gamepadUiEnabled) {
|
||||
choice(
|
||||
"gamepadUIMode", GpTab.INTERFACE, null, "Show it",
|
||||
"With a controller: the touch interface comes back when the last one " +
|
||||
"disconnects. Always keeps this layout either way — for a device that lives " +
|
||||
"docked to a TV. A TV itself is always in this mode regardless.",
|
||||
GAMEPAD_UI_MODE_OPTIONS, s.gamepadUiMode,
|
||||
) { update(s.copy(gamepadUiMode = it)) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -16,15 +16,35 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
|
||||
/**
|
||||
* [Settings.gamepadUiMode]: take over only while a controller is attached. The default, and what
|
||||
* the switch meant when it was a lone Boolean.
|
||||
*/
|
||||
const val GAMEPAD_UI_WHEN_CONNECTED = "connected"
|
||||
|
||||
/**
|
||||
* [Settings.gamepadUiMode]: take over whenever the switch is on, pad or no pad — for a phone or
|
||||
* tablet that lives docked to a TV, where the console layout is the one wanted and the pad is not
|
||||
* always awake.
|
||||
*/
|
||||
const val GAMEPAD_UI_ALWAYS = "always"
|
||||
|
||||
/**
|
||||
* Whether the controller-optimized "console" home (the host carousel + gamepad chrome) should
|
||||
* replace the touch UI — the Android mirror of the Apple client's `GamepadUIEnvironment.isActive`:
|
||||
* the user's [enabled] setting AND (a controller is attached OR this is a TV OR the dev [forced]
|
||||
* flag). A TV counts unconditionally — its remote/gamepad is the only input, so it's always the
|
||||
* console UI (as long as the setting is on).
|
||||
* the user's [enabled] setting AND (the [mode] is [GAMEPAD_UI_ALWAYS] OR a controller is attached
|
||||
* OR this is a TV OR the dev [forced] flag). A TV counts unconditionally — its remote/gamepad is
|
||||
* the only input, so it's always the console UI (as long as the setting is on), which is why the
|
||||
* mode row means nothing there. An unrecognized [mode] waits for a controller, so a value a newer
|
||||
* client wrote can never strand this one in a layout it has no way back out of.
|
||||
*/
|
||||
fun gamepadUiActive(enabled: Boolean, controllerConnected: Boolean, tv: Boolean, forced: Boolean): Boolean =
|
||||
enabled && (controllerConnected || tv || forced)
|
||||
fun gamepadUiActive(
|
||||
enabled: Boolean,
|
||||
mode: String,
|
||||
controllerConnected: Boolean,
|
||||
tv: Boolean,
|
||||
forced: Boolean,
|
||||
): Boolean = enabled && (mode == GAMEPAD_UI_ALWAYS || controllerConnected || tv || forced)
|
||||
|
||||
/** True on a TV: the leanback/television feature or the TELEVISION ui-mode. */
|
||||
fun isTvDevice(context: Context): Boolean {
|
||||
|
||||
@@ -94,11 +94,20 @@ data class Settings(
|
||||
val touchMode: TouchMode = TouchMode.TRACKPAD,
|
||||
/**
|
||||
* Swap the whole home screen for the controller-optimized "console" UI (the host carousel +
|
||||
* gamepad chrome) whenever a controller is connected — mirrors the Apple client's
|
||||
* `gamepadUIEnabled`. On by default; turn it off to keep the touch UI even with a pad attached.
|
||||
* gamepad chrome) — mirrors the Apple client's `gamepadUIEnabled`. On by default; turn it off
|
||||
* to keep the touch UI even with a pad attached. WHEN it takes over is [gamepadUiMode].
|
||||
* A TV (leanback) is always in this mode regardless (its remote/pad is the only input).
|
||||
*/
|
||||
val gamepadUiEnabled: Boolean = true,
|
||||
/**
|
||||
* When [gamepadUiEnabled] actually takes over — the cross-client `gamepad_ui_mode` pair,
|
||||
* mirroring the Apple client's `gamepadUIMode`: `"connected"` (default, and what the switch
|
||||
* has always meant) waits for a controller; `"always"` keeps the console UI with no pad in
|
||||
* reach, for a phone or tablet that lives docked to a TV. Read only while [gamepadUiEnabled]
|
||||
* is on, which is why both settings screens hide the row when the switch is off. Anything
|
||||
* unrecognized resolves to `"connected"`. A TV ignores it — it is always in console mode.
|
||||
*/
|
||||
val gamepadUiMode: String = GAMEPAD_UI_WHEN_CONNECTED,
|
||||
/**
|
||||
* Show the experimental game-library browser (the coverflow reached with Y from a saved host).
|
||||
* Fetched from the host's management API over mTLS; needs a paired host. Mirrors the Apple
|
||||
@@ -107,9 +116,10 @@ data class Settings(
|
||||
val libraryEnabled: Boolean = true,
|
||||
/**
|
||||
* Which colour family the console (gamepad) UI's living backdrop drifts through — the
|
||||
* cross-client `ui_palette` key: `"violet"` (the brand default), `"tide"`, `"forest"`,
|
||||
* `"ember"`, `"rose"`, `"graphite"`. See [GamepadPalette], whose table and maths mirror the
|
||||
* desktop console's and the Apple client's under the same names. Presentation only: nothing
|
||||
* cross-client `ui_palette` key: `"violet"` (the brand default), then `"oled"`, `"nebula"`,
|
||||
* `"abyss"`, `"ember"`, `"moss"`, `"graphite"`, then the six pale fields. See
|
||||
* [GamepadPalette], whose table and maths mirror the desktop console's and the Apple
|
||||
* client's under the same names. Presentation only: nothing
|
||||
* about a stream depends on it, so it is a device preference and never part of a profile.
|
||||
* An unknown value reads as the default rather than failing — a newer client may have shipped
|
||||
* a palette this build doesn't know.
|
||||
@@ -303,6 +313,8 @@ class SettingsStore(context: Context) {
|
||||
// Migration: the pre-enum Boolean "trackpad_mode" (true = trackpad, false = direct).
|
||||
?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER,
|
||||
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
|
||||
gamepadUiMode = prefs.getString(K_GAMEPAD_UI_MODE, GAMEPAD_UI_WHEN_CONNECTED)
|
||||
?: GAMEPAD_UI_WHEN_CONNECTED,
|
||||
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
|
||||
uiPalette = prefs.getString(K_UI_PALETTE, "violet") ?: "violet",
|
||||
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
|
||||
@@ -344,6 +356,7 @@ class SettingsStore(context: Context) {
|
||||
.putString(K_STATS_VERBOSITY, s.statsVerbosity.name)
|
||||
.putString(K_TOUCH_MODE, s.touchMode.name)
|
||||
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
|
||||
.putString(K_GAMEPAD_UI_MODE, s.gamepadUiMode)
|
||||
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
||||
.putString(K_UI_PALETTE, s.uiPalette)
|
||||
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
|
||||
@@ -384,6 +397,7 @@ class SettingsStore(context: Context) {
|
||||
const val K_HUD = "stats_hud_enabled"
|
||||
const val K_TOUCH_MODE = "touch_mode"
|
||||
const val K_GAMEPAD_UI = "gamepad_ui_enabled"
|
||||
const val K_GAMEPAD_UI_MODE = "gamepad_ui_mode"
|
||||
const val K_LIBRARY = "library_enabled"
|
||||
const val K_UI_PALETTE = "ui_palette"
|
||||
|
||||
@@ -778,6 +792,13 @@ fun smoothBufferOptions(hz: Int): List<Pair<Int, String>> {
|
||||
)
|
||||
}
|
||||
|
||||
/** (stored value, label) for when the console UI takes over — the Apple client's table verbatim.
|
||||
* Only offered while [Settings.gamepadUiEnabled] is on; a TV is in console mode either way. */
|
||||
val GAMEPAD_UI_MODE_OPTIONS = listOf(
|
||||
GAMEPAD_UI_WHEN_CONNECTED to "With a controller",
|
||||
GAMEPAD_UI_ALWAYS to "Always",
|
||||
)
|
||||
|
||||
/** (mode, label) for the touch-input model. */
|
||||
val TOUCH_MODE_OPTIONS = listOf(
|
||||
TouchMode.TRACKPAD to "Trackpad",
|
||||
|
||||
@@ -592,11 +592,24 @@ private fun GeneralSettings(s: Settings, update: (Settings) -> Unit) {
|
||||
SettingsGroup("Interface") {
|
||||
ToggleRow(
|
||||
title = "Controller-optimized UI",
|
||||
subtitle = "Switch to the console home when a controller is connected. A TV " +
|
||||
"always uses it.",
|
||||
subtitle = "Swap the touch home for the console home — the host carousel and " +
|
||||
"gamepad chrome. A TV always uses it.",
|
||||
checked = s.gamepadUiEnabled,
|
||||
onCheckedChange = { on -> update(s.copy(gamepadUiEnabled = on)) },
|
||||
)
|
||||
// Only decides anything while the switch above is on, so it is HIDDEN rather than
|
||||
// dimmed when it isn't — a picker whose every option changes nothing is worse than
|
||||
// no picker, and this group is short enough that nothing jumps far.
|
||||
if (s.gamepadUiEnabled) {
|
||||
SettingDropdown(
|
||||
label = "Show it",
|
||||
options = GAMEPAD_UI_MODE_OPTIONS,
|
||||
selected = s.gamepadUiMode,
|
||||
caption = "With a controller: the touch home comes back when the last one " +
|
||||
"disconnects. Always keeps the console home either way — for a device " +
|
||||
"that lives docked to a TV.",
|
||||
) { v -> update(s.copy(gamepadUiMode = v)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,14 +33,14 @@ class GamepadPaletteTest {
|
||||
fun tableMatchesTheOtherClients() {
|
||||
assertEquals(
|
||||
listOf(
|
||||
"violet", "nebula", "abyss", "ember", "moss", "graphite",
|
||||
"violet", "oled", "nebula", "abyss", "ember", "moss", "graphite",
|
||||
"holo", "sunset", "bloom", "dawn", "mint", "opal",
|
||||
),
|
||||
GamepadPalette.ALL.map { it.id },
|
||||
)
|
||||
// Dark fields lead, pale ones follow, so stepping the row walks one direction.
|
||||
val firstLight = GamepadPalette.ALL.indexOfFirst { it.light }
|
||||
assertEquals(6, firstLight)
|
||||
assertEquals(7, firstLight)
|
||||
assertTrue(GamepadPalette.ALL.drop(firstLight).all { it.light })
|
||||
// An unknown name is a newer client's palette, not an error.
|
||||
assertEquals("violet", GamepadPalette.named("chartreuse").id)
|
||||
@@ -72,6 +72,25 @@ class GamepadPaletteTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OLED is the one palette whose selling point is measurable: it has to be genuinely black,
|
||||
* not merely the darkest of the dark fields. The blob field this client draws samples the
|
||||
* ramp at 0.15/0.40/0.65/0.90, so its darkest blob lands in the all-black head of the ramp.
|
||||
*/
|
||||
@Test
|
||||
fun oledIsActuallyBlack() {
|
||||
val oled = GamepadPalette.named("oled")
|
||||
assertEquals(Triple(0.0, 0.0, 0.0), oled.ground)
|
||||
assertEquals(0f, oled.blobColors[0].red, 1e-6f)
|
||||
assertEquals(0f, oled.blobColors[0].green, 1e-6f)
|
||||
assertEquals(0f, oled.blobColors[0].blue, 1e-6f)
|
||||
val mean = oled.stops.sumOf { luma(it) } / oled.stops.size
|
||||
val darkestOther = GamepadPalette.ALL
|
||||
.filter { it.id != "oled" && it.stops.isNotEmpty() }
|
||||
.minOf { p -> p.stops.sumOf { luma(it) } / p.stops.size }
|
||||
assertTrue("oled means $mean, barely under $darkestOther", mean < darkestOther / 2)
|
||||
}
|
||||
|
||||
/** A pale palette really is pale — its ink flips, so a mislabelled one is unreadable. */
|
||||
@Test
|
||||
fun palettesAreHonestAboutLightness() {
|
||||
|
||||
@@ -95,4 +95,47 @@ class GamepadSettingsRowsTest {
|
||||
// Drawn as a switch, and reading the persisted default.
|
||||
assertEquals(true, row(on, "dsCapture").toggled)
|
||||
}
|
||||
|
||||
/**
|
||||
* The activation-mode row is a sub-setting of the Controller-optimized UI switch, so it is
|
||||
* OFFERED only while that switch is on — hidden rather than dimmed, because with the switch
|
||||
* off this whole screen is about to be replaced by the touch UI and a dimmed row there would
|
||||
* be one last thing to step past on the way out.
|
||||
*/
|
||||
@Test
|
||||
fun `the activation-mode row follows the switch it belongs to`() {
|
||||
fun ids(enabled: Boolean) = buildSettingsRows(
|
||||
Settings(gamepadUiEnabled = enabled),
|
||||
hasBodyVibrator = false, hasGyroscope = false, av1Capable = false,
|
||||
) {}.map { it.id }
|
||||
|
||||
val on = ids(enabled = true)
|
||||
assertTrue("the mode row is missing", "gamepadUIMode" in on)
|
||||
assertEquals(
|
||||
"the mode belongs directly under the switch it qualifies",
|
||||
on.indexOf("gamepadUI") + 1,
|
||||
on.indexOf("gamepadUIMode"),
|
||||
)
|
||||
val off = ids(enabled = false)
|
||||
assertFalse("the mode row must not outlive its switch", "gamepadUIMode" in off)
|
||||
assertTrue("the switch itself stays, or it could never be turned back on", "gamepadUI" in off)
|
||||
}
|
||||
|
||||
/** Stepping the mode row writes the shared `gamepad_ui_mode` value, and wraps on A. */
|
||||
@Test
|
||||
fun `the activation-mode row steps the shared key`() {
|
||||
var s = Settings()
|
||||
fun mode() = buildSettingsRows(
|
||||
s, hasBodyVibrator = false, hasGyroscope = false, av1Capable = false,
|
||||
) { s = it }.first { it.id == "gamepadUIMode" }
|
||||
|
||||
assertEquals(GAMEPAD_UI_WHEN_CONNECTED, s.gamepadUiMode)
|
||||
assertEquals("With a controller", mode().value)
|
||||
assertFalse("already the first = thud", mode().adjust(-1))
|
||||
assertTrue(mode().adjust(1))
|
||||
assertEquals(GAMEPAD_UI_ALWAYS, s.gamepadUiMode)
|
||||
// A from the last entry wraps home.
|
||||
mode().activate()
|
||||
assertEquals(GAMEPAD_UI_WHEN_CONNECTED, s.gamepadUiMode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* [gamepadUiActive] is pure — table-tested over its inputs, and the mirror of the Apple client's
|
||||
* `GamepadUIEnvironmentTests`. The two clients share the stored `gamepad_ui_mode` values, so a
|
||||
* disagreement here is a device that behaves differently from the same setting.
|
||||
*/
|
||||
class GamepadUiTest {
|
||||
|
||||
/** The default mode is what the switch meant when it was a lone Boolean. */
|
||||
@Test
|
||||
fun whenConnectedWaitsForAPad() {
|
||||
assertTrue(gamepadUiActive(true, GAMEPAD_UI_WHEN_CONNECTED, true, tv = false, forced = false))
|
||||
assertFalse(gamepadUiActive(true, GAMEPAD_UI_WHEN_CONNECTED, false, tv = false, forced = false))
|
||||
assertFalse(gamepadUiActive(false, GAMEPAD_UI_WHEN_CONNECTED, true, tv = false, forced = false))
|
||||
assertFalse(gamepadUiActive(false, GAMEPAD_UI_WHEN_CONNECTED, false, tv = false, forced = false))
|
||||
// A TV is in console mode whatever the mode says — its remote is the only input.
|
||||
assertTrue(gamepadUiActive(true, GAMEPAD_UI_WHEN_CONNECTED, false, tv = true, forced = false))
|
||||
}
|
||||
|
||||
/** Always drops the controller from the decision — but never the switch, which is the one
|
||||
* way back to the touch UI. */
|
||||
@Test
|
||||
fun alwaysIgnoresThePadButNotTheSwitch() {
|
||||
assertTrue(gamepadUiActive(true, GAMEPAD_UI_ALWAYS, false, tv = false, forced = false))
|
||||
assertTrue(gamepadUiActive(true, GAMEPAD_UI_ALWAYS, true, tv = false, forced = false))
|
||||
assertFalse(gamepadUiActive(false, GAMEPAD_UI_ALWAYS, false, tv = false, forced = false))
|
||||
assertFalse(gamepadUiActive(false, GAMEPAD_UI_ALWAYS, true, tv = false, forced = false))
|
||||
}
|
||||
|
||||
/** A value a newer client wrote waits for a pad rather than stranding this build in a
|
||||
* layout it has no way back out of. */
|
||||
@Test
|
||||
fun anUnknownModeWaitsForAPad() {
|
||||
assertFalse(gamepadUiActive(true, "whenever-i-say-so", false, tv = false, forced = false))
|
||||
assertTrue(gamepadUiActive(true, "whenever-i-say-so", true, tv = false, forced = false))
|
||||
assertFalse(gamepadUiActive(true, "", false, tv = false, forced = false))
|
||||
}
|
||||
|
||||
/** The shipped default: the console UI still waits for a controller. */
|
||||
@Test
|
||||
fun theDefaultIsUnchangedBehaviour() {
|
||||
val s = Settings()
|
||||
assertTrue(s.gamepadUiEnabled)
|
||||
assertEquals(GAMEPAD_UI_WHEN_CONNECTED, s.gamepadUiMode)
|
||||
assertFalse(gamepadUiActive(s.gamepadUiEnabled, s.gamepadUiMode, false, tv = false, forced = false))
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,7 @@ class ProfilesTest {
|
||||
|
||||
// Device-scope settings are not in the overlay at all, so no profile can move them.
|
||||
assertEquals(base.gamepadUiEnabled, out.gamepadUiEnabled)
|
||||
assertEquals(base.gamepadUiMode, out.gamepadUiMode)
|
||||
assertEquals(base.libraryEnabled, out.libraryEnabled)
|
||||
assertEquals(base.autoWakeEnabled, out.autoWakeEnabled)
|
||||
assertEquals(base.sc2Capture, out.sc2Capture)
|
||||
|
||||
@@ -99,6 +99,10 @@ struct ContentView: View {
|
||||
// with no (extended) controller attached tvOS falls back to HomeView as before.
|
||||
@ObservedObject private var gamepadManager = GamepadManager.shared
|
||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||
/// When the switch above takes over — "connected" (default) or "always". See
|
||||
/// `GamepadUIEnvironment`.
|
||||
@AppStorage(DefaultsKey.gamepadUIMode) private var gamepadUIMode =
|
||||
GamepadUIEnvironment.modeWhenConnected
|
||||
/// Auto-wake on connect (Settings → General). On (default): a dial to an offline saved host
|
||||
/// fires Wake-on-LAN up front and falls into the "Waking…" wait if the dial fails. Off: connects
|
||||
/// go straight through with no wake. The explicit "Wake Host" action is unaffected either way.
|
||||
@@ -113,7 +117,8 @@ struct ContentView: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
private var gamepadUIActive: Bool {
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
|
||||
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled,
|
||||
mode: gamepadUIMode)
|
||||
}
|
||||
|
||||
// The body is split in two — `driven` (the screen plus its lifecycle drivers and sheets) and
|
||||
|
||||
@@ -85,16 +85,40 @@ extension EnvironmentValues {
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Resolve the stored `ui_palette` and publish its ink to everything below. Applied by the
|
||||
/// gamepad screens' common root so no individual view has to read the setting.
|
||||
func gamepadPaletteInk() -> some View { modifier(GamepadInkModifier()) }
|
||||
/// Resolve the stored `ui_palette` and publish its ink — AND the matching colour scheme — to
|
||||
/// everything below. Applied by the gamepad screens' common root so no individual view has to
|
||||
/// read the setting.
|
||||
///
|
||||
/// `active` exists for the one surface that is the same view in both worlds: `LibraryView`
|
||||
/// renders the coverflow under the gamepad UI and a plain grid without it. Passing `false`
|
||||
/// publishes nothing, because the touch/desktop layouts sit on the SYSTEM background, where a
|
||||
/// palette's scheme would invert their own system colours instead of matching them.
|
||||
func gamepadPaletteInk(_ active: Bool = true) -> some View {
|
||||
modifier(GamepadInkModifier(active: active))
|
||||
}
|
||||
}
|
||||
|
||||
private struct GamepadInkModifier: ViewModifier {
|
||||
var active = true
|
||||
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
|
||||
/// The ambient scheme from ABOVE this modifier — what gets republished unchanged when the
|
||||
/// gamepad UI isn't the one drawing, so `active: false` is a true no-op rather than a branch
|
||||
/// that would change this view's identity.
|
||||
@Environment(\.colorScheme) private var systemScheme
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content.environment(\.gamepadInk, GamepadInk.of(GamepadPalette.named(paletteID)))
|
||||
let palette = GamepadPalette.named(paletteID)
|
||||
return content
|
||||
.environment(\.gamepadInk, active ? GamepadInk.of(palette) : .dark)
|
||||
// The ink alone was never enough. Every SYSTEM-derived colour that lands on these
|
||||
// screens — `.secondary` in a placeholder, a `.bordered` button's chrome, a
|
||||
// NavigationStack's title, a material's frost — resolves against the DEVICE's
|
||||
// appearance, which no part of this app had ever set. On iPhone and Mac that is often
|
||||
// Light, so the pale palettes looked correct by accident; an Apple TV is Dark
|
||||
// essentially always, so on tvOS every one of them came out WHITE on a pale field and
|
||||
// the interface was unreadable. Publishing the scheme here — once, beside the ink it
|
||||
// has to agree with — is what makes a pale palette mean "light" to UIKit too.
|
||||
.environment(\.colorScheme, active ? (palette.light ? .light : .dark) : systemScheme)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,9 +32,12 @@ struct LibraryView: View {
|
||||
// setting off) every platform keeps the plain-grid presentation of this same view.
|
||||
@ObservedObject private var gamepadManager = GamepadManager.shared
|
||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||
@AppStorage(DefaultsKey.gamepadUIMode) private var gamepadUIMode =
|
||||
GamepadUIEnvironment.modeWhenConnected
|
||||
private var gamepadUIActive: Bool {
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
|
||||
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled,
|
||||
mode: gamepadUIMode)
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -78,6 +81,16 @@ struct LibraryView: View {
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
// Published HERE, not just inside the coverflow, because the coverflow is only one of
|
||||
// four things this view renders: the loading spinner, the error state and the empty
|
||||
// state sit above it, as do the navigation title and toolbar. On iOS those are wrapped
|
||||
// by GamepadLibraryScreen, which inks the whole thing; tvOS and macOS present this view
|
||||
// directly in a NavigationStack, so under a pale palette every one of them kept the
|
||||
// system's own (dark, on an Apple TV) chrome over a light field. Off when the gamepad
|
||||
// UI isn't drawing — the plain grid belongs to the system background.
|
||||
.gamepadPaletteInk(gamepadUIActive)
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder private var content: some View {
|
||||
|
||||
@@ -81,6 +81,9 @@ struct GamepadSettingsView: View {
|
||||
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
|
||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||
/// When the switch above takes over — the row is only built while it is on.
|
||||
@AppStorage(DefaultsKey.gamepadUIMode) private var gamepadUIMode =
|
||||
GamepadUIEnvironment.modeWhenConnected
|
||||
/// The gamepad UI's background colour family — the backdrop BEHIND this screen re-colours as
|
||||
/// the row steps, which is why the picker lives here and not in a sheet.
|
||||
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
|
||||
@@ -659,6 +662,21 @@ struct GamepadSettingsView: View {
|
||||
detail: "Turn off to use the touch interface even with a controller connected.",
|
||||
value: $gamepadUIEnabled),
|
||||
]
|
||||
// WHEN the switch above takes over. Built only while it is on: with the switch off this
|
||||
// screen is unreachable in the first place (no gamepad UI to open it from), so a row
|
||||
// that decides nothing would exist purely to be found in a screenshot.
|
||||
if gamepadUIEnabled, let at = list.firstIndex(where: { $0.id == "gamepadUI" }) {
|
||||
list.insert(
|
||||
choiceRow(
|
||||
id: "gamepadUIMode", tab: .interface, icon: "gamecontroller.circle",
|
||||
label: "Show it",
|
||||
detail: "With a controller: the touch interface comes back when the last one "
|
||||
+ "disconnects. Always keeps this layout either way — for a device that "
|
||||
+ "lives on a TV.",
|
||||
options: SettingsOptions.gamepadUIModes, current: gamepadUIMode
|
||||
) { gamepadUIMode = $0 },
|
||||
at: at + 1)
|
||||
}
|
||||
#if os(macOS)
|
||||
// The windowed safe-present toggle slots in after "Smoothness buffer" (staying inside
|
||||
// the Video tab) — macOS only, mirroring the touch SettingsView's Presentation row
|
||||
@@ -707,6 +725,14 @@ struct GamepadSettingsView: View {
|
||||
at: anchor + 1)
|
||||
}
|
||||
#endif
|
||||
// The smoothness buffer only decides anything under Smoothness. Every other settings
|
||||
// surface — touch, tvOS, the GTK and WinUI shells — hides it under Lowest latency; this
|
||||
// screen alone left it live and steppable, which is a row that thuds or silently stores
|
||||
// a value nothing reads. Removed here rather than omitted from the literal above so the
|
||||
// macOS safe-present insertion can still anchor on it.
|
||||
if presentPriority != "smooth" {
|
||||
list.removeAll { $0.id == "smoothBuffer" }
|
||||
}
|
||||
return list + profileRows
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,14 @@ enum SettingsOptions {
|
||||
static let hudPlacements: [(label: String, tag: String)] =
|
||||
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
|
||||
|
||||
/// When the gamepad UI takes over (`DefaultsKey.gamepadUIMode`) — only meaningful while
|
||||
/// `gamepadUIEnabled` is on, so every surface that offers it hides the row when the switch
|
||||
/// is off rather than showing a picker that decides nothing.
|
||||
static let gamepadUIModes: [(label: String, tag: String)] = [
|
||||
("With a controller", GamepadUIEnvironment.modeWhenConnected),
|
||||
("Always", GamepadUIEnvironment.modeAlways),
|
||||
]
|
||||
|
||||
/// Presentation intent (`DefaultsKey.presentPriority` — the 2026-07 rebuild that replaced
|
||||
/// the visible stage picker with intent; see SessionPresenter's PresentPriority and
|
||||
/// design/apple-presentation-rebuild.md). The stage ladder survives only as the hidden
|
||||
|
||||
@@ -724,11 +724,24 @@ extension SettingsView {
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
if !inProfileScope {
|
||||
described("With a controller connected, the host list and library switch to a "
|
||||
+ "controller-friendly layout — larger focus targets, a swipeable cover "
|
||||
+ "browser.") {
|
||||
described("The host list and library switch to a controller-friendly layout — "
|
||||
+ "larger focus targets, a swipeable cover browser.") {
|
||||
Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled)
|
||||
}
|
||||
// Only meaningful while the switch above is on, so it is HIDDEN rather than
|
||||
// disabled when it isn't: a picker whose every option decides nothing is worse
|
||||
// than no picker, and this Section is short enough that nothing jumps far.
|
||||
if gamepadUIEnabled {
|
||||
described("With a controller: the touch interface comes back when the last "
|
||||
+ "one disconnects. Always keeps the controller-friendly layout either "
|
||||
+ "way — for a device that lives on a TV.") {
|
||||
Picker("Show it", selection: $gamepadUIMode) {
|
||||
ForEach(SettingsOptions.gamepadUIModes, id: \.tag) { option in
|
||||
Text(option.label).tag(option.tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#if DEBUG && !os(tvOS)
|
||||
|
||||
@@ -75,6 +75,13 @@ struct SettingsView: View {
|
||||
@AppStorage(DefaultsKey.hudPlacement) var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||
@ObservedObject var gamepads = GamepadManager.shared
|
||||
@AppStorage(DefaultsKey.gamepadUIEnabled) var gamepadUIEnabled = true
|
||||
/// When the switch above takes over — read (and shown) only while it is on.
|
||||
@AppStorage(DefaultsKey.gamepadUIMode) var gamepadUIMode =
|
||||
GamepadUIEnvironment.modeWhenConnected
|
||||
/// The gamepad UI's background palette. Edited here on tvOS only (see `tvBody`) — every other
|
||||
/// platform reaches it through the gamepad settings screen, which an Apple TV without a
|
||||
/// controller cannot open.
|
||||
@AppStorage(DefaultsKey.uiPalette) var uiPalette = "violet"
|
||||
@AppStorage(DefaultsKey.autoWake) var autoWakeEnabled = true
|
||||
@AppStorage(DefaultsKey.backgroundKeepAlive) var backgroundKeepAlive = false
|
||||
@AppStorage(DefaultsKey.backgroundTimeoutMinutes) var backgroundTimeoutMinutes = 10
|
||||
@@ -488,6 +495,22 @@ struct SettingsView: View {
|
||||
TVSelectionRow(
|
||||
title: "Gamepad-optimized browsing",
|
||||
options: [("On", "on"), ("Off", "off")], selection: gamepadUIEnabledTag)
|
||||
// Hidden while the switch above is off — see the touch settings' identical gate.
|
||||
if gamepadUIEnabled {
|
||||
TVSelectionRow(
|
||||
title: "Show it",
|
||||
options: SettingsOptions.gamepadUIModes, selection: $gamepadUIMode)
|
||||
// The Apple TV's ONLY route to the shared `ui_palette`. Everywhere else the
|
||||
// Background row lives on the gamepad settings screen, which is reached from
|
||||
// the gamepad launcher — and on tvOS that launcher needs an extended-profile
|
||||
// controller, so an Apple TV driven by the Siri Remote alone could not reach
|
||||
// the palettes at all. It belongs beside "Show it" because both describe the
|
||||
// same interface: this row is what that interface looks like once it is up.
|
||||
TVSelectionRow(
|
||||
title: "Background",
|
||||
options: GamepadPalette.all.map { (label: $0.name, tag: $0.id) },
|
||||
selection: $uiPalette)
|
||||
}
|
||||
tvCaption(Self.controllersFooter)
|
||||
NavigationLink("About") { AboutView() }
|
||||
.padding(.top, 8)
|
||||
|
||||
@@ -95,24 +95,19 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
|
||||
private var materialWash: Color { ink.glass(ink.isLight ? 0.55 : 0.40) }
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
// The scheme goes on the WHOLE modified view, not just the fill inside `.background {}`.
|
||||
// Scoped to the fill it frosts the material correctly and stops there, so a system colour
|
||||
// in the row's own content (a `.secondary` label, a `.bordered` button) still resolved
|
||||
// against the device appearance — which is how the pale palettes came out light-on-light
|
||||
// on tvOS, whose appearance is always Dark. The 26 branch had it right all along; the
|
||||
// tvOS and pre-26 branches were the odd ones out.
|
||||
#if os(tvOS)
|
||||
// ALWAYS the material fallback on tvOS: the gamepad settings list is 15+ of these
|
||||
// surfaces, and live Liquid Glass per row made the whole screen visibly laggy on the
|
||||
// Apple TV's GPU (same class of call GlassProminentButton already makes — glass fights
|
||||
// the 10-foot platform). The wash and tint ride overlays — two flat fills, no GPU cost.
|
||||
content.background {
|
||||
shape.fill(.ultraThinMaterial)
|
||||
.environment(\.colorScheme, scheme)
|
||||
.overlay { shape.fill(materialWash) }
|
||||
.overlay {
|
||||
if let tint { shape.fill(tint) }
|
||||
}
|
||||
}
|
||||
#else
|
||||
if #available(iOS 26, macOS 26, *) {
|
||||
content.glassEffect(glass, in: shape).environment(\.colorScheme, scheme)
|
||||
} else {
|
||||
content.background {
|
||||
content
|
||||
.background {
|
||||
shape.fill(.ultraThinMaterial)
|
||||
.environment(\.colorScheme, scheme)
|
||||
.overlay { shape.fill(materialWash) }
|
||||
@@ -120,6 +115,21 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
|
||||
if let tint { shape.fill(tint) }
|
||||
}
|
||||
}
|
||||
.environment(\.colorScheme, scheme)
|
||||
#else
|
||||
if #available(iOS 26, macOS 26, *) {
|
||||
content.glassEffect(glass, in: shape).environment(\.colorScheme, scheme)
|
||||
} else {
|
||||
content
|
||||
.background {
|
||||
shape.fill(.ultraThinMaterial)
|
||||
.environment(\.colorScheme, scheme)
|
||||
.overlay { shape.fill(materialWash) }
|
||||
.overlay {
|
||||
if let tint { shape.fill(tint) }
|
||||
}
|
||||
}
|
||||
.environment(\.colorScheme, scheme)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -173,11 +183,14 @@ private struct ConsoleGlassBackground<S: Shape>: ViewModifier {
|
||||
in: shape)
|
||||
.environment(\.colorScheme, scheme)
|
||||
} else {
|
||||
content.background {
|
||||
shape.fill(.regularMaterial)
|
||||
.environment(\.colorScheme, scheme)
|
||||
.overlay { shape.fill(ink.glass(ink.isLight ? 0.55 : 0.40)) }
|
||||
}
|
||||
// Same hoist as ConsoleGlass: the content needs the scheme too, not only the frost.
|
||||
content
|
||||
.background {
|
||||
shape.fill(.regularMaterial)
|
||||
.environment(\.colorScheme, scheme)
|
||||
.overlay { shape.fill(ink.glass(ink.isLight ? 0.55 : 0.40)) }
|
||||
}
|
||||
.environment(\.colorScheme, scheme)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,11 +16,17 @@ import os
|
||||
/// (`punktfunk_core::audio::JitterPolicy`): a slow depth average that sits above target for a
|
||||
/// sustained window sheds ONE 5 ms frame with a crossfade, and the hard cap is only a backstop.
|
||||
///
|
||||
/// **Adaptive depth.** The target is a floor, not a constant: repeated genuine underruns grow it
|
||||
/// a step at a time (`noteRead`, mirroring `JitterPolicy::note_read`) up to `maxTargetMS`, and a
|
||||
/// long quiet spell relaxes it back toward the base — so a session on Wi-Fi that bunches arrivals
|
||||
/// deepens until it stops crackling, while a clean LAN keeps the tight base latency. Keep the
|
||||
/// constants here in step with `JitterTuning.COREAUDIO`.
|
||||
/// **Adaptive depth.** The target is a floor, not a constant: a NEAR-MISS — a read served with
|
||||
/// less than one frame left over — grows it a step BEFORE anything was audible, repeated genuine
|
||||
/// underruns grow it too (`noteRead`, mirroring `JitterPolicy::note_read`) up to `maxTargetMS`,
|
||||
/// and a long quiet spell relaxes it back toward the base — so a session on Wi-Fi that bunches
|
||||
/// arrivals deepens until it stops crackling, while a clean LAN keeps the tight base latency.
|
||||
/// Growth only raises a promise; the one thing that re-banks real depth is a re-prime, so an
|
||||
/// underrun while the ring is HOLLOW (depth average far below the target) re-primes at once,
|
||||
/// spending the click it already cost on the whole refill. Every shrink is armed as a PROBE:
|
||||
/// answered by an underrun or near-miss within its window, it is undone on the spot, and a
|
||||
/// failed sync-driven shrink is not retried for a growing backoff. Keep the constants here in
|
||||
/// step with `JitterTuning.COREAUDIO`.
|
||||
///
|
||||
/// **A/V sync.** On top of all that the depth can be STEERED, by `setSyncTarget` from the drain
|
||||
/// thread's `AvSync` — because a ring that is the right depth for the link is not thereby the
|
||||
@@ -58,9 +64,29 @@ final class AudioRing: @unchecked Sendable {
|
||||
/// target normally relaxes only after a long spell because, absent other evidence, the only
|
||||
/// thing that can justify giving up hard-won slack is time; a sync request IS that evidence —
|
||||
/// a measurement saying the extra depth is costing alignment right now — so a smaller target
|
||||
/// gets tested sooner. Wrong guesses are cheap and self-correcting (one underrun and the
|
||||
/// growth path takes it straight back). Mirrors `SHRINK_QUIET_SYNC_MS`.
|
||||
/// gets tested sooner. Mirrors `SHRINK_QUIET_SYNC_MS`.
|
||||
private static let shrinkQuietSyncMS = 5_000
|
||||
/// Post-read depth below which a served callback counts as a NEAR-MISS: the device got its
|
||||
/// samples, but with less than one protocol frame left in hand — the same evidence as an
|
||||
/// underrun, except nobody heard it yet, so the target grows BEFORE the click instead of
|
||||
/// after the third one. Mirrors `NEAR_MISS_MARGIN_MS`.
|
||||
private static let nearMissMarginMS = frameMS
|
||||
/// How long a shrink remains a PROBE, in consumed audio: an underrun or near-miss inside
|
||||
/// this window means the shrink was wrong, and the previous target is restored at once.
|
||||
/// Mirrors `SHRINK_PROBE_MS`.
|
||||
private static let shrinkProbeMS = 5_000
|
||||
/// How long a failed probe keeps the sync loop from driving another shrink — without it the
|
||||
/// loop pays an audible starvation event every `shrinkQuietSyncMS` on any link whose jitter
|
||||
/// genuinely needs the depth, forever. Doubles per consecutive failure, capped; a probe that
|
||||
/// survives its window resets it. Mirror `SYNC_BACKOFF_MS` / `SYNC_BACKOFF_MAX_MS`.
|
||||
private static let syncBackoffMS = 60_000
|
||||
private static let syncBackoffMaxMS = 480_000
|
||||
/// A ring is HOLLOW when its depth AVERAGE sits this far below the target: growth only ever
|
||||
/// raises the promise, and the one thing that re-banks real depth is a re-prime — so an
|
||||
/// underrun in a hollow ring re-primes AT ONCE, spending the click it already cost on the
|
||||
/// whole refill instead of riding the knife edge one click per bunching period. Mirrors
|
||||
/// `DEPRIME_DEBT_MS`.
|
||||
private static let deprimeDebtMS = growStepMS
|
||||
|
||||
private var buf: [Float]
|
||||
private var readIdx = 0
|
||||
@@ -87,6 +113,24 @@ final class AudioRing: @unchecked Sendable {
|
||||
/// `nil` — the default, and what an un-wired session keeps — reproduces the pre-sync
|
||||
/// behaviour exactly, so this ring could adopt sync without the other three diverging.
|
||||
private var syncTarget: Int?
|
||||
/// This read was served with less than `nearMissMarginMS` left over (set in `read`,
|
||||
/// consumed by `noteRead`).
|
||||
private var nearMiss = false
|
||||
/// A near-miss already grew the target this window — one step per window, so a bunching
|
||||
/// episode (a RUN of consecutive near-misses while the ring refills) buys one measured
|
||||
/// step, not a sprint to the ceiling.
|
||||
private var nearMissGrown = false
|
||||
/// The depth average runs a `deprimeDebtMS` debt against the target (set in `read`): an
|
||||
/// underrun should re-prime at once instead of waiting out the hysteresis.
|
||||
private var hollow = false
|
||||
/// Interleaved samples left in the current shrink-probe window (0 = no probe outstanding).
|
||||
private var probeRun = 0
|
||||
/// The live target before the probed shrink, restored if the probe fails.
|
||||
private var probePrevTarget = 0
|
||||
/// Interleaved samples before the sync loop may drive another shrink (0 = allowed now).
|
||||
private var syncBackoffRun = 0
|
||||
/// Length of the NEXT backoff, in ms — doubles per consecutive failed probe, capped.
|
||||
private var syncBackoffLenMS = AudioRing.syncBackoffMS
|
||||
/// The sync loop's smoothed offset in ms, STORED not computed: the ring owns the depth but has
|
||||
/// no timestamps, so the drain thread (which has both a packet's `pts_ns` and the video leg)
|
||||
/// hands the number back for reporting. Mirrors `NativeClient::audio_av_offset_ms`.
|
||||
@@ -121,8 +165,15 @@ final class AudioRing: @unchecked Sendable {
|
||||
/// then return the CAP — i.e. quietly below the continuity floor, inverting the very ordering
|
||||
/// this exists to guarantee, on exactly the awkward hardware it exists to survive. (Rust's
|
||||
/// `Ord::clamp` announces the same condition by panicking; Swift would just get it wrong.)
|
||||
private var target: Int {
|
||||
let floor = max(targetLive, renderQuantum + Self.frameMS * perMS)
|
||||
private var target: Int { target(lift: renderQuantum) }
|
||||
|
||||
/// The effective target with an explicit quantum lift. The property above uses the high-water
|
||||
/// `renderQuantum` (priming must survive the biggest callback seen); the hollow check in
|
||||
/// `read` passes the CURRENT callback instead, mirroring the Rust side's `want` — a one-off
|
||||
/// oversized read would otherwise inflate the debt threshold forever and turn the very next
|
||||
/// late packet into a full re-prime.
|
||||
private func target(lift quantum: Int) -> Int {
|
||||
let floor = max(targetLive, quantum + Self.frameMS * perMS)
|
||||
guard let want = syncTarget else { return floor }
|
||||
let cap = max(Self.hardCapMS * perMS, floor)
|
||||
return min(max(want, floor), cap)
|
||||
@@ -211,12 +262,24 @@ final class AudioRing: @unchecked Sendable {
|
||||
if available >= target {
|
||||
primed = true
|
||||
emptyReads = 0
|
||||
// The refill just banked this much: seed the average with it rather than letting
|
||||
// it climb from wherever the drought left it — a freshly-primed ring would
|
||||
// otherwise read as hollow for the EWMA's whole settling time, and the FIRST
|
||||
// late packet would re-prime a ring that is actually full.
|
||||
depthAvg = Double(available)
|
||||
} else {
|
||||
for i in 0..<count { out[i] = 0 }
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Hollow: the depth AVERAGE runs a debt against the target — the promise has been raised
|
||||
// but the depth was never re-banked (see `deprimeDebtMS`). Judged on the average, not
|
||||
// this instant: a single late packet empties the ring for a callback without making it
|
||||
// hollow, and must keep the consecutive-empties hysteresis. Lifted by THIS callback's
|
||||
// size, not the high-water quantum — see `target(lift:)`.
|
||||
hollow = depthAvg + Double(Self.deprimeDebtMS * perMS) < Double(target(lift: count))
|
||||
|
||||
// Drift correction: shed exactly one frame, crossfaded, once the AVERAGE has sat above
|
||||
// the threshold for the sustain window. Anything shorter is jitter and must be left alone.
|
||||
if depthAvg > Double(target + Self.shedExcessMS * perMS) {
|
||||
@@ -240,6 +303,9 @@ final class AudioRing: @unchecked Sendable {
|
||||
if n < count {
|
||||
for i in n..<count { out[i] = 0 }
|
||||
}
|
||||
// Near-miss: served in full, but with less than one frame left over — the next callback
|
||||
// starves unless a packet lands within one frame time.
|
||||
nearMiss = n == count && writeIdx - readIdx < Self.nearMissMarginMS * perMS
|
||||
noteRead(ranShort: n < count, count: count)
|
||||
}
|
||||
|
||||
@@ -254,32 +320,84 @@ final class AudioRing: @unchecked Sendable {
|
||||
if windowRun >= Self.growWindowMS * perMS {
|
||||
windowRun = 0
|
||||
underrunsInWindow = 0
|
||||
nearMissGrown = false
|
||||
}
|
||||
syncBackoffRun = max(0, syncBackoffRun - count)
|
||||
var restored = false
|
||||
if probeRun > 0 {
|
||||
probeRun = max(0, probeRun - count)
|
||||
if ranShort || nearMiss {
|
||||
// The probe FAILED: the link answered a shrink with (nearly) starving the ring.
|
||||
// Take the depth straight back — re-learning it three audible underruns at a
|
||||
// time is what made the sync-vs-growth tug-of-war audible — and keep the sync
|
||||
// loop from probing again for a while, doubling per consecutive failure. The
|
||||
// residual A/V offset is reported instead; continuity outranks sync. The
|
||||
// restore CONSUMES this event as growth evidence: it answered a depth the ring
|
||||
// is no longer at, so growing past the proven target on top would overshoot.
|
||||
probeRun = 0
|
||||
targetLive = max(targetLive, probePrevTarget)
|
||||
syncBackoffRun = syncBackoffLenMS * perMS
|
||||
syncBackoffLenMS = min(syncBackoffLenMS * 2, Self.syncBackoffMaxMS)
|
||||
restored = true
|
||||
} else if probeRun == 0 {
|
||||
// Survived the whole window: the shallower depth is genuinely safe here, so the
|
||||
// next probe starts from a clean slate.
|
||||
syncBackoffLenMS = Self.syncBackoffMS
|
||||
}
|
||||
}
|
||||
if ranShort {
|
||||
quietRun = 0
|
||||
emptyReads += 1
|
||||
underrunCount += 1
|
||||
if emptyReads >= Self.deprimeAfter {
|
||||
if emptyReads >= Self.deprimeAfter || hollow {
|
||||
// The consecutive-empties hysteresis protects a FULL ring from one late packet.
|
||||
// A hollow ring is the opposite case: the target has been raised but the depth
|
||||
// never re-banked (growth is a promise; only a re-prime cashes it), and riding
|
||||
// that out is a click per bunching period, forever. The click just heard has
|
||||
// already paid for the refill — take it now.
|
||||
primed = false
|
||||
emptyReads = 0
|
||||
}
|
||||
underrunsInWindow += 1
|
||||
if !restored {
|
||||
underrunsInWindow += 1
|
||||
}
|
||||
if underrunsInWindow >= Self.growUnderruns {
|
||||
underrunsInWindow = 0
|
||||
windowRun = 0
|
||||
targetLive = min(targetLive + Self.growStepMS * perMS, Self.maxTargetMS * perMS)
|
||||
}
|
||||
} else if nearMiss {
|
||||
// Came within one frame of an underrun — the same evidence as one, heard by no one.
|
||||
// Growing here, BEFORE the click, is what "no audible jitter" means: waiting for
|
||||
// the third audible underrun means the user heard two. One step per window (a
|
||||
// bunching episode is a RUN of near-misses while the ring refills, and must buy one
|
||||
// measured step, not a sprint to the ceiling); if it worsens into real underruns
|
||||
// the path above takes over. A near-miss is pressure, not quiet.
|
||||
quietRun = 0
|
||||
emptyReads = 0
|
||||
if !nearMissGrown, !restored {
|
||||
nearMissGrown = true
|
||||
targetLive = min(targetLive + Self.growStepMS * perMS, Self.maxTargetMS * perMS)
|
||||
}
|
||||
} else {
|
||||
emptyReads = 0
|
||||
quietRun += count
|
||||
// Without a sync request, time is the only evidence that hard-won slack is no longer
|
||||
// needed, so a grown target waits out the long window. A request for less IS evidence,
|
||||
// and without this branch a ring that ratcheted to the ceiling during a transient would
|
||||
// hold audio a ceiling's worth late for minutes after the cause had gone.
|
||||
let quietNeeded = syncWantsLess ? Self.shrinkQuietSyncMS : Self.shrinkQuietMS
|
||||
// hold audio a ceiling's worth late for minutes after the cause had gone. Every shrink
|
||||
// is armed as a PROBE — answered by an underrun or near-miss it is undone at once (see
|
||||
// above), and a failed sync-driven guess is not retried for a backoff.
|
||||
let syncShrink = syncWantsLess && syncBackoffRun == 0
|
||||
let quietNeeded = syncShrink ? Self.shrinkQuietSyncMS : Self.shrinkQuietMS
|
||||
if quietRun >= quietNeeded * perMS {
|
||||
quietRun = 0
|
||||
let prev = targetLive
|
||||
targetLive = max(targetLive - Self.growStepMS * perMS, Self.targetMS * perMS)
|
||||
if targetLive < prev {
|
||||
probeRun = Self.shrinkProbeMS * perMS
|
||||
probePrevTarget = prev
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,20 +3,40 @@
|
||||
// layouts). A pure function, not a singleton: the reactivity comes from callers already observing
|
||||
// `GamepadManager.shared` and the `DefaultsKey.gamepadUIEnabled` @AppStorage themselves (the same
|
||||
// local-read pattern SettingsView already uses for GamepadManager), so this stays the single place
|
||||
// the two combine without adding a second ObservableObject or an environment key nobody else needs.
|
||||
// the inputs combine without adding a second ObservableObject or an environment key nobody else needs.
|
||||
|
||||
import Foundation
|
||||
import PunktfunkShared
|
||||
|
||||
public enum GamepadUIEnvironment {
|
||||
/// `enabledSetting` is the user's Settings toggle (`DefaultsKey.gamepadUIEnabled`);
|
||||
/// `DefaultsKey.gamepadUIMode`: take over only while a controller is attached. The default,
|
||||
/// and what the switch meant when it was a lone Bool.
|
||||
public static let modeWhenConnected = "connected"
|
||||
/// `DefaultsKey.gamepadUIMode`: take over whenever the switch is on, pad or no pad — asked
|
||||
/// for by people driving a TV-connected iPad or a couch Mac, where the console layout is the
|
||||
/// one they want and the pad is not always awake.
|
||||
public static let modeAlways = "always"
|
||||
|
||||
/// `enabledSetting` is the user's Settings switch (`DefaultsKey.gamepadUIEnabled`) — off means
|
||||
/// the touch/desktop UI, full stop. `mode` is `DefaultsKey.gamepadUIMode`, and only matters
|
||||
/// once the switch is on: `modeAlways` takes over unconditionally, anything else (including a
|
||||
/// value a newer client wrote) waits for a controller.
|
||||
///
|
||||
/// `gamepadConnected` is `GamepadManager.shared.active != nil` — active only once a usable
|
||||
/// controller is actually attached (a non-extended-profile device leaves `active` nil, which
|
||||
/// keeps the touch UI). A `Bool` rather than the `DiscoveredController` itself: this function's
|
||||
/// whole job is the AND, so there's nothing else to inspect, and it keeps the helper testable
|
||||
/// without a real `GCController` (which XCTest can't construct).
|
||||
public static func isActive(gamepadConnected: Bool, enabledSetting: Bool) -> Bool {
|
||||
enabledSetting && (gamepadConnected || forced)
|
||||
/// keeps the touch UI). A `Bool` rather than the `DiscoveredController` itself: this function
|
||||
/// has nothing else to inspect, and it keeps the helper testable without a real `GCController`
|
||||
/// (which XCTest can't construct).
|
||||
/// `mode` carries no default on purpose: a call site that forgot it would silently strand
|
||||
/// everyone who picked Always back on "only with a controller", which is exactly the bug
|
||||
/// this parameter exists to make impossible.
|
||||
public static func isActive(
|
||||
gamepadConnected: Bool,
|
||||
enabledSetting: Bool,
|
||||
mode: String
|
||||
) -> Bool {
|
||||
guard enabledSetting else { return false }
|
||||
return mode == modeAlways || gamepadConnected || forced
|
||||
}
|
||||
|
||||
/// Dev-only escape hatch (like ContentView's `PUNKTFUNK_AUTOCONNECT`): pretend a controller is
|
||||
|
||||
@@ -176,16 +176,23 @@ public enum DefaultsKey {
|
||||
/// ("topLeading"/"topTrailing"/"bottomLeading"/"bottomTrailing"). Default top-trailing.
|
||||
public static let hudPlacement = "punktfunk.hudPlacement"
|
||||
/// iOS/iPadOS/macOS: switch the host list, settings and game library to a controller-friendly
|
||||
/// layout (the console launcher, gamepad-navigable settings, a coverflow-style library)
|
||||
/// whenever a gamepad is connected. On by default; see `GamepadUIEnvironment.isActive`.
|
||||
/// layout (the console launcher, gamepad-navigable settings, a coverflow-style library).
|
||||
/// On by default; WHEN it takes over is `gamepadUIMode`. See `GamepadUIEnvironment.isActive`.
|
||||
public static let gamepadUIEnabled = "punktfunk.gamepadUIEnabled"
|
||||
/// When `gamepadUIEnabled` actually takes over: `"connected"` (the default — only while a
|
||||
/// usable controller is attached, the behaviour this switch has always had) or `"always"`,
|
||||
/// for someone who prefers the console layout with no pad in reach (a TV-connected iPad, a
|
||||
/// Mac driven from the couch). Read only while `gamepadUIEnabled` is on, which is why the
|
||||
/// settings rows hide it when the switch is off. Anything unrecognized reads as
|
||||
/// `"connected"`. A device preference, never part of a stream profile.
|
||||
public static let gamepadUIMode = "punktfunk.gamepadUIMode"
|
||||
/// Which colour family the gamepad UI's living backdrop drifts through — a
|
||||
/// `GamepadPalette` id ("violet" = the brand default, then "tide"/"forest"/"ember"/
|
||||
/// "rose"/"graphite"). The cross-client `ui_palette` key: the desktop console and the
|
||||
/// Android client carry the same table under the same names. Presentation only, so it is
|
||||
/// a device preference and never part of a stream profile. An unknown value reads as the
|
||||
/// default rather than failing — a newer client may have shipped a palette this build
|
||||
/// doesn't know.
|
||||
/// `GamepadPalette` id ("violet" = the brand default, then "oled"/"nebula"/"abyss"/"ember"/
|
||||
/// "moss"/"graphite", then the pale ones). The cross-client `ui_palette` key: the desktop
|
||||
/// console and the Android client carry the same table under the same names. Presentation
|
||||
/// only, so it is a device preference and never part of a stream profile. An unknown value
|
||||
/// reads as the default rather than failing — a newer client may have shipped a palette this
|
||||
/// build doesn't know.
|
||||
public static let uiPalette = "punktfunk.uiPalette"
|
||||
/// iPhone: ALSO play the rumble the host addresses to controller 1 (wire pad 0) on this
|
||||
/// device's own Taptic Engine — for phone-clip pads that ship without rumble motors, where
|
||||
|
||||
@@ -65,13 +65,25 @@ public struct GamepadPalette: Identifiable, Equatable, Sendable {
|
||||
SIMD3(0.22, 0.38, 0.86), SIMD3(0.53, 0.47, 0.96),
|
||||
]
|
||||
|
||||
/// The twelve shipped palettes: the brand default, five more dark fields, then six pale
|
||||
/// The thirteen shipped palettes: the brand default, six more dark fields, then six pale
|
||||
/// ones. Cycling order runs dark → light, so stepping the row walks the whole range one way.
|
||||
public static let all: [GamepadPalette] = [
|
||||
// --- dark fields (white ink) ---
|
||||
GamepadPalette(
|
||||
id: "violet", name: "Violet", stops: [],
|
||||
ground: SIMD3(0.075, 0.060, 0.160), accent: SIMD3(0.525, 0.471, 0.961), light: false),
|
||||
GamepadPalette(
|
||||
// For OLED and AMOLED panels, where a black pixel is a pixel switched off — no glow,
|
||||
// no power. The first two stops are literally (0,0,0), so the shaded half of the
|
||||
// field is genuinely off rather than "very dark grey", and the ground is pure black
|
||||
// too: the calm mix on the form screens lifts toward nothing. What is left is a
|
||||
// faint indigo→violet ember in the bright corner. The accent stays the brand violet
|
||||
// — focus has to be findable on black.
|
||||
id: "oled", name: "OLED",
|
||||
stops: [SIMD3(0.000, 0.000, 0.000), SIMD3(0.000, 0.000, 0.000),
|
||||
SIMD3(0.010, 0.020, 0.100), SIMD3(0.045, 0.016, 0.115),
|
||||
SIMD3(0.120, 0.024, 0.130)],
|
||||
ground: SIMD3(0, 0, 0), accent: SIMD3(0.525, 0.471, 0.961), light: false),
|
||||
GamepadPalette(
|
||||
// Deep indigo climbing through violet into a hot magenta.
|
||||
id: "nebula", name: "Nebula",
|
||||
|
||||
@@ -53,11 +53,22 @@ final class AudioRingDriftTests: XCTestCase {
|
||||
XCTAssertEqual(silent, 0, "drift correction must never starve the callback")
|
||||
}
|
||||
|
||||
/// The mirror case: a host clock running SLOW must keep audio flowing rather than being
|
||||
/// "corrected" into a stutter.
|
||||
func testNegativeDriftKeepsPlaying() {
|
||||
/// The mirror case: a host clock running SLOW is a genuine deficit — no depth is ever deep
|
||||
/// enough forever — so the ring must spend it on RARE, clean re-banks (a hollow ring
|
||||
/// re-primes on its first click and refills the whole target) rather than riding the knife
|
||||
/// edge in permanent sub-frame chatter, which is what "silence-free" used to hide: every
|
||||
/// callback a fraction of a frame short, none of them fully silent, all of them audible.
|
||||
/// −200 ppm is an exaggeration of real DAC skew (tens of ppm); even so, two minutes may
|
||||
/// cost at most a couple of refills' worth of silent callbacks.
|
||||
func testNegativeDriftBanksRarelyInsteadOfChattering() {
|
||||
let (_, _, silent) = simulate(ms: 2 * 60 * 1_000, quantumMS: 5, driftPPM: -200)
|
||||
XCTAssertEqual(silent, 0, "a draining ring must re-prime, not chatter")
|
||||
XCTAssertLessThanOrEqual(
|
||||
silent, 24,
|
||||
"a draining ring re-banks a few times; a silent-callback stream means it is thrashing")
|
||||
XCTAssertGreaterThan(
|
||||
silent, 0,
|
||||
"a persistent deficit cannot be ridden out silence-free — if this is zero the ring "
|
||||
+ "is back to sub-frame chatter, which is audible without ever being silent")
|
||||
}
|
||||
|
||||
/// A device that pulls a large quantum cannot sustain a target below it — the ring must lift
|
||||
@@ -79,11 +90,14 @@ final class AudioRingDriftTests: XCTestCase {
|
||||
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
|
||||
XCTAssertTrue(scratch.contains { $0 != 0 }, "should be playing after priming")
|
||||
|
||||
// Drain it dry with one oversized read, then feed a normal quantum again. The length comes
|
||||
// off the buffer pointer, not off `huge`: touching the array inside the closure that is
|
||||
// already holding it exclusively is an exclusivity violation.
|
||||
var huge = [Float](repeating: 0, count: 200 * perMS)
|
||||
huge.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: $0.count) }
|
||||
// Drain it dry at the device's own quantum — an oversized read would count as ITS OWN
|
||||
// huge callback and legitimately read as hollow — then starve one callback and feed a
|
||||
// normal quantum again. The ring is freshly primed, so its depth average is nowhere near
|
||||
// hollow, and one short read must ride on the hysteresis.
|
||||
while ring.bufferedMS > 0 {
|
||||
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
|
||||
}
|
||||
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
|
||||
let feed = [Float](repeating: 0.5, count: want)
|
||||
feed.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: want) }
|
||||
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
|
||||
@@ -92,14 +106,17 @@ final class AudioRingDriftTests: XCTestCase {
|
||||
"a single short read must not force a full re-prime")
|
||||
}
|
||||
|
||||
/// Mirror of the Rust `target_grows_on_underruns_and_relaxes_when_quiet`: clustered genuine
|
||||
/// underruns raise the target floor (that session needs the slack), a long quiet spell gives
|
||||
/// it back — and the floor never dips below the base.
|
||||
/// Mirror of the Rust `target_grows_on_underruns_and_relaxes_when_quiet`, updated for
|
||||
/// near-miss growth: the drain's LAST full read (less than a frame left over) already grows
|
||||
/// the floor before anything was audible, clustered genuine underruns raise it further, and
|
||||
/// a long — genuinely quiet — spell gives it back, never below the base. The quiet refill
|
||||
/// runs DEEP: a knife-edge refill (exactly what each read takes) leaves the ring within a
|
||||
/// frame of empty every callback, which now correctly reads as pressure, not quiet.
|
||||
func testTargetGrowsOnUnderrunsAndRelaxesWhenQuiet() {
|
||||
let ring = AudioRing(capacity: 48_000 * channels, channels: channels)
|
||||
let want = 5 * perMS
|
||||
var scratch = [Float](repeating: 0, count: want)
|
||||
let feed = [Float](repeating: 0.5, count: 25 * perMS)
|
||||
let feed = [Float](repeating: 0.5, count: 60 * perMS)
|
||||
func write(ms: Int) {
|
||||
feed.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: ms * perMS) }
|
||||
}
|
||||
@@ -108,20 +125,27 @@ final class AudioRingDriftTests: XCTestCase {
|
||||
}
|
||||
XCTAssertEqual(ring.stats.targetMS, 20, "base target must match JitterTuning.COREAUDIO")
|
||||
|
||||
// Prime, drain dry, then alternate starve/refill: each dry read is a genuine underrun,
|
||||
// each full read in between keeps the de-prime hysteresis from tripping.
|
||||
// Prime, then drain: the 5th read is still served in full but leaves nothing over — a
|
||||
// near-miss, and the floor grows BEFORE any click.
|
||||
write(ms: 25)
|
||||
for _ in 0..<5 { read() } // drains to zero
|
||||
for _ in 0..<5 { read() }
|
||||
XCTAssertEqual(ring.stats.targetMS, 30, "a near-miss must grow the floor pre-click")
|
||||
XCTAssertEqual(ring.stats.underruns, 0, "nothing was audible yet")
|
||||
|
||||
// Then alternate starve/refill: each dry read is a genuine underrun, each full read in
|
||||
// between keeps the de-prime hysteresis from tripping. (The refills land as further
|
||||
// near-misses, but growth is one step per window — the cluster is what grows it again.)
|
||||
read() // short — underrun 1
|
||||
write(ms: 5); read() // full — hysteresis reset
|
||||
read() // short — underrun 2
|
||||
write(ms: 5); read() // full
|
||||
read() // short — underrun 3 → the floor grows one step
|
||||
XCTAssertEqual(ring.stats.targetMS, 30, "3 clustered underruns must grow the target 10 ms")
|
||||
XCTAssertEqual(ring.stats.targetMS, 40, "3 clustered underruns must grow the target 10 ms")
|
||||
XCTAssertEqual(ring.stats.underruns, 3)
|
||||
|
||||
// A long clean run (30 s of consumed audio) relaxes the growth back to the base…
|
||||
for _ in 0..<(30_000 / 5 + 10) {
|
||||
// A long clean run at a healthy depth relaxes the growth back to the base…
|
||||
write(ms: 60)
|
||||
for _ in 0..<(90_000 / 5 + 10) {
|
||||
write(ms: 5)
|
||||
read()
|
||||
}
|
||||
@@ -435,10 +459,14 @@ final class AudioRingDriftTests: XCTestCase {
|
||||
write(ms: 5); read()
|
||||
read()
|
||||
}
|
||||
/// Quiet (full) reads needed before the grown target relaxes one step.
|
||||
/// Quiet (full) reads needed before the grown target relaxes one step. The ring is
|
||||
/// refilled DEEP first: a knife-edge refill (exactly what each read takes) leaves less
|
||||
/// than a frame over every callback, which now correctly reads as pressure — near-misses
|
||||
/// — and pressure never relaxes anything.
|
||||
func quietToRelax(_ ring: AudioRing) -> Int {
|
||||
var scratch = [Float](repeating: 0, count: want)
|
||||
let feed = [Float](repeating: 0.5, count: 5 * perMS)
|
||||
let feed = [Float](repeating: 0.5, count: 60 * perMS)
|
||||
feed.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: 60 * perMS) }
|
||||
let start = ring.stats.targetMS
|
||||
var reads = 0
|
||||
while ring.stats.targetMS == start, reads < 200_000 {
|
||||
@@ -466,6 +494,118 @@ final class AudioRingDriftTests: XCTestCase {
|
||||
"sync pressure should relax sooner: \(fastReads) vs \(slowReads) quiet reads")
|
||||
}
|
||||
|
||||
/// A shrink answered by an underrun or near-miss inside its probe window is undone AT ONCE,
|
||||
/// and the sync loop is backed off — mirrors the Rust `a_failed_shrink_probe_is_undone_at_once`
|
||||
/// and `a_failed_probe_backs_the_sync_shrink_off`. Before this, the loop re-probed a proven
|
||||
/// depth every five quiet seconds and paid an audible starvation event each time it was wrong,
|
||||
/// forever — the 0.25.0 MacBook field report.
|
||||
func testAFailedShrinkProbeIsUndoneAtOnceAndBacksTheSyncLoopOff() {
|
||||
let ring = AudioRing(capacity: 48_000 * channels, channels: channels)
|
||||
let want = 5 * perMS
|
||||
var scratch = [Float](repeating: 0, count: want)
|
||||
let feed = [Float](repeating: 0.5, count: 60 * perMS)
|
||||
func write(ms: Int) {
|
||||
feed.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: ms * perMS) }
|
||||
}
|
||||
func read() {
|
||||
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
|
||||
}
|
||||
// Grow the floor (near-miss + a cluster of genuine underruns), as the usual pattern does.
|
||||
write(ms: 25)
|
||||
for _ in 0..<5 { read() }
|
||||
read()
|
||||
write(ms: 5); read()
|
||||
read()
|
||||
write(ms: 5); read()
|
||||
read()
|
||||
let grown = ring.stats.targetMS
|
||||
XCTAssertGreaterThan(grown, 20, "the test needs a GROWN floor to probe")
|
||||
|
||||
// Sync asks for less; a deep, genuinely quiet spell later the shrink probes.
|
||||
ring.setSyncTarget(perMS)
|
||||
write(ms: 60)
|
||||
var reads = 0
|
||||
while ring.stats.targetMS == grown, reads < 10_000 {
|
||||
write(ms: 5)
|
||||
read()
|
||||
reads += 1
|
||||
}
|
||||
XCTAssertEqual(ring.stats.targetMS, grown - 10, "the sync-driven shrink must have probed")
|
||||
|
||||
// Drain to the knife edge: the last full read leaves nothing over — a near-miss, nobody
|
||||
// heard anything — and the probe must be undone on the spot.
|
||||
while ring.bufferedMS > 5 { read() }
|
||||
read()
|
||||
XCTAssertEqual(
|
||||
ring.stats.targetMS, grown,
|
||||
"a failed probe must restore the target on the first near-miss")
|
||||
XCTAssertEqual(ring.stats.underruns, 3, "and nothing audible may have paid for it")
|
||||
|
||||
// Backed off: two accelerated windows of clean, deep audio must NOT shrink again…
|
||||
write(ms: 60)
|
||||
for _ in 0..<(2 * 5_000 / 5) {
|
||||
write(ms: 5)
|
||||
read()
|
||||
}
|
||||
XCTAssertEqual(
|
||||
ring.stats.targetMS, grown,
|
||||
"the five-second cadence must be suspended after a failure")
|
||||
// …while the slow, pre-sync window eventually still tests one — backoff is not a freeze.
|
||||
for _ in 0..<(2 * 30_000 / 5) {
|
||||
write(ms: 5)
|
||||
read()
|
||||
}
|
||||
XCTAssertLessThan(
|
||||
ring.stats.targetMS, grown,
|
||||
"the slow window must still be allowed to test a shrink")
|
||||
}
|
||||
|
||||
/// Growth raises a promise; only a re-prime banks real depth. An underrun while the ring is
|
||||
/// HOLLOW — its depth AVERAGE far below the target — re-primes immediately, spending the click
|
||||
/// it already cost on the whole refill, instead of riding the knife edge and clicking once per
|
||||
/// bunching period indefinitely. The average, not the instant, is what separates a hollow ring
|
||||
/// from one late packet (`testSingleShortReadDoesNotDeprime` pins that side).
|
||||
func testAHollowRingReprimesOnItsFirstClick() {
|
||||
let ring = AudioRing(capacity: 48_000 * channels, channels: channels)
|
||||
let want = 5 * perMS
|
||||
var scratch = [Float](repeating: 0, count: want)
|
||||
let feed = [Float](repeating: 0.5, count: 60 * perMS)
|
||||
func write(ms: Int) {
|
||||
feed.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: ms * perMS) }
|
||||
}
|
||||
func read() {
|
||||
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
|
||||
}
|
||||
// Grow the floor to 40 the usual way…
|
||||
write(ms: 25)
|
||||
for _ in 0..<5 { read() }
|
||||
read()
|
||||
write(ms: 5); read()
|
||||
read()
|
||||
write(ms: 5); read()
|
||||
read()
|
||||
XCTAssertEqual(ring.stats.targetMS, 40)
|
||||
// …then ride the knife edge for ~2 s of audio, so the depth average genuinely sinks far
|
||||
// below the promised 40 ms.
|
||||
for _ in 0..<400 {
|
||||
write(ms: 5)
|
||||
read()
|
||||
}
|
||||
// One dry read — the click. The ring is hollow, so this single click must re-prime.
|
||||
read()
|
||||
// A packet arrives, but the ring stays SILENT: it is re-priming toward the full target
|
||||
// rather than playing the packet and clicking again at the next bunch.
|
||||
write(ms: 10)
|
||||
read()
|
||||
XCTAssertTrue(
|
||||
scratch.allSatisfy { $0 == 0 },
|
||||
"a hollow ring must spend its click on the whole refill, not keep limping")
|
||||
// And once the refill reaches the target, it plays again.
|
||||
write(ms: 40)
|
||||
read()
|
||||
XCTAssertTrue(scratch.contains { $0 != 0 }, "refilled to target — playback resumes")
|
||||
}
|
||||
|
||||
/// The four client rings adopt sync one at a time; an un-wired one must behave exactly as it
|
||||
/// did. `nil` is the default, so this pins the initializer too — and every other test in this
|
||||
/// file runs without a sync target, which is the real guard that nothing moved underneath them.
|
||||
|
||||
@@ -46,12 +46,29 @@ final class GamepadPaletteTests: XCTestCase {
|
||||
func testTableMatchesTheOtherClients() {
|
||||
XCTAssertEqual(
|
||||
GamepadPalette.all.map(\.id),
|
||||
["violet", "nebula", "abyss", "ember", "moss", "graphite",
|
||||
["violet", "oled", "nebula", "abyss", "ember", "moss", "graphite",
|
||||
"holo", "sunset", "bloom", "dawn", "mint", "opal"])
|
||||
// Dark fields lead, pale ones follow, so stepping the row walks one direction.
|
||||
let firstLight = GamepadPalette.all.firstIndex { $0.light }
|
||||
XCTAssertEqual(firstLight, 6)
|
||||
XCTAssertTrue(GamepadPalette.all.dropFirst(6).allSatisfy(\.light))
|
||||
XCTAssertEqual(firstLight, 7)
|
||||
XCTAssertTrue(GamepadPalette.all.dropFirst(7).allSatisfy(\.light))
|
||||
}
|
||||
|
||||
/// OLED is the one palette whose selling point is measurable: it has to be genuinely black,
|
||||
/// not merely the darkest of the dark fields.
|
||||
func testOLEDIsActuallyBlack() {
|
||||
let oled = GamepadPalette.named("oled")
|
||||
XCTAssertEqual(oled.ground, SIMD3(0, 0, 0), "the calm lift must be nothing")
|
||||
let cells = oled.meshColors
|
||||
XCTAssertGreaterThanOrEqual(
|
||||
cells.filter { luma($0) == 0 }.count, 3,
|
||||
"the shaded corner has to be switched off, not dimmed")
|
||||
let mean = cells.map(luma).reduce(0, +) / Double(cells.count)
|
||||
let darkestOther = GamepadPalette.all
|
||||
.filter { $0.id != "oled" }
|
||||
.map { p in p.meshColors.map(luma).reduce(0, +) / Double(p.meshColors.count) }
|
||||
.min() ?? 0
|
||||
XCTAssertLessThan(mean, darkestOther / 2, "oled is barely darker than \(darkestOther)")
|
||||
}
|
||||
|
||||
/// A palette must read as SEVERAL hues, not one hue at several brightnesses — that was
|
||||
|
||||
@@ -1,14 +1,58 @@
|
||||
// GamepadUIEnvironment.isActive is a pure AND — table-tested exhaustively over its 2x2 inputs.
|
||||
// GamepadUIEnvironment.isActive is pure — table-tested exhaustively over its inputs.
|
||||
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
final class GamepadUIEnvironmentTests: XCTestCase {
|
||||
func testActiveOnlyWhenEnabledAndConnected() {
|
||||
XCTAssertTrue(GamepadUIEnvironment.isActive(gamepadConnected: true, enabledSetting: true))
|
||||
XCTAssertFalse(GamepadUIEnvironment.isActive(gamepadConnected: true, enabledSetting: false))
|
||||
XCTAssertFalse(GamepadUIEnvironment.isActive(gamepadConnected: false, enabledSetting: true))
|
||||
XCTAssertFalse(GamepadUIEnvironment.isActive(gamepadConnected: false, enabledSetting: false))
|
||||
private let connected = GamepadUIEnvironment.modeWhenConnected
|
||||
private let always = GamepadUIEnvironment.modeAlways
|
||||
|
||||
/// The default mode is the behaviour the switch had when it was a lone Bool, so an install
|
||||
/// that never sees the new row is exactly where it was.
|
||||
func testWhenConnectedIsAPlainAnd() {
|
||||
XCTAssertTrue(
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: true, enabledSetting: true, mode: connected))
|
||||
XCTAssertFalse(
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: true, enabledSetting: false, mode: connected))
|
||||
XCTAssertFalse(
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: false, enabledSetting: true, mode: connected))
|
||||
XCTAssertFalse(
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: false, enabledSetting: false, mode: connected))
|
||||
}
|
||||
|
||||
/// Always drops the controller from the decision entirely — but NOT the switch, which stays
|
||||
/// the one way back to the touch UI.
|
||||
func testAlwaysIgnoresTheControllerButNotTheSwitch() {
|
||||
XCTAssertTrue(
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: false, enabledSetting: true, mode: always))
|
||||
XCTAssertTrue(
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: true, enabledSetting: true, mode: always))
|
||||
XCTAssertFalse(
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: false, enabledSetting: false, mode: always))
|
||||
XCTAssertFalse(
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: true, enabledSetting: false, mode: always))
|
||||
}
|
||||
|
||||
/// A value a newer client wrote must wait for a controller, never strand this build in a
|
||||
/// layout it has no way back out of.
|
||||
func testUnknownModeWaitsForAController() {
|
||||
XCTAssertFalse(
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: false, enabledSetting: true, mode: "whenever-i-say-so"))
|
||||
XCTAssertTrue(
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: true, enabledSetting: true, mode: "whenever-i-say-so"))
|
||||
XCTAssertFalse(
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: false, enabledSetting: true, mode: ""))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1174,12 +1174,12 @@ pub struct Settings {
|
||||
/// mirrors the Apple client's "Show game library" toggle, default off.
|
||||
pub library_enabled: bool,
|
||||
/// Which colour family the gamepad UI's living backdrop drifts through — the shared
|
||||
/// `ui_palette` key (`"violet"` = the brand default, then `tide`/`forest`/`ember`/
|
||||
/// `rose`/`graphite`; see `pf-console-ui`'s palette table, and the Apple/Android
|
||||
/// clients' twins). Presentation only: nothing about a stream depends on it, which is
|
||||
/// why it is a device preference and never part of a settings profile. An unknown
|
||||
/// name reads as the default rather than erroring — a newer client may have shipped a
|
||||
/// palette this binary doesn't know.
|
||||
/// `ui_palette` key (`"violet"` = the brand default, then `oled`/`nebula`/`abyss`/
|
||||
/// `ember`/`moss`/`graphite`, then the six pale fields; see `pf-console-ui`'s palette
|
||||
/// table, and the Apple/Android clients' twins). Presentation only: nothing about a
|
||||
/// stream depends on it, which is why it is a device preference and never part of a
|
||||
/// settings profile. An unknown name reads as the default rather than erroring — a
|
||||
/// newer client may have shipped a palette this binary doesn't know.
|
||||
#[serde(default = "default_ui_palette")]
|
||||
pub ui_palette: String,
|
||||
/// Send Wake-on-LAN before connecting to a saved host and wait for it to boot (the
|
||||
|
||||
@@ -246,17 +246,34 @@ const CELL_RAMP: [f64; 16] = [
|
||||
-0.10, 0.08, -0.06, 0.12,
|
||||
];
|
||||
|
||||
/// The twelve shipped palettes: the brand default, five more dark fields, then six pale ones.
|
||||
/// The thirteen shipped palettes: the brand default, six more dark fields, then six pale ones.
|
||||
/// Cycling order runs dark → light, so stepping the row walks the whole range in one direction.
|
||||
/// Adding one here adds it to every console settings screen; the Apple and Android tables must
|
||||
/// gain the same entry to keep the `ui_palette` key portable.
|
||||
#[rustfmt::skip]
|
||||
pub const PALETTES: [Palette; 12] = [
|
||||
pub const PALETTES: [Palette; 13] = [
|
||||
// --- dark fields (white ink) ---
|
||||
Palette {
|
||||
id: "violet", name: "Violet", stops: None,
|
||||
ground: (0.075, 0.060, 0.160), accent: (0.525, 0.471, 0.961), light: false,
|
||||
},
|
||||
Palette {
|
||||
// For OLED and AMOLED panels, where a black pixel is a pixel switched off — no glow,
|
||||
// no power. The ramp's first two stops are literally (0,0,0), so the whole shaded half
|
||||
// of the field is genuinely off rather than "very dark grey", and the ground is pure
|
||||
// black too: the calm mix on the form screens lifts toward nothing, so settings and
|
||||
// pairing sit on an unlit panel. What is left is a faint indigo→violet ember in the
|
||||
// bright corner, dim enough to stay under a tenth of the other dark fields' mean
|
||||
// luminance while keeping the backdrop a field with somewhere to go rather than a
|
||||
// dead rectangle. The accent stays the brand violet — focus has to be findable on
|
||||
// black.
|
||||
id: "oled", name: "OLED",
|
||||
stops: Some(&[
|
||||
(0.000, 0.000, 0.000), (0.000, 0.000, 0.000), (0.010, 0.020, 0.100),
|
||||
(0.045, 0.016, 0.115), (0.120, 0.024, 0.130),
|
||||
]),
|
||||
ground: (0.0, 0.0, 0.0), accent: (0.525, 0.471, 0.961), light: false,
|
||||
},
|
||||
Palette {
|
||||
// Deep indigo climbing through violet into a hot magenta.
|
||||
id: "nebula", name: "Nebula",
|
||||
@@ -857,7 +874,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
ids,
|
||||
[
|
||||
"violet", "nebula", "abyss", "ember", "moss", "graphite", "holo", "sunset",
|
||||
"violet", "oled", "nebula", "abyss", "ember", "moss", "graphite", "holo", "sunset",
|
||||
"bloom", "dawn", "mint", "opal",
|
||||
]
|
||||
);
|
||||
@@ -867,7 +884,36 @@ mod tests {
|
||||
.position(|p| p.light)
|
||||
.expect("some are light");
|
||||
assert!(PALETTES[first_light..].iter().all(|p| p.light));
|
||||
assert_eq!(first_light, 6);
|
||||
assert_eq!(first_light, 7);
|
||||
}
|
||||
|
||||
/// OLED is the one palette whose selling point is measurable: it has to be genuinely
|
||||
/// black, not merely the darkest of the dark fields. Pure black corners, a mean well
|
||||
/// under every other field's, and a ground that lifts to nothing on the form screens.
|
||||
#[test]
|
||||
fn oled_is_actually_black() {
|
||||
let luma = |c: (f64, f64, f64)| 0.2126 * c.0 + 0.7152 * c.1 + 0.0722 * c.2;
|
||||
let oled = palette("oled");
|
||||
assert_eq!(
|
||||
oled.ground,
|
||||
(0.0, 0.0, 0.0),
|
||||
"the calm lift must be nothing"
|
||||
);
|
||||
let cells = oled.mesh_colors();
|
||||
assert!(
|
||||
cells.iter().filter(|c| luma(**c) == 0.0).count() >= 3,
|
||||
"the shaded corner has to be switched off, not dimmed"
|
||||
);
|
||||
let mean = cells.iter().map(|c| luma(*c)).sum::<f64>() / 16.0;
|
||||
let darkest_other = PALETTES
|
||||
.iter()
|
||||
.filter(|p| p.id != "oled")
|
||||
.map(|p| p.mesh_colors().iter().map(|c| luma(*c)).sum::<f64>() / 16.0)
|
||||
.fold(f64::MAX, f64::min);
|
||||
assert!(
|
||||
mean < darkest_other / 2.0,
|
||||
"oled means {mean:.3}, only half a stop under {darkest_other:.3}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Every colour a palette produces stays in gamut, and a pale palette really is pale —
|
||||
|
||||
@@ -258,11 +258,17 @@ impl SettingsScreen {
|
||||
}
|
||||
}
|
||||
|
||||
/// The rows of the CURRENT tab. Profiles is built from the catalog: one row per
|
||||
/// profile, or the explainer placeholder while there are none.
|
||||
fn row_ids(&self) -> Vec<RowId> {
|
||||
/// The rows of the CURRENT tab, minus any whose setting has nothing to act on (see
|
||||
/// [`row_applies`]). Profiles is built from the catalog: one row per profile, or the
|
||||
/// explainer placeholder while there are none.
|
||||
fn row_ids(&self, ctx: &Ctx) -> Vec<RowId> {
|
||||
if self.tab != PROFILES_TAB {
|
||||
return TABS[self.tab].1.to_vec();
|
||||
return TABS[self.tab]
|
||||
.1
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| row_applies(*id, ctx.settings))
|
||||
.collect();
|
||||
}
|
||||
if self.profiles.is_empty() {
|
||||
vec![RowId::NoProfiles]
|
||||
@@ -271,6 +277,16 @@ impl SettingsScreen {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull the cursor back onto the list. Every tab but Profiles used to be a fixed length,
|
||||
/// so this only mattered on entry ([`show_tab`]); the smoothness buffer's row now comes
|
||||
/// and goes, and another writer (a desktop shell, a session's match-window persist) can
|
||||
/// take it away between frames while this screen is open.
|
||||
fn clamp_cursor(&mut self, len: usize) {
|
||||
if self.list.cursor >= len {
|
||||
self.list.jump_to(len.saturating_sub(1));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn tab_for_test(&self) -> usize {
|
||||
self.tab
|
||||
@@ -278,21 +294,22 @@ impl SettingsScreen {
|
||||
|
||||
/// L1/R1 (and Tab/PgUp/PgDn) — move one tab, wrapping (the strip is a ring, like A's
|
||||
/// value cycle), keeping each tab's own cursor.
|
||||
fn switch_tab(&mut self, delta: i32) -> Option<MenuPulse> {
|
||||
fn switch_tab(&mut self, delta: i32, ctx: &Ctx) -> Option<MenuPulse> {
|
||||
let n = TABS.len() as i32;
|
||||
self.show_tab((self.tab as i32 + delta).rem_euclid(n) as usize)
|
||||
self.show_tab((self.tab as i32 + delta).rem_euclid(n) as usize, ctx)
|
||||
}
|
||||
|
||||
/// Show `tab`, parking the cursor the outgoing tab was on. Also the pointer's path in:
|
||||
/// a press on a pill names a tab outright rather than a direction to step in.
|
||||
fn show_tab(&mut self, tab: usize) -> Option<MenuPulse> {
|
||||
fn show_tab(&mut self, tab: usize, ctx: &Ctx) -> Option<MenuPulse> {
|
||||
if tab >= TABS.len() {
|
||||
return None;
|
||||
}
|
||||
self.tab_cursors[self.tab] = self.list.cursor;
|
||||
self.tab = tab;
|
||||
// Clamp the remembered cursor: the Profiles tab's length follows the catalog.
|
||||
let len = self.row_ids().len();
|
||||
// Clamp the remembered cursor: the Profiles tab's length follows the catalog, and
|
||||
// Video's follows whether the smoothness buffer is offered.
|
||||
let len = self.row_ids(ctx).len();
|
||||
self.list
|
||||
.jump_to(self.tab_cursors[self.tab].min(len.saturating_sub(1)));
|
||||
Some(MenuPulse::Move)
|
||||
@@ -302,10 +319,11 @@ impl SettingsScreen {
|
||||
/// there is never meant for a row.
|
||||
pub(crate) fn pointer(&mut self, p: Pointer, ctx: &mut Ctx, fx: &mut Outbox) -> bool {
|
||||
if let Some(tab) = self.strip.pointer(p) {
|
||||
self.show_tab(tab);
|
||||
self.show_tab(tab, ctx);
|
||||
return true;
|
||||
}
|
||||
let ids = self.row_ids();
|
||||
let ids = self.row_ids(ctx);
|
||||
self.clamp_cursor(ids.len());
|
||||
let (msg, pulse) = self.list.pointer(p, ids.len());
|
||||
if matches!(msg, ListMsg::None) && pulse.is_none() {
|
||||
return false;
|
||||
@@ -325,11 +343,12 @@ impl SettingsScreen {
|
||||
fx.pop();
|
||||
return None;
|
||||
}
|
||||
MenuEvent::JumpBack => return self.switch_tab(-1),
|
||||
MenuEvent::JumpForward => return self.switch_tab(1),
|
||||
MenuEvent::JumpBack => return self.switch_tab(-1, ctx),
|
||||
MenuEvent::JumpForward => return self.switch_tab(1, ctx),
|
||||
_ => {}
|
||||
}
|
||||
let ids = self.row_ids();
|
||||
let ids = self.row_ids(ctx);
|
||||
self.clamp_cursor(ids.len());
|
||||
let (msg, pulse) = self.list.menu(ev, ids.len());
|
||||
self.apply_row(msg, pulse, &ids, ctx, fx)
|
||||
}
|
||||
@@ -344,8 +363,14 @@ impl SettingsScreen {
|
||||
ctx: &mut Ctx,
|
||||
fx: &mut Outbox,
|
||||
) -> Option<MenuPulse> {
|
||||
// A cursor with no row under it can only mean the list shrank between the clamp above
|
||||
// and here, which nothing does today — but indexing on the assumption would turn that
|
||||
// into a panic in a shipping console rather than a dropped keypress.
|
||||
let Some(&focused) = ids.get(self.list.cursor) else {
|
||||
return pulse;
|
||||
};
|
||||
// The Profiles rows navigate instead of editing the settings file.
|
||||
match ids[self.list.cursor] {
|
||||
match focused {
|
||||
RowId::Profile(i) => {
|
||||
return match msg {
|
||||
ListMsg::Activate => {
|
||||
@@ -378,7 +403,7 @@ impl SettingsScreen {
|
||||
}
|
||||
match msg {
|
||||
ListMsg::Adjust(delta) => {
|
||||
let changed = adjust(ids[self.list.cursor], delta, false, ctx);
|
||||
let changed = adjust(focused, delta, false, ctx);
|
||||
if changed {
|
||||
ctx.settings.save();
|
||||
Some(MenuPulse::Move)
|
||||
@@ -388,7 +413,7 @@ impl SettingsScreen {
|
||||
}
|
||||
ListMsg::Activate => {
|
||||
// A cycles forward WRAPPING, so every option is reachable one-handed.
|
||||
if adjust(ids[self.list.cursor], 1, true, ctx) {
|
||||
if adjust(focused, 1, true, ctx) {
|
||||
ctx.settings.save();
|
||||
}
|
||||
pulse
|
||||
@@ -397,8 +422,8 @@ impl SettingsScreen {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn hints(&self, _ctx: &Ctx) -> Vec<Hint> {
|
||||
let ids = self.row_ids();
|
||||
pub(crate) fn hints(&self, ctx: &Ctx) -> Vec<Hint> {
|
||||
let ids = self.row_ids(ctx);
|
||||
// The shoulders always change section, so that hint leads on every row.
|
||||
let mut hints = vec![Hint::new(HintKey::Shoulders, "Section")];
|
||||
hints.extend(match ids.get(self.list.cursor) {
|
||||
@@ -445,7 +470,8 @@ impl SettingsScreen {
|
||||
rect.right,
|
||||
rect.bottom - detail_h as f32,
|
||||
);
|
||||
let ids = self.row_ids();
|
||||
let ids = self.row_ids(ctx);
|
||||
self.clamp_cursor(ids.len());
|
||||
let rows: Vec<RowSpec> = ids
|
||||
.iter()
|
||||
.map(|id| row_spec(*id, ctx, &self.profiles))
|
||||
@@ -466,6 +492,24 @@ impl SettingsScreen {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a row is OFFERED at all, as opposed to offered-but-inert.
|
||||
///
|
||||
/// The two are a real distinction. Echo cancellation and the pad rows follow a switch the user
|
||||
/// can see a line or two above them, so dimming them shows the relationship — dropping them
|
||||
/// would just make settings appear and disappear as the switch flips. The smoothness buffer is
|
||||
/// different: it is not a sub-setting of a switch, it is a knob on ONE of two intents, and
|
||||
/// under Lowest latency it names a quantity that doesn't exist. Every other settings surface —
|
||||
/// the GTK and WinUI shells, the Apple touch/tvOS screens, the Android touch screen — hides it
|
||||
/// there. This screen was the lone exception because its row list was fixed; it is rebuilt from
|
||||
/// this filter each frame now, and the row it drops sits directly BELOW the row that drops it,
|
||||
/// so the cursor is never under anything that moves.
|
||||
fn row_applies(id: RowId, s: &pf_client_core::trust::Settings) -> bool {
|
||||
match id {
|
||||
RowId::SmoothBuffer => s.present_priority == "smooth",
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
// The Profiles section: name + how many hosts pin it (counted from the live rows, so
|
||||
// it reflects what the carousel shows). Read-only here beyond opening the pin screen.
|
||||
@@ -497,18 +541,17 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
_ => {}
|
||||
}
|
||||
let s = &ctx.settings;
|
||||
// Several rows follow another: echo cancellation only means anything while the mic
|
||||
// streams, the pad rows only while any controller is forwarded at all, and the
|
||||
// smoothness buffer only while that intent is chosen. All go dim and inert otherwise
|
||||
// — the same relationship the desktop shells draw by greying a row out (they hide the
|
||||
// buffer row entirely; a fixed row list can't, and a row that vanished mid-list would
|
||||
// move everything under the cursor).
|
||||
// Two rows follow a switch a line or two above them: echo cancellation only means
|
||||
// anything while the mic streams, and the pad rows only while any controller is
|
||||
// forwarded at all. Both go dim and inert otherwise — the same relationship the desktop
|
||||
// shells draw by greying a row out, and dimming (not dropping) is what shows the
|
||||
// relationship. The smoothness buffer used to be listed here too; it is dropped from the
|
||||
// list instead now — see [`row_applies`] for why that one is different.
|
||||
let enabled = match id {
|
||||
RowId::EchoCancel => s.mic_enabled,
|
||||
RowId::Pad | RowId::PadType | RowId::SystemButtons | RowId::GuideGesture => {
|
||||
s.gamepad_forwarding
|
||||
}
|
||||
RowId::SmoothBuffer => s.present_priority == "smooth",
|
||||
_ => true,
|
||||
};
|
||||
let (header, label, value): (Option<&'static str>, &str, String) = match id {
|
||||
@@ -848,7 +891,10 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
||||
step_option(cur, PRESENT_PRIORITIES.len(), delta, wrap)
|
||||
.map(|i| s.present_priority = PRESENT_PRIORITIES[i].0.to_string())
|
||||
}
|
||||
// Inert unless smoothness is chosen — a boundary thud, matching the dimmed row.
|
||||
// Under Lowest latency the row isn't offered at all ([`row_applies`]), so this branch
|
||||
// is only reachable if another writer flipped the intent between the frame that built
|
||||
// the list and the keypress that lands here — a boundary thud, not a stored value
|
||||
// nothing will read.
|
||||
RowId::SmoothBuffer => {
|
||||
if s.present_priority == "smooth" {
|
||||
let cur = SMOOTH_BUFFERS
|
||||
@@ -1093,9 +1139,6 @@ mod tests {
|
||||
fake_home();
|
||||
let mut s = SettingsScreen::with_profiles(Vec::new());
|
||||
rendered(&mut s);
|
||||
// Row 0 of the leading tab is Resolution, whose first step is Native → Match
|
||||
// window: one field, one unambiguous effect to assert on.
|
||||
assert_eq!(s.row_ids()[0], RowId::Resolution);
|
||||
let first = s.list.row_rect(0).expect("the list drew its rows");
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
settings.save(); // seat the fake HOME's file — `apply_row` rebases on it
|
||||
@@ -1109,6 +1152,9 @@ mod tests {
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
// Row 0 of the leading tab is Resolution, whose first step is Native → Match
|
||||
// window: one field, one unambiguous effect to assert on.
|
||||
assert_eq!(s.row_ids(&ctx)[0], RowId::Resolution);
|
||||
let mut fx = Outbox::default();
|
||||
assert!(!ctx.settings.match_window);
|
||||
assert!(s.pointer(press(first), &mut ctx, &mut fx));
|
||||
@@ -1232,13 +1278,12 @@ mod tests {
|
||||
assert!(ctx.settings.echo_cancel);
|
||||
}
|
||||
|
||||
/// The smoothness buffer follows the presentation intent, exactly as echo cancellation
|
||||
/// follows the mic: dimmed and inert under Lowest latency (where holding frames means
|
||||
/// nothing), live under Smoothness. The desktop shells hide the row instead; a fixed
|
||||
/// row list dims it, because a row vanishing mid-list would shift everything under the
|
||||
/// cursor.
|
||||
/// The smoothness buffer is OFFERED only under Smoothness — under Lowest latency it names
|
||||
/// a quantity that doesn't exist, so the row is gone from the Video tab rather than sitting
|
||||
/// there dimmed. This is what the GTK and WinUI shells and the Apple/Android screens have
|
||||
/// always done; this screen was the exception until its row list stopped being fixed.
|
||||
#[test]
|
||||
fn smoothness_buffer_follows_the_intent() {
|
||||
fn smoothness_buffer_is_offered_only_under_smoothness() {
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
assert_eq!(settings.present_priority, "latency", "the shipped default");
|
||||
let library = crate::library::LibraryShared::default();
|
||||
@@ -1251,24 +1296,93 @@ mod tests {
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
assert!(!row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled);
|
||||
let mut s = SettingsScreen::with_profiles(Vec::new());
|
||||
s.tab = TABS
|
||||
.iter()
|
||||
.position(|(name, _)| *name == "Video")
|
||||
.expect("the Video tab");
|
||||
|
||||
let video = s.row_ids(&ctx);
|
||||
assert!(
|
||||
!video.contains(&RowId::SmoothBuffer),
|
||||
"latency hides the buffer row: {video:?}"
|
||||
);
|
||||
assert!(video.contains(&RowId::PresentPriority), "the intent stays");
|
||||
// Even reached out of band it writes nothing — the list it came from is a frame old.
|
||||
assert!(
|
||||
!adjust(RowId::SmoothBuffer, 1, false, &mut ctx),
|
||||
"latency intent = thud"
|
||||
);
|
||||
assert_eq!(ctx.settings.smooth_buffer, 0, "and nothing was written");
|
||||
|
||||
// Stepping the intent to Smoothness brings the buffer row to life.
|
||||
// Stepping the intent to Smoothness brings the row into the list, directly under it.
|
||||
assert!(adjust(RowId::PresentPriority, 1, false, &mut ctx));
|
||||
assert_eq!(ctx.settings.present_priority, "smooth");
|
||||
assert!(row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled);
|
||||
let video = s.row_ids(&ctx);
|
||||
let intent = video
|
||||
.iter()
|
||||
.position(|id| *id == RowId::PresentPriority)
|
||||
.expect("the intent row");
|
||||
assert_eq!(
|
||||
video.get(intent + 1),
|
||||
Some(&RowId::SmoothBuffer),
|
||||
"the row that comes and goes sits BELOW the row that decides it, so the cursor \
|
||||
never has anything move out from under it"
|
||||
);
|
||||
assert!(adjust(RowId::SmoothBuffer, 1, false, &mut ctx));
|
||||
assert_eq!(ctx.settings.smooth_buffer, 1);
|
||||
|
||||
// The intent wraps back and the row goes inert again.
|
||||
// The intent wraps back and the row leaves again — with the cursor parked on the
|
||||
// intent row, which is where a user who just stepped it necessarily is.
|
||||
s.list.cursor = intent;
|
||||
assert!(adjust(RowId::PresentPriority, -1, false, &mut ctx));
|
||||
assert_eq!(ctx.settings.present_priority, "latency");
|
||||
assert!(!row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled);
|
||||
let video = s.row_ids(&ctx);
|
||||
assert!(!video.contains(&RowId::SmoothBuffer));
|
||||
assert_eq!(
|
||||
video.get(s.list.cursor),
|
||||
Some(&RowId::PresentPriority),
|
||||
"the cursor is still on the row the user was stepping"
|
||||
);
|
||||
}
|
||||
|
||||
/// A cursor parked past the end of a list that shrank underneath it is pulled back rather
|
||||
/// than indexed with — the console must not panic because another writer changed the
|
||||
/// presentation intent while its settings screen was open.
|
||||
#[test]
|
||||
fn a_shrinking_list_pulls_the_cursor_back() {
|
||||
// `apply_row` rebases on the FILE before acting, so this has to be seated — and
|
||||
// seated with the SHRUNKEN list's intent, which is the state being tested.
|
||||
fake_home();
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
settings.present_priority = "latency".into();
|
||||
settings.save();
|
||||
settings.present_priority = "smooth".into();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let mut ctx = Ctx {
|
||||
hosts: &[],
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
let mut s = SettingsScreen::with_profiles(Vec::new());
|
||||
s.tab = TABS
|
||||
.iter()
|
||||
.position(|(name, _)| *name == "Video")
|
||||
.expect("the Video tab");
|
||||
// Park on the last row while the buffer row is still there…
|
||||
s.list.cursor = s.row_ids(&ctx).len() - 1;
|
||||
let parked = s.list.cursor;
|
||||
// …then take it away behind the screen's back, as a desktop shell would.
|
||||
ctx.settings.present_priority = "latency".into();
|
||||
let mut fx = Outbox::default();
|
||||
let pulse = s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
|
||||
assert!(pulse.is_some(), "the press was routed, not dropped");
|
||||
assert!(s.list.cursor < parked, "the cursor came back onto the list");
|
||||
assert!(fx.nav.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1392,7 +1506,7 @@ mod tests {
|
||||
("p2".into(), "Game".into()),
|
||||
]);
|
||||
s.tab = PROFILES_TAB;
|
||||
let ids = s.row_ids();
|
||||
let ids = s.row_ids(&ctx);
|
||||
assert_eq!(ids, vec![RowId::Profile(0), RowId::Profile(1)]);
|
||||
|
||||
let spec = row_spec(RowId::Profile(0), &ctx, &s.profiles);
|
||||
@@ -1438,7 +1552,7 @@ mod tests {
|
||||
};
|
||||
let mut s = SettingsScreen::with_profiles(Vec::new());
|
||||
s.tab = PROFILES_TAB;
|
||||
let ids = s.row_ids();
|
||||
let ids = s.row_ids(&ctx);
|
||||
assert_eq!(ids, vec![RowId::NoProfiles]);
|
||||
let spec = row_spec(RowId::NoProfiles, &ctx, &s.profiles);
|
||||
assert!(!spec.enabled);
|
||||
|
||||
@@ -329,7 +329,7 @@ fn dump_console_screens() {
|
||||
for _ in 0..5 {
|
||||
s.handle_menu(MenuEvent::JumpForward);
|
||||
}
|
||||
for id in ["violet", "ember", "abyss", "holo", "sunset", "mint"] {
|
||||
for id in ["violet", "oled", "ember", "abyss", "holo", "sunset", "mint"] {
|
||||
s.settings.ui_palette = id.to_string();
|
||||
dump(&mut s, 40, 8, &format!("03-settings-{id}"), true);
|
||||
}
|
||||
|
||||
@@ -550,7 +550,15 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
Some((x, y)) => b.position(x, y),
|
||||
None => b.position_centered(),
|
||||
};
|
||||
b.resizable().vulkan();
|
||||
// HIGH_PIXEL_DENSITY: give us a backbuffer in the panel's REAL pixels. Without it
|
||||
// SDL leaves the Wayland surface at buffer scale 1, so on a fractionally scaled
|
||||
// output (KDE at 150 %: a 2560×1600 panel reported as 1707×1067 points) the
|
||||
// swapchain is built at 1707×1067 and the compositor upscales it to the glass —
|
||||
// a 2560×1600 stream is resampled DOWN and back UP, and looks it. The flag only
|
||||
// widens `size_in_pixels()`; `size()` stays logical, which is what the persisted
|
||||
// window size and SDL's own mouse coordinates are in, and both callers already
|
||||
// use the right one.
|
||||
b.resizable().vulkan().high_pixel_density();
|
||||
if opts.fullscreen {
|
||||
b.fullscreen();
|
||||
}
|
||||
@@ -644,11 +652,11 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
let native = window
|
||||
.get_display()
|
||||
.and_then(|d| d.get_mode())
|
||||
.map(|m| Mode {
|
||||
width: m.w.max(0) as u32,
|
||||
height: m.h.max(0) as u32,
|
||||
refresh_hz: m.refresh_rate.round().max(0.0) as u32,
|
||||
})
|
||||
.map(|m| native_mode(m.w, m.h, m.pixel_density, m.refresh_rate))
|
||||
.ok()
|
||||
// A zero-sized mode is as useless as no mode at all — only `Err` used to reach
|
||||
// the fallback, so a display that reported 0×0 streamed a 0×0 request.
|
||||
.filter(|m: &Mode| m.width > 0 && m.height > 0)
|
||||
.unwrap_or(Mode {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
@@ -2136,6 +2144,37 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// An `SDL_DisplayMode` as the panel's REAL pixels — the `0 = native` stream mode.
|
||||
///
|
||||
/// SDL3 reports a display mode in SCREEN COORDINATES, not pixels, and hands you the ratio
|
||||
/// between the two separately as `pixel_density`. On X11 and Windows that ratio is always
|
||||
/// 1.0 (SDL never sets it there, and `SDL_video.c` normalizes the unset 0.0 up to 1.0), so
|
||||
/// this is a no-op — but under a Wayland compositor doing FRACTIONAL scaling it is the
|
||||
/// whole ballgame: KDE at 150 % advertises a 2560×1600 panel as 1707×1067 points with
|
||||
/// `pixel_density` ≈ 1.4997, and taking `m.w`/`m.h` raw is what made "Native resolution"
|
||||
/// negotiate 1706×1066 (1707×1067 even-floored by `render_scale::apply`) and stream a
|
||||
/// blurry two-thirds-size image. `SDL_VIDEO_WAYLAND_SCALE_TO_DISPLAY=1` is the same fix
|
||||
/// from the outside — it makes SDL report the native mode itself — which is why setting it
|
||||
/// was a workaround.
|
||||
///
|
||||
/// The density is the exact `pixels / points` ratio SDL derived from the output, so the
|
||||
/// multiplication recovers the panel size to the pixel rather than approximating it.
|
||||
fn native_mode(w: i32, h: i32, pixel_density: f32, refresh_rate: f32) -> Mode {
|
||||
// A non-finite or non-positive density is SDL telling us nothing useful; 1× at least
|
||||
// preserves the pre-fix behaviour instead of collapsing the mode to zero.
|
||||
let density = if pixel_density.is_finite() && pixel_density > 0.0 {
|
||||
pixel_density
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let px = |v: i32| (v.max(0) as f32 * density).round().max(0.0) as u32;
|
||||
Mode {
|
||||
width: px(w),
|
||||
height: px(h),
|
||||
refresh_hz: refresh_rate.round().max(0.0) as u32,
|
||||
}
|
||||
}
|
||||
|
||||
/// Match-window (D1): replace the params' requested w/h with the window's physical pixel
|
||||
/// size — even-floored (the host's `validate_dimensions` rejects odd) and clamped to a
|
||||
/// sane minimum — keeping the resolved refresh. Under `--fullscreen` the window IS the
|
||||
@@ -2908,6 +2947,52 @@ fn stats_text(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The field report this exists for: CachyOS/KDE Plasma 6.7.4 Wayland, a 2560×1600@165
|
||||
/// laptop panel at 150 % scaling. KDE advertises the output as 1707×1067 points, SDL
|
||||
/// hands that back as the desktop mode with `pixel_density` = 2560/1707, and "Native
|
||||
/// resolution" streamed 1706×1066 — the points, even-floored by `render_scale::apply`.
|
||||
#[test]
|
||||
fn native_is_the_panels_pixels_under_fractional_wayland_scaling() {
|
||||
// SDL derives the density as the exact pixels-per-point ratio of the output.
|
||||
let density = 2560.0 / 1707.0;
|
||||
let m = native_mode(1707, 1067, density, 165.0);
|
||||
assert_eq!((m.width, m.height, m.refresh_hz), (2560, 1600, 165));
|
||||
// …and it survives the even-floor the host's `validate_dimensions` forces, which is
|
||||
// where 1707×1067 lost its odd pixel and became the reported 1706×1066.
|
||||
assert_eq!(
|
||||
punktfunk_core::render_scale::apply(m.width, m.height, 1.0, 8192),
|
||||
(2560, 1600)
|
||||
);
|
||||
assert_eq!(
|
||||
punktfunk_core::render_scale::apply(1707, 1067, 1.0, 8192),
|
||||
(1706, 1066),
|
||||
"the pre-fix mode, kept here so the regression is legible"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_is_unchanged_where_the_density_is_one() {
|
||||
// X11, Windows, and Wayland at 100 % all report 1.0 — the fix must be inert there.
|
||||
let m = native_mode(2560, 1600, 1.0, 165.0);
|
||||
assert_eq!((m.width, m.height, m.refresh_hz), (2560, 1600, 165));
|
||||
// Integer scaling (a 200 % 4K panel reported as 1920×1080 points) doubles cleanly.
|
||||
let m = native_mode(1920, 1080, 2.0, 60.0);
|
||||
assert_eq!((m.width, m.height), (3840, 2160));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nonsense_density_falls_back_to_one_rather_than_zeroing_the_mode() {
|
||||
// SDL normalizes an unset density to 1.0, but this must not be the one place a
|
||||
// driver quirk can hand the host a 0×0 mode request.
|
||||
for bogus in [0.0, -1.0, f32::NAN, f32::INFINITY] {
|
||||
let m = native_mode(2560, 1600, bogus, 60.0);
|
||||
assert_eq!((m.width, m.height), (2560, 1600), "density {bogus}");
|
||||
}
|
||||
// A negative mode size is clamped, not wrapped into a huge u32.
|
||||
let m = native_mode(-1, -1, 1.5, 60.0);
|
||||
assert_eq!((m.width, m.height), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_scale_follows_dpi_and_survives_a_bogus_display() {
|
||||
// 100 % / 96 dpi is the identity — the chrome keeps the size it always had.
|
||||
|
||||
@@ -497,6 +497,31 @@ const SHRINK_QUIET_MS: u32 = 30_000;
|
||||
/// The same, while the A/V sync loop is actively asking for a shallower ring — see the branch in
|
||||
/// [`JitterPolicy::note_read`] that selects between them.
|
||||
const SHRINK_QUIET_SYNC_MS: u32 = 5_000;
|
||||
/// Post-read depth below which a served callback counts as a NEAR-MISS: the device got its
|
||||
/// samples, but less than one protocol frame was left in hand, so the next callback starves
|
||||
/// unless a packet lands inside one frame time. On a healthy link the post-read depth hovers a
|
||||
/// whole target above this, which is what makes a near-miss evidence of real delivery jitter —
|
||||
/// the same evidence as an underrun, except nobody heard it yet.
|
||||
const NEAR_MISS_MARGIN_MS: u32 = FRAME_MS;
|
||||
/// How long a shrink remains a PROBE, in consumed audio: an underrun or near-miss inside this
|
||||
/// window means the shrink was wrong, and the previous target is restored at once instead of
|
||||
/// being re-learned three audible underruns at a time.
|
||||
const SHRINK_PROBE_MS: u32 = 5_000;
|
||||
/// A ring is HOLLOW when its depth AVERAGE sits this far below the target: the target promises a
|
||||
/// depth the ring does not actually hold. Growth only ever raises the promise — the one thing
|
||||
/// that re-banks real depth is a re-prime — so an underrun in a hollow ring re-primes AT ONCE:
|
||||
/// the click has already happened, and spending it on the whole refill is strictly better than
|
||||
/// riding the knife edge and paying a click per bunching period indefinitely, which is what the
|
||||
/// consecutive-empties hysteresis alone converges to. A full ring's underrun (one packet a few
|
||||
/// ms late) is nowhere near hollow and keeps the hysteresis.
|
||||
const DEPRIME_DEBT_MS: u32 = GROW_STEP_MS;
|
||||
/// How long a failed probe keeps the sync loop from driving another shrink. Without this the
|
||||
/// loop pays an audible starvation event every [`SHRINK_QUIET_SYNC_MS`] on any link whose jitter
|
||||
/// genuinely needs the depth — sync asks for less, the ring shrinks, the link answers, the ring
|
||||
/// grows back, five quiet seconds later sync asks again, forever. Doubles per consecutive
|
||||
/// failure up to [`SYNC_BACKOFF_MAX_MS`]; a probe that survives its window resets it.
|
||||
const SYNC_BACKOFF_MS: u32 = 60_000;
|
||||
const SYNC_BACKOFF_MAX_MS: u32 = 480_000;
|
||||
|
||||
/// The playback de-jitter state machine shared by every client's audio ring.
|
||||
///
|
||||
@@ -539,6 +564,24 @@ pub struct JitterPolicy {
|
||||
/// behaviour exactly, which is what lets the four client rings adopt this one at a time
|
||||
/// without diverging in the meantime.
|
||||
sync_target: Option<usize>,
|
||||
/// Set by [`step`](Self::step) when the read it authorised leaves less than
|
||||
/// [`NEAR_MISS_MARGIN_MS`] buffered; consumed by [`note_read`](Self::note_read).
|
||||
near_miss: bool,
|
||||
/// A near-miss already grew the target this window — one step per window, so a single
|
||||
/// bunching episode (which lands as a RUN of consecutive near-misses while the ring refills)
|
||||
/// buys one measured step, not a sprint to the ceiling.
|
||||
near_miss_grown: bool,
|
||||
/// Set by [`step`](Self::step): the depth average sits more than [`DEPRIME_DEBT_MS`] below
|
||||
/// the target, so an underrun should re-prime at once instead of waiting out the hysteresis.
|
||||
hollow: bool,
|
||||
/// Consumed samples left in the current shrink-probe window (0 = no probe outstanding).
|
||||
probe_run: usize,
|
||||
/// The live target before the probed shrink, restored if the probe fails.
|
||||
probe_prev_target: usize,
|
||||
/// Consumed samples before the sync loop may drive another shrink (0 = allowed now).
|
||||
sync_backoff_run: usize,
|
||||
/// Length of the NEXT backoff, in ms — doubles per consecutive failed probe, capped.
|
||||
sync_backoff_ms: u32,
|
||||
}
|
||||
|
||||
impl JitterPolicy {
|
||||
@@ -558,6 +601,13 @@ impl JitterPolicy {
|
||||
quiet_run: 0,
|
||||
last_want: 0,
|
||||
sync_target: None,
|
||||
near_miss: false,
|
||||
near_miss_grown: false,
|
||||
hollow: false,
|
||||
probe_run: 0,
|
||||
probe_prev_target: 0,
|
||||
sync_backoff_run: 0,
|
||||
sync_backoff_ms: SYNC_BACKOFF_MS,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -667,8 +717,26 @@ impl JitterPolicy {
|
||||
if !self.primed && depth.saturating_sub(out.drop_front) >= target {
|
||||
self.primed = true;
|
||||
self.empties = 0;
|
||||
// The refill just banked this much: seed the average with it rather than letting it
|
||||
// climb from wherever the drought left it — a freshly-primed ring would otherwise
|
||||
// read as hollow for the EWMA's whole settling time, and the FIRST late packet
|
||||
// would re-prime a ring that is actually full.
|
||||
self.depth_avg = depth.saturating_sub(out.drop_front) as f32;
|
||||
}
|
||||
out.silence = !self.primed;
|
||||
// Near-miss: this read will be served, but with less than one frame left over — the
|
||||
// next callback starves unless a packet lands within one frame time. Unconditional
|
||||
// assignment, so a stale flag can never survive a de-prime into the next primed read.
|
||||
let after = depth.saturating_sub(out.drop_front);
|
||||
self.near_miss = self.primed
|
||||
&& after >= want
|
||||
&& after - want < NEAR_MISS_MARGIN_MS as usize * self.per_ms;
|
||||
// Hollow: the depth AVERAGE runs a debt against the target — the promise has been raised
|
||||
// but the depth was never re-banked (see `DEPRIME_DEBT_MS`). Judged on the average, not
|
||||
// this instant: a single late packet empties the ring for a callback without making it
|
||||
// hollow, and must keep the consecutive-empties hysteresis.
|
||||
self.hollow = self.primed
|
||||
&& (self.depth_avg as usize + DEPRIME_DEBT_MS as usize * self.per_ms) < target;
|
||||
out
|
||||
}
|
||||
|
||||
@@ -683,19 +751,51 @@ impl JitterPolicy {
|
||||
return;
|
||||
}
|
||||
let want = self.last_want.max(1);
|
||||
let near_miss = std::mem::take(&mut self.near_miss);
|
||||
self.window_run += want;
|
||||
if self.window_run >= GROW_WINDOW_MS as usize * self.per_ms {
|
||||
self.window_run = 0;
|
||||
self.underruns = 0;
|
||||
self.near_miss_grown = false;
|
||||
}
|
||||
self.sync_backoff_run = self.sync_backoff_run.saturating_sub(want);
|
||||
let mut restored = false;
|
||||
if self.probe_run > 0 {
|
||||
self.probe_run = self.probe_run.saturating_sub(want);
|
||||
if ran_short || near_miss {
|
||||
// The probe FAILED: the link answered a shrink with (nearly) starving the ring.
|
||||
// Take the depth straight back — re-learning it three audible underruns at a
|
||||
// time is what made the sync-vs-growth tug-of-war audible — and keep the sync
|
||||
// loop from probing again for a while, doubling per consecutive failure. The
|
||||
// residual A/V offset is reported instead; continuity outranks sync. The
|
||||
// restore CONSUMES this event as growth evidence: it answered a depth the ring
|
||||
// is no longer at, so growing past the proven target on top would overshoot.
|
||||
self.probe_run = 0;
|
||||
self.target = self.target.max(self.probe_prev_target);
|
||||
self.sync_backoff_run = self.sync_backoff_ms as usize * self.per_ms;
|
||||
self.sync_backoff_ms = (self.sync_backoff_ms * 2).min(SYNC_BACKOFF_MAX_MS);
|
||||
restored = true;
|
||||
} else if self.probe_run == 0 {
|
||||
// Survived the whole window: the shallower depth is genuinely safe here, so the
|
||||
// next probe starts from a clean slate.
|
||||
self.sync_backoff_ms = SYNC_BACKOFF_MS;
|
||||
}
|
||||
}
|
||||
if ran_short {
|
||||
self.quiet_run = 0;
|
||||
self.empties += 1;
|
||||
if self.empties >= self.tuning.deprime_after {
|
||||
if self.empties >= self.tuning.deprime_after || self.hollow {
|
||||
// The consecutive-empties hysteresis protects a FULL ring from one late packet.
|
||||
// A hollow ring is the opposite case: the target has been raised but the depth
|
||||
// never re-banked (growth is a promise; only a re-prime cashes it), and riding
|
||||
// that out is a click per bunching period, forever. The click just heard has
|
||||
// already paid for the refill — take it now.
|
||||
self.primed = false;
|
||||
self.empties = 0;
|
||||
}
|
||||
self.underruns += 1;
|
||||
if !restored {
|
||||
self.underruns += 1;
|
||||
}
|
||||
if self.underruns >= GROW_UNDERRUNS {
|
||||
// This device genuinely needs more slack than the base target. Grow ONCE per
|
||||
// window, capped — the alternative (every device pre-paying the worst device's
|
||||
@@ -705,17 +805,33 @@ impl JitterPolicy {
|
||||
let grown = self.target + GROW_STEP_MS as usize * self.per_ms;
|
||||
self.target = grown.min(self.tuning.max_target_ms as usize * self.per_ms);
|
||||
}
|
||||
} else if near_miss {
|
||||
// Came within one frame of an underrun — the same evidence as one, heard by no one.
|
||||
// Growing here, BEFORE the click, is what "no audible jitter" means: waiting for
|
||||
// the third audible underrun means the user heard two. One step per window (a
|
||||
// bunching episode is a RUN of near-misses while the ring refills, and must buy one
|
||||
// measured step, not a sprint to the ceiling); if it worsens into real underruns
|
||||
// the path above takes over. A near-miss is pressure, not quiet.
|
||||
self.quiet_run = 0;
|
||||
self.empties = 0;
|
||||
if !self.near_miss_grown && !restored {
|
||||
self.near_miss_grown = true;
|
||||
let grown = self.target + GROW_STEP_MS as usize * self.per_ms;
|
||||
self.target = grown.min(self.tuning.max_target_ms as usize * self.per_ms);
|
||||
}
|
||||
} else {
|
||||
self.empties = 0;
|
||||
self.quiet_run += want;
|
||||
// A grown target normally relaxes only after a long quiet spell, because without other
|
||||
// evidence the only thing that can justify giving up hard-won slack is time. When the
|
||||
// sync loop is asking to run shallower it IS that evidence — a measurement saying the
|
||||
// extra depth is costing alignment right now — so test a smaller target sooner. Wrong
|
||||
// guesses are cheap and self-correcting: one underrun and the growth path takes it
|
||||
// straight back. Without this a ring that ratcheted to the ceiling during a transient
|
||||
// would hold the audio a ceiling's worth late for minutes after the cause had gone.
|
||||
let quiet_needed = if self.sync_wants_less() {
|
||||
// extra depth is costing alignment right now — so test a smaller target sooner. Every
|
||||
// shrink is armed as a PROBE: answered by an underrun or near-miss it is undone at
|
||||
// once (see above), and a failed sync-driven guess is not retried for a backoff —
|
||||
// without that, a link whose jitter genuinely needs the depth pays an audible
|
||||
// starvation event every five seconds, forever.
|
||||
let sync_shrink = self.sync_wants_less() && self.sync_backoff_run == 0;
|
||||
let quiet_needed = if sync_shrink {
|
||||
SHRINK_QUIET_SYNC_MS
|
||||
} else {
|
||||
SHRINK_QUIET_MS
|
||||
@@ -725,10 +841,15 @@ impl JitterPolicy {
|
||||
// doesn't cost latency for the rest of the session.
|
||||
self.quiet_run = 0;
|
||||
let base = self.tuning.base_target_ms as usize * self.per_ms;
|
||||
let prev = self.target;
|
||||
self.target = self
|
||||
.target
|
||||
.saturating_sub(GROW_STEP_MS as usize * self.per_ms)
|
||||
.max(base);
|
||||
if self.target < prev {
|
||||
self.probe_run = SHRINK_PROBE_MS as usize * self.per_ms;
|
||||
self.probe_prev_target = prev;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1937,4 +2058,244 @@ mod tests {
|
||||
"sync pressure should relax sooner: {fast_reads} vs {slow_reads} quiet reads"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- near-miss growth and shrink probes (the audible-limit-cycle fixes) ---------------
|
||||
|
||||
/// A primed read that is served but leaves less than one frame buffered is a NEAR-MISS —
|
||||
/// the same evidence as an underrun, heard by no one — and must grow the target BEFORE the
|
||||
/// click, not after the third one. One step per window: a bunching episode lands as a run of
|
||||
/// consecutive near-misses while the ring refills, and must not sprint to the ceiling.
|
||||
#[test]
|
||||
fn a_near_miss_grows_the_target_without_an_underrun() {
|
||||
let t = JitterTuning::COREAUDIO;
|
||||
let pm = per_ms(2);
|
||||
let want = 5 * pm;
|
||||
let mut p = JitterPolicy::new(t, 2);
|
||||
p.step(t.base_target_ms as usize * pm, want); // primes exactly at target
|
||||
assert!(p.is_primed());
|
||||
let base = p.target_ms();
|
||||
// Serve the callback with less than one frame left over: depth = want + (margin − 1).
|
||||
p.step(want + NEAR_MISS_MARGIN_MS as usize * pm - 1, want);
|
||||
p.note_read(false); // NOT short — the device got its samples
|
||||
assert_eq!(
|
||||
p.target_ms(),
|
||||
base + GROW_STEP_MS,
|
||||
"a near-miss must buy one step"
|
||||
);
|
||||
// A second near-miss in the same window is the same episode: no further growth.
|
||||
p.step(want + pm, want);
|
||||
p.note_read(false);
|
||||
assert_eq!(p.target_ms(), base + GROW_STEP_MS, "one step per window");
|
||||
// A healthy read does not grow anything.
|
||||
let grown = p.target_ms();
|
||||
p.step(grown as usize * pm + want, want);
|
||||
p.note_read(false);
|
||||
assert_eq!(p.target_ms(), grown);
|
||||
}
|
||||
|
||||
/// A healthy steady depth must never read as a near-miss: the margin is one frame, and a
|
||||
/// ring hovering at target sits a whole target above it.
|
||||
#[test]
|
||||
fn steady_depth_never_grows_the_target() {
|
||||
let t = JitterTuning::PIPEWIRE;
|
||||
let pm = per_ms(2);
|
||||
let want = 5 * pm;
|
||||
let mut p = JitterPolicy::new(t, 2);
|
||||
for _ in 0..(60_000 / 5) {
|
||||
// one minute of clean callbacks
|
||||
p.step(t.base_target_ms as usize * pm + want, want);
|
||||
p.note_read(false);
|
||||
}
|
||||
assert_eq!(p.target_ms(), t.base_target_ms);
|
||||
}
|
||||
|
||||
/// A shrink answered by an underrun (or near-miss) inside its probe window is undone AT
|
||||
/// ONCE — re-learning the depth three audible underruns at a time is what made the
|
||||
/// sync-vs-growth tug-of-war audible in the field.
|
||||
#[test]
|
||||
fn a_failed_shrink_probe_is_undone_at_once() {
|
||||
let t = JitterTuning::COREAUDIO;
|
||||
let pm = per_ms(2);
|
||||
let want = 5 * pm;
|
||||
let mut p = JitterPolicy::new(t, 2);
|
||||
// Grow the floor two steps the audible way.
|
||||
for _ in 0..(2 * GROW_UNDERRUNS) {
|
||||
while !p.is_primed() {
|
||||
p.step(200 * pm, want);
|
||||
}
|
||||
p.step(200 * pm, want);
|
||||
p.note_read(true);
|
||||
}
|
||||
let grown = p.target_ms();
|
||||
assert!(grown > t.base_target_ms);
|
||||
// Sync asks for less; five quiet seconds later the shrink probes.
|
||||
p.set_sync_target(Some(pm));
|
||||
let depth = grown as usize * pm + want;
|
||||
while p.target_ms() == grown {
|
||||
p.step(depth, want);
|
||||
p.note_read(false);
|
||||
}
|
||||
assert_eq!(p.target_ms(), grown - GROW_STEP_MS);
|
||||
// ONE near-miss — nobody heard anything yet — and the depth is back.
|
||||
p.step(want + pm, want);
|
||||
p.note_read(false);
|
||||
assert_eq!(
|
||||
p.target_ms(),
|
||||
grown,
|
||||
"a failed probe must restore the target on the first near-miss"
|
||||
);
|
||||
}
|
||||
|
||||
/// After a failed probe the sync loop may not drive another shrink at the accelerated
|
||||
/// cadence — the slow, pre-sync window still applies, the five-second one does not.
|
||||
#[test]
|
||||
fn a_failed_probe_backs_the_sync_shrink_off() {
|
||||
let t = JitterTuning::COREAUDIO;
|
||||
let pm = per_ms(2);
|
||||
let want = 5 * pm;
|
||||
let mut p = JitterPolicy::new(t, 2);
|
||||
for _ in 0..(2 * GROW_UNDERRUNS) {
|
||||
while !p.is_primed() {
|
||||
p.step(200 * pm, want);
|
||||
}
|
||||
p.step(200 * pm, want);
|
||||
p.note_read(true);
|
||||
}
|
||||
let grown = p.target_ms();
|
||||
p.set_sync_target(Some(pm));
|
||||
let depth = grown as usize * pm + want;
|
||||
// First sync-driven shrink, then fail its probe.
|
||||
while p.target_ms() == grown {
|
||||
p.step(depth, want);
|
||||
p.note_read(false);
|
||||
}
|
||||
p.step(want + pm, want);
|
||||
p.note_read(false);
|
||||
assert_eq!(p.target_ms(), grown, "restored");
|
||||
// Twice the accelerated window of clean audio: the backed-off loop must NOT have
|
||||
// shrunk again (before the fix this was exactly one audible failure per five seconds).
|
||||
for _ in 0..(2 * SHRINK_QUIET_SYNC_MS / 5) {
|
||||
p.step(depth, want);
|
||||
p.note_read(false);
|
||||
}
|
||||
assert_eq!(
|
||||
p.target_ms(),
|
||||
grown,
|
||||
"the accelerated cadence must be suspended after a failure"
|
||||
);
|
||||
// The slow pre-sync window still relaxes it eventually — backoff is not a freeze.
|
||||
for _ in 0..(2 * SHRINK_QUIET_MS / 5) {
|
||||
p.step(depth, want);
|
||||
p.note_read(false);
|
||||
}
|
||||
assert!(
|
||||
p.target_ms() < grown,
|
||||
"the slow window must still be allowed to test a shrink"
|
||||
);
|
||||
}
|
||||
|
||||
/// One simulated bunching run's outcome.
|
||||
#[derive(Debug, Default)]
|
||||
struct BunchSim {
|
||||
/// Reads that actually starved the device — each one is audible.
|
||||
audible: u32,
|
||||
/// Audible reads in the second half of the run: non-zero means the policy never
|
||||
/// converged and the user hears it forever.
|
||||
audible_tail: u32,
|
||||
}
|
||||
|
||||
/// Drive a policy over a link that BUNCHES: delivery pauses for `gap_ms` every `period_ms`,
|
||||
/// then the withheld audio arrives at once — the Wi-Fi power-save pattern from the field
|
||||
/// reports, where the total rate is fine and only the spacing is wrong. `drift_ppm` is the
|
||||
/// host-vs-DAC clock skew; a slightly slow host (negative) erodes the depth over minutes,
|
||||
/// which is what keeps re-testing whatever target the policy has settled on — without it a
|
||||
/// simulated ring freezes wherever priming left it and a wrong target is never punished.
|
||||
fn simulate_bunching(
|
||||
tuning: JitterTuning,
|
||||
sync_target: Option<usize>,
|
||||
ms: u32,
|
||||
gap_ms: u32,
|
||||
period_ms: u32,
|
||||
drift_ppm: i64,
|
||||
) -> BunchSim {
|
||||
let pm = per_ms(2);
|
||||
let want = 5 * pm;
|
||||
let mut p = JitterPolicy::new(tuning, 2);
|
||||
p.set_sync_target(sync_target);
|
||||
let mut depth = 0usize;
|
||||
let mut withheld = 0usize;
|
||||
let mut carry: i64 = 0;
|
||||
let mut out = BunchSim::default();
|
||||
for cb in 0..(ms / 5) {
|
||||
// The host keeps producing (want ± drift per callback); the link decides delivery.
|
||||
carry += want as i64 * drift_ppm;
|
||||
let extra = carry / 1_000_000;
|
||||
carry -= extra * 1_000_000;
|
||||
let produced = (want as i64 + extra).max(0) as usize;
|
||||
let in_gap = (cb * 5) % period_ms < gap_ms;
|
||||
if in_gap {
|
||||
withheld += produced;
|
||||
} else {
|
||||
depth += produced + std::mem::take(&mut withheld);
|
||||
}
|
||||
let s = p.step(depth, want);
|
||||
depth -= s.drop_front.min(depth);
|
||||
if s.silence {
|
||||
p.note_read(false);
|
||||
continue;
|
||||
}
|
||||
let short = depth < want;
|
||||
depth -= want.min(depth);
|
||||
if short {
|
||||
out.audible += 1;
|
||||
if cb >= ms / 10 {
|
||||
out.audible_tail += 1;
|
||||
}
|
||||
}
|
||||
p.note_read(short);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// THE field regression this whole change is for. A link that bunches needs ~30 ms of ring;
|
||||
/// the sync loop wants less. Before this change the policy paid an audible event nearly
|
||||
/// every bunching period, indefinitely — this exact simulation measured ~2000 over ten
|
||||
/// minutes: the sync loop re-probed a proven depth every five quiet seconds, growth needed
|
||||
/// three audible underruns to answer, and a grown target was never re-banked (growth raises
|
||||
/// a threshold; only a re-prime deepens the ring), so the depth rode the knife edge. Now
|
||||
/// near-misses grow the target before the first click, a failed shrink probe is undone at
|
||||
/// once and backs the sync loop off, and a hollow ring cashes the whole refill on the click
|
||||
/// it already paid. What remains is the clock-skew re-anchor — a slightly slow host
|
||||
/// genuinely starves the ring every few minutes, and only rate adaptation (which no client
|
||||
/// has) could remove that — so the bound is "a handful over ten minutes", not zero.
|
||||
#[test]
|
||||
fn sync_pressure_on_a_bunching_link_converges_instead_of_clicking_forever() {
|
||||
// 25 ms gaps every 300 ms, a slightly slow host, ten minutes, sync permanently asking
|
||||
// for a 5 ms ring.
|
||||
let s = simulate_bunching(
|
||||
JitterTuning::COREAUDIO,
|
||||
Some(per_ms(2) * 5),
|
||||
600_000,
|
||||
25,
|
||||
300,
|
||||
-50,
|
||||
);
|
||||
assert!(
|
||||
s.audible_tail <= 4,
|
||||
"the tug-of-war must converge to the skew floor: {s:?}"
|
||||
);
|
||||
assert!(
|
||||
s.audible <= 12,
|
||||
"learning the link may cost a handful of audible events, not a stream of them: {s:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The same link without sync pressure — the plain adaptive-growth behaviour — must land in
|
||||
/// the same place: sync steering may not add a persistent audible cost over not steering.
|
||||
#[test]
|
||||
fn a_bunching_link_without_sync_stays_clean_after_growing() {
|
||||
let s = simulate_bunching(JitterTuning::COREAUDIO, None, 600_000, 25, 300, -50);
|
||||
assert!(s.audible_tail <= 4, "{s:?}");
|
||||
assert!(s.audible <= 12, "{s:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ mod epic;
|
||||
mod gog;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod heroic;
|
||||
mod hidden;
|
||||
mod launch;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod lutris;
|
||||
@@ -46,6 +47,7 @@ pub use epic::*;
|
||||
pub use gog::*;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use heroic::*;
|
||||
pub use hidden::*;
|
||||
pub use launch::*;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use lutris::*;
|
||||
@@ -195,6 +197,32 @@ pub struct GameEntry {
|
||||
pub meta: GameMeta,
|
||||
}
|
||||
|
||||
/// A library entry plus the operator's own view of it — today, whether they hid it.
|
||||
///
|
||||
/// A separate type rather than a field on [`GameEntry`] for two reasons. It keeps the visibility
|
||||
/// answer out of the providers entirely: a store parser has no opinion on what the operator hid, and
|
||||
/// adding `hidden: false` to all eight construction sites would imply it does. More importantly it
|
||||
/// makes the lane rule a TYPE guarantee instead of a discipline — `GET /library` answers
|
||||
/// `Vec<GameEntry>` on every lane but the operator's, so a hidden entry cannot leak to a paired
|
||||
/// client by someone forgetting a filter; there is no field there to leak.
|
||||
///
|
||||
/// `flatten` keeps the wire shape identical to a plain entry with one extra key, so the console
|
||||
/// parses one model either way.
|
||||
#[derive(Clone, Debug, Serialize, ToSchema)]
|
||||
pub struct OperatorGameEntry {
|
||||
#[serde(flatten)]
|
||||
pub entry: GameEntry,
|
||||
/// The operator hid this title ([`set_entry_hidden`]) — omitted when false, so the shape only
|
||||
/// grows for entries that actually are hidden.
|
||||
#[serde(skip_serializing_if = "is_not_hidden")]
|
||||
pub hidden: bool,
|
||||
}
|
||||
|
||||
/// `skip_serializing_if` predicate for [`OperatorGameEntry::hidden`] — `&bool` as serde requires.
|
||||
fn is_not_hidden(hidden: &bool) -> bool {
|
||||
!*hidden
|
||||
}
|
||||
|
||||
/// A store that contributes titles to the library. The trait is the extension point for future
|
||||
/// launchers; today only [`SteamProvider`] implements it.
|
||||
pub trait LibraryProvider {
|
||||
@@ -268,7 +296,39 @@ impl ArtKind {
|
||||
/// 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.
|
||||
///
|
||||
/// A **third** gate rides on top of these two: the operator's per-entry hides (`hidden.rs`). It is
|
||||
/// applied here rather than at each call site so a hidden title is gone from every surface by
|
||||
/// construction — the grid, native clients, `/applist`, and launch resolution — exactly as a
|
||||
/// disabled source's titles are. [`all_games_for_operator`] is the single deliberate exception.
|
||||
pub fn all_games() -> Vec<GameEntry> {
|
||||
let hidden = hidden_ids();
|
||||
let mut games = collect_games();
|
||||
games.retain(|g| !hidden.contains(&g.id));
|
||||
games
|
||||
}
|
||||
|
||||
/// The library **including** the operator's hidden titles, each flagged.
|
||||
///
|
||||
/// The console's list is the only caller, and only on the operator's own lane (`GET /library`
|
||||
/// branches on it): a hidden entry has to be visible SOMEWHERE or it could never be brought back.
|
||||
/// Everything else — every paired client, the GameStream app list, launch resolution — goes through
|
||||
/// [`all_games`] and never sees them.
|
||||
pub fn all_games_for_operator() -> Vec<OperatorGameEntry> {
|
||||
let hidden = hidden_ids();
|
||||
collect_games()
|
||||
.into_iter()
|
||||
.map(|entry| OperatorGameEntry {
|
||||
hidden: hidden.contains(&entry.id),
|
||||
entry,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Merge every enabled source + the custom entries, sorted by title — with no visibility gate of its
|
||||
/// own. Split out so the two public views above cannot drift: they differ only in what they do with
|
||||
/// the hidden set, never in what they collect.
|
||||
fn collect_games() -> Vec<GameEntry> {
|
||||
let off = disabled_scanners();
|
||||
let claimed = claimed_stores();
|
||||
// A built-in scanner runs when the operator hasn't disabled it AND no plugin has claimed its
|
||||
@@ -314,3 +374,90 @@ pub fn all_games() -> Vec<GameEntry> {
|
||||
games.sort_by_key(|g| g.title.to_lowercase());
|
||||
games
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entry(id: &str, title: &str) -> GameEntry {
|
||||
GameEntry {
|
||||
id: id.into(),
|
||||
store: id.split_once(':').map_or("custom", |(s, _)| s).into(),
|
||||
title: title.into(),
|
||||
art: Artwork::default(),
|
||||
role: GameRole::default(),
|
||||
launch: None,
|
||||
provider: None,
|
||||
detect: DetectSpec::default(),
|
||||
meta: GameMeta::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The console codes against this shape, so pin it: the operator view must be a normal entry
|
||||
/// with ONE extra key, and that key must vanish when the title is visible.
|
||||
///
|
||||
/// The skip matters beyond tidiness — it is what keeps this response byte-identical to the old
|
||||
/// one for a library with nothing hidden, so shipping the feature cannot change what an existing
|
||||
/// console renders until someone actually hides something.
|
||||
#[test]
|
||||
fn operator_entry_flattens_and_omits_hidden_when_false() {
|
||||
let visible = OperatorGameEntry {
|
||||
entry: entry("steam:70", "Half-Life"),
|
||||
hidden: false,
|
||||
};
|
||||
let v = serde_json::to_value(&visible).expect("serializes");
|
||||
assert_eq!(v["id"], "steam:70", "the entry's fields stay at top level");
|
||||
assert_eq!(v["title"], "Half-Life");
|
||||
assert!(
|
||||
v.get("hidden").is_none(),
|
||||
"a visible entry must not carry the key at all: {v}"
|
||||
);
|
||||
|
||||
let hidden = OperatorGameEntry {
|
||||
entry: entry("steam:70", "Half-Life"),
|
||||
hidden: true,
|
||||
};
|
||||
let v = serde_json::to_value(&hidden).expect("serializes");
|
||||
assert_eq!(v["hidden"], true);
|
||||
assert_eq!(v["id"], "steam:70", "flatten still applies when hidden");
|
||||
}
|
||||
|
||||
/// `all_games` and `all_games_for_operator` must agree on WHICH entries exist and differ only in
|
||||
/// visibility — they share `collect_games` for exactly that reason. This pins the shared-source
|
||||
/// property the same way the art test pins write/read symmetry: both views of an id-set built
|
||||
/// from one collector, so a future edit that inlines one of them is caught.
|
||||
#[test]
|
||||
fn hidden_filter_is_the_only_difference_between_the_two_views() {
|
||||
let games = vec![
|
||||
entry("steam:70", "Half-Life"),
|
||||
entry("lutris:4", "Syndicate"),
|
||||
entry("custom:abc", "Chrono Trigger"),
|
||||
];
|
||||
let hidden: HashSet<String> = ["lutris:4".to_string()].into_iter().collect();
|
||||
|
||||
let operator: Vec<OperatorGameEntry> = games
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|entry| OperatorGameEntry {
|
||||
hidden: hidden.contains(&entry.id),
|
||||
entry,
|
||||
})
|
||||
.collect();
|
||||
let played: Vec<GameEntry> = games
|
||||
.into_iter()
|
||||
.filter(|g| !hidden.contains(&g.id))
|
||||
.collect();
|
||||
|
||||
assert_eq!(operator.len(), 3, "the operator sees every title");
|
||||
assert_eq!(played.len(), 2, "a player does not see the hidden one");
|
||||
assert!(
|
||||
!played.iter().any(|g| g.id == "lutris:4"),
|
||||
"the hidden id must be absent, not merely flagged"
|
||||
);
|
||||
assert_eq!(
|
||||
operator.iter().filter(|r| r.hidden).count(),
|
||||
1,
|
||||
"exactly the hidden one is flagged"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,8 +350,21 @@ fn sniff_image_type(bytes: &[u8]) -> Option<&'static str> {
|
||||
/// 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.
|
||||
///
|
||||
/// A `file://` value is decoded to a plain path FIRST, exactly as [`local_art_bytes`] does. Both
|
||||
/// halves of the confinement must judge the *same* string or they disagree: `Path::new` on a raw
|
||||
/// `file:///home/u/c.jpg` yields a RELATIVE path whose first component is `file:`, which
|
||||
/// canonicalizes against the cwd, fails, and reads as "outside every root". That is not a
|
||||
/// conservative failure — it rejected every `file://` cover the plugin kit emits (`fileUrl`, the
|
||||
/// documented way for a library plugin to publish local art), so the Lutris and Steam scanners
|
||||
/// could not reconcile a single entry while the read path would have served those same files
|
||||
/// happily.
|
||||
pub fn art_path_is_servable(value: &str) -> bool {
|
||||
let p = Path::new(value);
|
||||
// Idempotent for the already-decoded caller: the decoded form no longer carries the prefix,
|
||||
// so `local_art_bytes` passing its own output back through here is a no-op, not a second
|
||||
// percent-decode of a path that legitimately contains `%`.
|
||||
let value = file_url_to_path(value);
|
||||
let p = Path::new(&*value);
|
||||
let ext_ok = p
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
@@ -699,11 +712,23 @@ mod tests {
|
||||
|
||||
const PNG: &[u8] = &[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 13];
|
||||
|
||||
/// `PUNKTFUNK_LIBRARY_ART_ROOTS` is process-global while cargo runs tests as threads, so the
|
||||
/// tests that repoint it must not overlap — one clearing the variable mid-flight makes the
|
||||
/// other's temp root stop being a root, which fails as a confinement bug that isn't there.
|
||||
/// Poisoning is recovered rather than propagated: a panic in one test should report ITS
|
||||
/// failure, not cascade into an unrelated `PoisonError`.
|
||||
static ART_ROOTS_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
fn lock_art_roots() -> std::sync::MutexGuard<'static, ()> {
|
||||
ART_ROOTS_LOCK.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// 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 _guard = lock_art_roots();
|
||||
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();
|
||||
@@ -837,6 +862,79 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The write gate and the read gate must judge the SAME string.
|
||||
///
|
||||
/// Regression for 2026-08-08: `validate_art_paths` handed the raw value to `Path::new`, so a
|
||||
/// `file:///…` cover became a *relative* path starting with a `file:` component, canonicalized
|
||||
/// against the cwd, failed, and was refused as "outside every art root" — while
|
||||
/// `local_art_bytes` decoded the very same value and served the file. Every Lutris and Steam
|
||||
/// entry carrying local art was rejected with a 400 the plugin could only report as
|
||||
/// `HostRequestError`, so neither scanner could sync a single game. Asserting servable and
|
||||
/// readable together is the point: either alone passes with the bug present.
|
||||
#[test]
|
||||
fn file_url_art_is_accepted_at_write_time_exactly_as_at_read_time() {
|
||||
let _guard = lock_art_roots();
|
||||
let dir = std::env::temp_dir().join(format!("pf-art-wr-{}", std::process::id()));
|
||||
let outside = std::env::temp_dir().join(format!("pf-art-wr-out-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::create_dir_all(&outside).unwrap();
|
||||
std::env::set_var("PUNKTFUNK_LIBRARY_ART_ROOTS", &dir);
|
||||
|
||||
let cover = dir.join("cover.png");
|
||||
std::fs::write(&cover, PNG).unwrap();
|
||||
|
||||
// What the kit's `fileUrl` actually emits for a Lutris/Steam cover.
|
||||
let url = file_url(&cover);
|
||||
assert!(
|
||||
is_local_art_path(&url),
|
||||
"a file:// value is local art, so the confinement applies to it"
|
||||
);
|
||||
assert!(
|
||||
art_path_is_servable(&url),
|
||||
"write time must accept the file:// form of a servable cover"
|
||||
);
|
||||
assert!(
|
||||
validate_art_paths(&Artwork {
|
||||
portrait: Some(url.clone()),
|
||||
header: Some(url),
|
||||
..Default::default()
|
||||
})
|
||||
.is_ok(),
|
||||
"a real Lutris-shaped payload must reconcile"
|
||||
);
|
||||
|
||||
// A percent-encoded name (the reason the decode exists at all) survives the round trip.
|
||||
let spaced = dir.join("My Cover.png");
|
||||
std::fs::write(&spaced, PNG).unwrap();
|
||||
let spaced_url = file_url(&spaced).replace(' ', "%20");
|
||||
assert!(
|
||||
art_path_is_servable(&spaced_url),
|
||||
"percent-encoded names must decode before the containment test: {spaced_url}"
|
||||
);
|
||||
assert!(local_art_bytes(&spaced_url).is_some(), "read time agrees");
|
||||
|
||||
// Loosening the write gate must not loosen the confinement: outside the root is still
|
||||
// refused in file:// clothing, which is what the raw-string bug was accidentally doing.
|
||||
let elsewhere = outside.join("cover.png");
|
||||
std::fs::write(&elsewhere, PNG).unwrap();
|
||||
assert!(
|
||||
!art_path_is_servable(&file_url(&elsewhere)),
|
||||
"file:// must not escape the art roots at write time either"
|
||||
);
|
||||
assert!(
|
||||
validate_art_paths(&Artwork {
|
||||
portrait: Some(file_url(&elsewhere)),
|
||||
..Default::default()
|
||||
})
|
||||
.is_err(),
|
||||
"an out-of-root file:// cover is still refused"
|
||||
);
|
||||
|
||||
std::env::remove_var("PUNKTFUNK_LIBRARY_ART_ROOTS");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let _ = std::fs::remove_dir_all(&outside);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sniff_image_type_recognizes_containers_and_rejects_secrets() {
|
||||
assert_eq!(sniff_image_type(PNG), Some("image/png"));
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Per-entry visibility: the operator hides one *title*, where `scanners.rs` hides a whole source.
|
||||
//!
|
||||
//! **Why this is a side table and not a field on the entry.** Only manual custom entries are stored;
|
||||
//! a scanner's and a plugin's titles are regenerated from scratch on every scan and every reconcile.
|
||||
//! A `hidden` flag written onto one of those would be erased by the next sync — silently, and
|
||||
//! minutes later, which is the worst possible shape for a setting. So the operator's choice lives
|
||||
//! here, keyed by the entry's stable `<store>:<external_id>` id, and the entries stay disposable.
|
||||
//!
|
||||
//! That id is stable *by construction* (design D2): a claimed store's entries keep
|
||||
//! `<store>:<external_id>` across reconciles no matter what the host-assigned id does, which is the
|
||||
//! same property GameStream app ids and client art caches already depend on. Hiding therefore
|
||||
//! survives a re-scan, a plugin restart, and the built-in→plugin migration for a store.
|
||||
//!
|
||||
//! Hiding is **curation, not access control** — it declutters a grid. It is applied in
|
||||
//! [`all_games`](crate::library::all_games), so a hidden title is gone from every play surface
|
||||
//! *including* launch resolution (the same reach a disabled scanner has), but nothing is deleted and
|
||||
//! un-hiding is immediate. The console is the one surface that still sees hidden titles — otherwise
|
||||
//! there would be no way to un-hide one — and only on the operator's own lane.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Persisted shape (`library-hidden.json`): the ids the operator hid. Absent file = nothing hidden.
|
||||
///
|
||||
/// Mirrors `library-scanners.json`'s disabled-set rather than sharing it: that file answers "which
|
||||
/// SOURCES run", this one answers "which TITLES show", and a source id (`steam`) and an entry id
|
||||
/// (`steam:70`) are different namespaces. Keeping them apart means neither migration can corrupt the
|
||||
/// other, and an operator reading either file sees one idea.
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
struct HiddenSettings {
|
||||
#[serde(default)]
|
||||
hidden: Vec<String>,
|
||||
}
|
||||
|
||||
fn settings_path() -> PathBuf {
|
||||
// Same hardened config dir as library.json / library-scanners.json.
|
||||
pf_paths::config_dir().join("library-hidden.json")
|
||||
}
|
||||
|
||||
/// Load the hidden set (default + non-fatal if the file is absent or malformed).
|
||||
///
|
||||
/// A malformed file means "nothing hidden", never "hide everything": the failure mode of a bad parse
|
||||
/// must be a library that shows too much, not one that looks empty and reads as data loss.
|
||||
fn load_settings() -> HiddenSettings {
|
||||
match std::fs::read_to_string(settings_path()) {
|
||||
Ok(raw) => serde_json::from_str(&raw).unwrap_or_else(|e| {
|
||||
tracing::warn!(error = %e, "library-hidden.json malformed — nothing hidden");
|
||||
HiddenSettings::default()
|
||||
}),
|
||||
Err(_) => HiddenSettings::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn save_settings(settings: &HiddenSettings) -> Result<()> {
|
||||
let dir = pf_paths::config_dir();
|
||||
pf_paths::create_private_dir(&dir).with_context(|| format!("create {}", dir.display()))?;
|
||||
let json = serde_json::to_string_pretty(settings)?;
|
||||
// Write-then-rename like the catalog, so a crash mid-write never truncates the settings.
|
||||
let tmp = settings_path().with_extension("json.tmp");
|
||||
pf_paths::write_secret_file(&tmp, json.as_bytes())
|
||||
.with_context(|| format!("write {}", tmp.display()))?;
|
||||
std::fs::rename(&tmp, settings_path()).context("rename library-hidden.json")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The hidden entry ids, loaded once per library read.
|
||||
pub(crate) fn hidden_ids() -> HashSet<String> {
|
||||
load_settings().hidden.into_iter().collect()
|
||||
}
|
||||
|
||||
/// The store half of a library id (`steam:70` → `steam`), for the `library.changed` source.
|
||||
///
|
||||
/// Falls back to the whole id rather than an empty string: an id without a `:` is not a shape this
|
||||
/// host produces, and naming it in the event beats emitting a blank source that matches no cache key.
|
||||
fn store_of(id: &str) -> &str {
|
||||
id.split_once(':').map_or(id, |(store, _)| store)
|
||||
}
|
||||
|
||||
/// Hide or un-hide one entry. Returns whether the entry is hidden **after** the call.
|
||||
///
|
||||
/// Idempotent, and deliberately not validated against the current library: an entry can be absent
|
||||
/// right now for reasons that have nothing to do with the operator's intent — the launcher is closed,
|
||||
/// a plugin has not finished its first sync, a disk is unmounted. Refusing to hide a title that is
|
||||
/// temporarily missing, or silently dropping the choice when it comes back, would both be worse than
|
||||
/// storing an id that currently matches nothing. Persists and emits `library.changed` only when the
|
||||
/// state actually changed, so a repeated PUT is a cheap no-op.
|
||||
pub fn set_entry_hidden(id: &str, hidden: bool) -> Result<bool> {
|
||||
let mut settings = load_settings();
|
||||
let was_hidden = settings.hidden.iter().any(|h| h == id);
|
||||
if was_hidden == hidden {
|
||||
return Ok(hidden);
|
||||
}
|
||||
if hidden {
|
||||
settings.hidden.push(id.to_string());
|
||||
settings.hidden.sort();
|
||||
settings.hidden.dedup();
|
||||
} else {
|
||||
settings.hidden.retain(|h| h != id);
|
||||
}
|
||||
save_settings(&settings)?;
|
||||
crate::events::emit(crate::events::EventKind::LibraryChanged {
|
||||
source: store_of(id).to_string(),
|
||||
});
|
||||
Ok(hidden)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The event source is the STORE, not the whole id — that is the key every client cache and the
|
||||
/// console's query invalidation is grouped by.
|
||||
#[test]
|
||||
fn store_of_takes_the_prefix_and_tolerates_a_bare_id() {
|
||||
assert_eq!(store_of("steam:70"), "steam");
|
||||
assert_eq!(store_of("custom:abc"), "custom");
|
||||
// An external id may itself contain a colon (Heroic's `legendary:<hash>`): split on the
|
||||
// FIRST one, or the store would come back wrong for exactly the store that does this.
|
||||
assert_eq!(store_of("heroic:legendary:fc0b13b7"), "heroic");
|
||||
assert_eq!(store_of("weird-no-colon"), "weird-no-colon");
|
||||
}
|
||||
|
||||
/// A malformed settings file must read as "nothing hidden". The inverse — treating a parse
|
||||
/// failure as "hide everything" — would present as a library that lost its games.
|
||||
#[test]
|
||||
fn malformed_settings_hide_nothing() {
|
||||
let s: HiddenSettings = serde_json::from_str("{ not json").unwrap_or_default();
|
||||
assert!(s.hidden.is_empty());
|
||||
let s: HiddenSettings = serde_json::from_str("{}").expect("an empty object is valid");
|
||||
assert!(s.hidden.is_empty(), "absent key means nothing hidden");
|
||||
}
|
||||
|
||||
/// The persisted shape is the contract an operator may hand-edit — pin it.
|
||||
#[test]
|
||||
fn settings_roundtrip_the_documented_shape() {
|
||||
let s: HiddenSettings =
|
||||
serde_json::from_str(r#"{"hidden":["steam:70","lutris:4"]}"#).expect("parses");
|
||||
assert_eq!(s.hidden, vec!["steam:70", "lutris:4"]);
|
||||
let json = serde_json::to_string(&s).expect("serializes");
|
||||
assert_eq!(json, r#"{"hidden":["steam:70","lutris:4"]}"#);
|
||||
}
|
||||
}
|
||||
@@ -228,6 +228,7 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
|
||||
.routes(routes!(library::get_library))
|
||||
.routes(routes!(library::list_library_scanners))
|
||||
.routes(routes!(library::set_library_scanner))
|
||||
.routes(routes!(library::set_library_entry_hidden))
|
||||
.routes(routes!(library::create_custom_game))
|
||||
.routes(routes!(
|
||||
library::update_custom_game,
|
||||
|
||||
@@ -51,6 +51,18 @@ impl AuthLane {
|
||||
pub(crate) fn may_set_privileged_fields(self) -> bool {
|
||||
matches!(self, AuthLane::Admin)
|
||||
}
|
||||
|
||||
/// Whether this is the operator's own lane — the console, as opposed to a paired client or a
|
||||
/// plugin.
|
||||
///
|
||||
/// Same arm as [`may_set_privileged_fields`](Self::may_set_privileged_fields) today, and
|
||||
/// deliberately a separate question: that one asks "may this caller cause command execution",
|
||||
/// this one asks "is this caller the person curating the library". A read-only view the operator
|
||||
/// alone should see (their hidden titles) is not a privilege escalation, and collapsing the two
|
||||
/// would leave whichever one changes first silently answering for the other.
|
||||
pub(crate) fn is_operator(self) -> bool {
|
||||
matches!(self, AuthLane::Admin)
|
||||
}
|
||||
}
|
||||
|
||||
/// Auth gate on the `/api/v1` routes: a paired client cert (mTLS, from anywhere) or the bearer token
|
||||
|
||||
@@ -14,33 +14,42 @@ use axum::Extension;
|
||||
/// 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
|
||||
/// `Some((reason, 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.
|
||||
///
|
||||
/// `reason` is the caller's log line. It exists because these are TWO different refusals — an
|
||||
/// operator-privileged field (403) and an unservable art path (400) — and logging both as "carries
|
||||
/// a field this lane may not set" sent the Lutris/Steam `file://` art rejection looking like an
|
||||
/// auth problem. The plugin only ever sees `HostRequestError`, so this log line is the sole
|
||||
/// diagnosis surface for whoever has to explain why a scanner syncs nothing.
|
||||
fn check_entry_fields(
|
||||
lane: AuthLane,
|
||||
art: &crate::library::Artwork,
|
||||
launch: Option<&crate::library::LaunchSpec>,
|
||||
prep: &[crate::hooks::PrepCmd],
|
||||
) -> Option<Response> {
|
||||
) -> Option<(String, 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, xbox, lutris_id, \
|
||||
heroic, playnite) \
|
||||
instead"
|
||||
return Some((
|
||||
format!("payload carries `{field}`, which this lane may not set"),
|
||||
api_error(
|
||||
StatusCode::FORBIDDEN,
|
||||
&format!(
|
||||
"`{field}` is executed as the host user and may only be set with the \
|
||||
operator's admin token — a plugin may publish entries with any host-resolved \
|
||||
launch kind (steam_appid, steam_ui, launcher_ui, epic, gog, aumid, xbox, lutris_id, \
|
||||
heroic, playnite) \
|
||||
instead"
|
||||
),
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
crate::library::validate_art_paths(art)
|
||||
.err()
|
||||
.map(|e| api_error(StatusCode::BAD_REQUEST, &e))
|
||||
.map(|e| (e.clone(), api_error(StatusCode::BAD_REQUEST, &e)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -58,6 +67,10 @@ pub(crate) struct LibraryQuery {
|
||||
/// fetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the
|
||||
/// entries a given external provider owns; `?platform=` to one platform (case-insensitive —
|
||||
/// installed-store titles are `PC`, custom/provider entries carry whatever was authored).
|
||||
///
|
||||
/// **The operator's own lane additionally sees the titles they have HIDDEN**, each carrying
|
||||
/// `hidden: true`; every other lane gets them filtered out upstream and cannot tell they exist. The
|
||||
/// console needs them to offer "un-hide", and it is the only surface that does.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/library",
|
||||
@@ -68,26 +81,28 @@ pub(crate) struct LibraryQuery {
|
||||
("platform" = Option<String>, Query, description = "Only entries on this platform (case-insensitive, e.g. `PS2`)"),
|
||||
),
|
||||
responses(
|
||||
(status = OK, description = "Unified library across all stores", body = [crate::library::GameEntry]),
|
||||
(status = OK, description = "Unified library across all stores (the operator's lane also gets hidden entries, flagged)", body = [crate::library::OperatorGameEntry]),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn get_library(
|
||||
Extension(lane): Extension<AuthLane>,
|
||||
Query(q): Query<LibraryQuery>,
|
||||
) -> Json<Vec<crate::library::GameEntry>> {
|
||||
) -> Response {
|
||||
// The operator's list is a DIFFERENT TYPE, not the same one with a flag set — which is what
|
||||
// makes "a hidden title never reaches a paired client" structural rather than a filter someone
|
||||
// has to remember. The redaction below is skipped here because this arm is the operator's own
|
||||
// token: the command line being redacted is the one they typed.
|
||||
if lane.is_operator() {
|
||||
let mut rows = crate::library::all_games_for_operator();
|
||||
rows.retain(|r| matches_query(&r.entry, &q));
|
||||
for r in &mut rows {
|
||||
crate::library::proxy_local_art(&r.entry.id, &mut r.entry.art);
|
||||
}
|
||||
return Json(rows).into_response();
|
||||
}
|
||||
let mut games = crate::library::all_games();
|
||||
if let Some(provider) = q.provider.filter(|p| !p.is_empty()) {
|
||||
games.retain(|g| g.provider.as_deref() == Some(provider.as_str()));
|
||||
}
|
||||
if let Some(platform) = q.platform.filter(|p| !p.is_empty()) {
|
||||
games.retain(|g| {
|
||||
g.meta
|
||||
.platform
|
||||
.as_deref()
|
||||
.is_some_and(|p| p.eq_ignore_ascii_case(&platform))
|
||||
});
|
||||
}
|
||||
games.retain(|g| matches_query(g, &q));
|
||||
// Rewrite provider entries' local-file art into host art-proxy URLs so a client fetches covers
|
||||
// from the host (a provider like Playnite stores on-host paths; the payload stays tiny at any
|
||||
// library size, and the client never sees an unreachable `C:\…`).
|
||||
@@ -103,16 +118,97 @@ pub(crate) async fn get_library(
|
||||
// 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();
|
||||
}
|
||||
//
|
||||
// Unconditional now: the operator's lane returned above, so reaching here IS "some lane but
|
||||
// theirs". Leaving the old `if !lane.may_set_privileged_fields()` would read as though an
|
||||
// unredacted path still existed here, and would quietly stop redacting if that early return
|
||||
// ever moved.
|
||||
for g in &mut games {
|
||||
if let Some(l) = g.launch.as_mut() {
|
||||
if l.kind == "command" {
|
||||
l.value.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
Json(games)
|
||||
Json(games).into_response()
|
||||
}
|
||||
|
||||
/// The `?provider=` / `?platform=` narrowing, shared by both lane arms so they cannot drift.
|
||||
fn matches_query(g: &crate::library::GameEntry, q: &LibraryQuery) -> bool {
|
||||
if let Some(provider) = q.provider.as_deref().filter(|p| !p.is_empty()) {
|
||||
if g.provider.as_deref() != Some(provider) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(platform) = q.platform.as_deref().filter(|p| !p.is_empty()) {
|
||||
if !g
|
||||
.meta
|
||||
.platform
|
||||
.as_deref()
|
||||
.is_some_and(|p| p.eq_ignore_ascii_case(platform))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Request body for `setLibraryEntryHidden`.
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(crate) struct HiddenToggle {
|
||||
/// Whether this title should be hidden from every play surface.
|
||||
hidden: bool,
|
||||
}
|
||||
|
||||
/// What `setLibraryEntryHidden` echoes back.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct HiddenState {
|
||||
/// The entry id the call addressed.
|
||||
id: String,
|
||||
/// Its visibility after the call.
|
||||
hidden: bool,
|
||||
}
|
||||
|
||||
/// Hide or un-hide one library title
|
||||
///
|
||||
/// Curation, not access control: a hidden title disappears from every play surface — the console
|
||||
/// grid on a client, native clients, the GameStream app list, and launch resolution — while nothing
|
||||
/// is deleted and un-hiding restores it immediately. The operator's own console still lists it
|
||||
/// (flagged `hidden`) so it can be brought back.
|
||||
///
|
||||
/// Keyed by the entry's stable `<store>:<external_id>` id, which survives re-scans and reconciles by
|
||||
/// construction (D2). The id is **not** validated against the current library on purpose: a title
|
||||
/// can be legitimately absent at this moment (launcher closed, plugin mid-sync, drive unmounted),
|
||||
/// and refusing the operator's choice in that window would be worse than storing an id that
|
||||
/// currently matches nothing. Emits `library.changed` (source = the store) only on a real change.
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/library/hidden/{id}",
|
||||
tag = "library",
|
||||
operation_id = "setLibraryEntryHidden",
|
||||
params(("id" = String, Path, description = "The library entry id (e.g. `steam:70`)")),
|
||||
request_body = HiddenToggle,
|
||||
responses(
|
||||
(status = OK, description = "Stored; the entry's visibility after the call", body = HiddenState),
|
||||
(status = BAD_REQUEST, description = "Empty entry id", body = ApiError),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the settings", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn set_library_entry_hidden(
|
||||
Path(id): Path<String>,
|
||||
ApiJson(toggle): ApiJson<HiddenToggle>,
|
||||
) -> Response {
|
||||
if id.trim().is_empty() {
|
||||
return api_error(StatusCode::BAD_REQUEST, "entry id must not be empty");
|
||||
}
|
||||
match crate::library::set_entry_hidden(&id, toggle.hidden) {
|
||||
Ok(hidden) => {
|
||||
tracing::info!(entry = %id, hidden, "management API: library entry visibility set");
|
||||
Json(HiddenState { id, hidden }).into_response()
|
||||
}
|
||||
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Request body for `setLibraryScanner`.
|
||||
@@ -205,7 +301,9 @@ pub(crate) async fn create_custom_game(
|
||||
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) {
|
||||
if let Some((_, denied)) =
|
||||
check_entry_fields(lane, &input.art, input.launch.as_ref(), &input.prep)
|
||||
{
|
||||
return denied;
|
||||
}
|
||||
match crate::library::add_custom(input) {
|
||||
@@ -238,7 +336,9 @@ pub(crate) async fn update_custom_game(
|
||||
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) {
|
||||
if let Some((_, denied)) =
|
||||
check_entry_fields(lane, &input.art, input.launch.as_ref(), &input.prep)
|
||||
{
|
||||
return denied;
|
||||
}
|
||||
use crate::library::MutateOutcome;
|
||||
@@ -364,11 +464,14 @@ pub(crate) async fn reconcile_provider_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) {
|
||||
if let Some((reason, 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"
|
||||
title = %e.title,
|
||||
reason = %reason,
|
||||
"library reconcile refused"
|
||||
);
|
||||
return denied;
|
||||
}
|
||||
|
||||
@@ -1198,6 +1198,10 @@ fn every_route_is_classified_for_the_plugin_and_cert_lanes() {
|
||||
("GET", "/api/v1/library/art/{id}/{kind}", true, true),
|
||||
("GET", "/api/v1/library/scanners", true, false),
|
||||
("PUT", "/api/v1/library/scanners/{id}", true, false),
|
||||
// Hiding a title is the OPERATOR curating their own library: a plugin has no business
|
||||
// deciding what the operator sees, and a paired client must not be able to hide a game on
|
||||
// the host it is streaming from. Neither lane, unlike the scanner toggle above.
|
||||
("PUT", "/api/v1/library/hidden/{id}", false, false),
|
||||
("POST", "/api/v1/library/custom", true, false),
|
||||
("PUT", "/api/v1/library/custom/{id}", true, false),
|
||||
("DELETE", "/api/v1/library/custom/{id}", true, false),
|
||||
@@ -2048,6 +2052,40 @@ async fn library_scanner_list_and_unknown_toggle() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A library id is `<store>:<external_id>`, so the hide route's path segment CONTAINS A COLON —
|
||||
/// and for Heroic (`heroic:legendary:<hash>`) it contains two.
|
||||
///
|
||||
/// This is the one thing about the endpoint that could be silently wrong: if the router did not
|
||||
/// match, or split on the colon, the console's hide button would 404 against an id the host itself
|
||||
/// produced. Asserting "not 404" is the whole point, so the body is deliberately INVALID — that
|
||||
/// stops at the JSON layer with a 4xx and never reaches the handler, which would otherwise write
|
||||
/// `library-hidden.json` into the developer's real config dir (the same reason the toggle test
|
||||
/// above only exercises its rejection path).
|
||||
#[tokio::test]
|
||||
async fn hide_route_matches_ids_containing_colons() {
|
||||
let app = test_app(test_state(), None);
|
||||
let put = |id: &str| {
|
||||
axum::http::Request::put(format!("/api/v1/library/hidden/{id}"))
|
||||
.header(axum::http::header::CONTENT_TYPE, "application/json")
|
||||
// Not a `HiddenToggle` — rejected before the handler runs.
|
||||
.body(Body::from(serde_json::json!({"nope": 1}).to_string()))
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
for id in ["steam:70", "custom:abc", "heroic:legendary:fc0b13b7"] {
|
||||
let (s, json) = send(&app, put(id)).await;
|
||||
assert_ne!(
|
||||
s,
|
||||
StatusCode::NOT_FOUND,
|
||||
"`{id}` must ROUTE — a colon is a legal path character and every library id has one: {json}"
|
||||
);
|
||||
assert!(
|
||||
s.is_client_error(),
|
||||
"a body that is not a HiddenToggle must be refused, not accepted: {s} {json}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ library providers
|
||||
|
||||
/// Provider reconcile validation (the write path itself is unit-tested in `library::custom`
|
||||
|
||||
@@ -96,11 +96,12 @@ those hiccups out, at that buffer's worth of added delay. Linux and Windows apps
|
||||
home; the Apple and Android apps have carried the same setting for a while, and it is stored
|
||||
under the same name, so a [profile](/docs/profiles-and-links) means the same thing on every device.
|
||||
|
||||
**Smoothness buffer** — *default: Automatic (two frames).* Only shown under **Smoothness**. How
|
||||
many frames are held back before showing. Each frame absorbs roughly one screen refresh of network
|
||||
hiccup and costs one refresh of delay — so on a 120 Hz screen, two frames is about 17 ms of extra
|
||||
delay bought against 17 ms of jitter. If you never see stutter, you don't need this. Wherever
|
||||
**Prioritize** is offered, and greyed out until you pick Smoothness.
|
||||
**Smoothness buffer** — *default: Automatic (two frames).* How many frames are held back before
|
||||
showing. Each frame absorbs roughly one screen refresh of network hiccup and costs one refresh of
|
||||
delay — so on a 120 Hz screen, two frames is about 17 ms of extra delay bought against 17 ms of
|
||||
jitter. If you never see stutter, you don't need this. The row appears wherever **Prioritize** is
|
||||
offered, and only once you have picked **Smoothness** — under Lowest latency there are no held
|
||||
frames for it to count, so it isn't shown at all.
|
||||
|
||||
**V-Sync** — *default: on.* Tear-free presentation. Turning it off asks the GPU to show each frame
|
||||
the instant it's ready instead of waiting for the screen's next refresh: the lowest delay a display
|
||||
@@ -263,6 +264,43 @@ when you return to the host list. The console home carries the row for the deskt
|
||||
shares the store — a Gaming-Mode launch is fullscreen whatever it says. iPhone, iPad, Apple TV
|
||||
and Android have no equivalent.
|
||||
|
||||
## Interface
|
||||
|
||||
These change how the client itself looks and behaves. None of them touches a stream, so none of them
|
||||
can live in a [profile](/docs/profiles-and-links) — they are decisions about the device in front of
|
||||
you.
|
||||
|
||||
**Gamepad-optimized browsing** — *default: on.* Swaps the touch or desktop home for the
|
||||
controller-optimized one: the host carousel, larger focus targets, a swipeable cover browser, and
|
||||
settings you can step with a thumbstick. The Apple and Android apps have this switch. Turn it off to
|
||||
stay in the touch interface even with a pad in your hands. On Linux, Windows and the Steam Deck the
|
||||
controller-optimized home is a separate entry point rather than a switch, so there is nothing to
|
||||
turn off. An Android TV is always in this mode — its remote is the only input it has.
|
||||
|
||||
**Show it** — *default: With a controller.* Only shown while the switch above is on, and it decides
|
||||
*when* that switch takes effect. **With a controller** is the long-standing behaviour: the
|
||||
controller-optimized home appears as a pad connects and the touch interface returns when the last one
|
||||
disconnects. **Always** keeps the controller-optimized home either way — for a phone or tablet that
|
||||
lives docked to a TV, where the pad isn't always awake but the couch layout is always the one you
|
||||
want. Apple and Android. (An Android TV is in that mode regardless, so the choice changes nothing
|
||||
there.)
|
||||
|
||||
**Background** — *default: Violet.* The colour family the controller-optimized home's living backdrop
|
||||
drifts through. Thirteen of them: seven dark fields — **Violet**, **OLED**, **Nebula**, **Abyss**,
|
||||
**Ember**, **Moss**, **Graphite** — then six pale ones, **Holo**, **Sunset**, **Bloom**, **Dawn**,
|
||||
**Mint** and **Opal**, which flip the whole interface to dark text on a light field. The backdrop
|
||||
recolours as you step the row, so pick by looking. **OLED** is the one with a practical point rather
|
||||
than a decorative one: it is true black — most of the frame is pixels switched off, which on an OLED
|
||||
or AMOLED panel means no glow and no power drawn, with only a faint violet ember left in one corner.
|
||||
Stored under the same name on every client, so a phone, a Deck and a desktop set to Mint all look
|
||||
alike. Appearance only — nothing about a stream depends on it.
|
||||
|
||||
The row lives in the controller-optimized settings themselves — the screen you reach with **X** from
|
||||
the controller-optimized home — on every platform that has one, which includes the Steam Deck and the
|
||||
Linux and Windows console home. The Apple TV is the exception: it carries **Background** in its
|
||||
ordinary Settings instead, next to **Show it**, because its controller-optimized home needs a real
|
||||
controller to open and the palettes would otherwise be unreachable from the Siri Remote.
|
||||
|
||||
## Overlay
|
||||
|
||||
**Statistics overlay** — *default: Normal.* Four tiers — Off, Compact, Normal, Detailed — each a
|
||||
@@ -292,6 +330,10 @@ stay global and **cannot be put in a settings profile**:
|
||||
profile forwards.
|
||||
- **Auto-wake on connect** and **Show game library** — decisions about this device and this network,
|
||||
not about how a given host is streamed.
|
||||
- Everything under **Interface** — **Gamepad-optimized browsing**, **Show it** and **Background**.
|
||||
How this client looks and which layout it wears has nothing to do with how a host streams to it,
|
||||
so binding them to a host would only make the same device change appearance depending on what it
|
||||
connected to.
|
||||
|
||||
One switch you might expect here isn't in Settings at all: **Share clipboard** lives in a saved
|
||||
host's own edit sheet, because handing a machine your clipboard is a decision about that one host —
|
||||
|
||||
@@ -143,17 +143,21 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
|
||||
|---|---|---|
|
||||
| `PUNKTFUNK_GAMEPAD` | `xbox360` · `xboxone` · `dualsense` · `dualsenseedge` · `dualshock4` · `steamdeck` · `switchpro` · `steamcontroller` · `steamcontroller2` (aliases: `ps5`, `edge`, `ps4`, `deck`, `switch`, `sc2`, `ibex`, …) | The virtual pad the host creates. Usually **auto-resolved from the client's physical controller** — set this only to force a type. `xbox360` (XInput) is the universal fallback. `dualsenseedge` gives the client's back paddles native buttons; `switchpro` gives Nintendo-family pads correct glyphs/layout + gyro. `steamcontroller2` (the 2026 Steam Controller) is passed through **as-is** — the host presents a real SC2 (`28DE:1302`) that Steam Input drives directly, mirroring the physical pad's raw reports (Linux only). DualSense (Edge)/DualShock 4 work on Linux (UHID) and Windows (UMDF); the Steam Deck pad too (Windows via the promoted UMDF identity); Switch Pro and the classic Steam Controller need Linux UHID. Unsupported choices fold to Xbox 360. |
|
||||
| `PUNKTFUNK_STEAM_GADGET` | `1` · `0` | Force the raw USB-gadget virtual Steam Deck on/off. **On by default on SteamOS**, off elsewhere. Lets Steam promote the virtual Deck to full Steam Input. |
|
||||
| `PUNKTFUNK_PAD_AUDIO` | `1` · `0` *(default on)* | **(Windows)** Controller audio: what a game plays through the DualSense's built-in speaker and voice-coil haptics is streamed to the client's physical pad as its own low-latency plane. On by default and free while idle — silence is never encoded or sent; `0` turns it off host-wide. |
|
||||
| `PUNKTFUNK_PAD_AUDIO_SLOTS` | `1`–`4` *(default `1`)* | **(Windows)** How many controllers can have their own audio at once. Each slot is a pre-provisioned virtual endpoint, so the default stays at one; raise it for multi-pad sessions. |
|
||||
|
||||
## Audio / microphone
|
||||
|
||||
| Setting | Values | Meaning |
|
||||
|---|---|---|
|
||||
| `PUNKTFUNK_AUDIO_QUALITY` | `low` · `standard` · `high` *(default `high`)* | Desktop-audio encode quality. `high` (stereo 256 kbps Opus, effectively transparent) costs about 1 % of a normal video bitrate, so there's rarely a reason to go lower. `standard` is exactly the pre-0.25 encoder (stereo 128 kbps) — handy for an A/B comparison; `low` is for genuinely constrained links (noticeably lossy on music, still fine for game audio and voice). A typo warns in the log and keeps `high` rather than silently downgrading. Host-side only — clients play whatever arrives, no client setting involved. |
|
||||
| `PUNKTFUNK_AUDIO_REDUNDANCY` | `1` · `0` *(default: automatic)* | Send audio packets redundantly so a lossy link doesn't crackle. Leave it unset: the host turns redundancy on by itself, only toward clients that support it and only while the link is actually losing packets. `1` forces it on for the whole session, `0` never sends it. |
|
||||
| `PUNKTFUNK_AUDIO_GAIN` | float (default `1.0`) | **(Moonlight/GameStream sessions only)** Linear gain applied to captured desktop audio — bump it for a quiet source. The native `punktfunk/1` path ignores it; adjust the source's own volume there instead. |
|
||||
| `PUNKTFUNK_MIC_DEVICE` | name substring | **(Windows)** Target mic-uplink device by friendly-name substring (first match wins). |
|
||||
| `PUNKTFUNK_MIC_LEGACY_BUFFER` | `1` | Restore the fixed pre-adaptive mic buffering (a ~48 ms prime and ~120 ms cap on Windows; a buffer scaled to the recording app's audio quantum on Linux) instead of the adaptive per-client jitter target. One-release escape hatch: if the microphone coming out of the host only sounds right *with* this set, that's a bug — please report it. |
|
||||
| `PUNKTFUNK_NO_MIC_INSTALL` | set | **(Windows)** Skip installing the virtual-mic driver (e.g. when the host runs as SYSTEM). |
|
||||
| `PUNKTFUNK_HOST_AUDIO` | set | **(Windows)** Also play the stream's audio on the host's own speakers. While a session is capturing desktop audio the host parks the default playback device on a silent sink, so sound comes out of the *client* only — that's why the PC goes quiet when a stream starts. Set this to prefer a real output device instead (audible on both ends). The default is put back when the capture closes. |
|
||||
| `PUNKTFUNK_KEEP_DEFAULT` | set | **(Windows)** Never touch the default playback/recording devices at all — the host leaves whatever you chose in Sound settings in place. The mic uplink still picks a target device; you may then have to select it yourself. |
|
||||
| `PUNKTFUNK_AUDIO_OUTPUT_MODE` | `client_only` *(default)* · `host_and_client` · `follow_default` | **(Windows)** Where desktop audio is audible while a stream runs. `client_only` parks playback on a silent endpoint so sound comes out of the *client* only — that's why the PC goes quiet when a stream starts; everything is put back when it ends. `host_and_client` prefers a real output device, so the host's speakers keep playing too. `follow_default` never touches your default devices at all — the host just captures whatever your default playback device is (the mic uplink still picks a target device; you may have to select it yourself). A misspelled value warns in the log and uses `client_only`. The pre-0.25 flags `PUNKTFUNK_HOST_AUDIO=1` and `PUNKTFUNK_KEEP_DEFAULT=1` still work as aliases for the last two; `follow_default` wins if both are set. |
|
||||
| `PUNKTFUNK_NO_AUDIO_MINT` | set | **(Windows)** Don't provision the host's own dedicated virtual audio endpoints at startup (they're minted from Steam's streaming-audio driver where it's installed, and give capture a stable target that renaming or unplugging hardware can't break). With this set — or whenever minting isn't possible — the host picks devices by name instead, exactly as before 0.25. |
|
||||
|
||||
## Clipboard
|
||||
|
||||
@@ -189,6 +193,18 @@ it — leave it or delete it, it makes no difference.
|
||||
| `PUNKTFUNK_MDNS` | `1` · `0` *(default on)* | mDNS adverts (native + GameStream). `0` skips them (same as `--no-mdns`) — for networks/containers where multicast doesn't work; add the host by address in the client instead. |
|
||||
| `PUNKTFUNK_DATA_PORT` | port | Pin the per-session video data plane to a fixed UDP port and stream direct (no hole-punch) — open exactly that port in the host firewall. Same as `serve --data-port`; see [Troubleshooting](/docs/troubleshooting). Default: random port + hole-punch. |
|
||||
| `PUNKTFUNK_IDLE_TIMEOUT_MS` | ms (default `8000`) | How long the host waits before declaring a client that vanished (cable pulled, Wi-Fi dropped) gone — which is when a kept virtual display starts its linger. Lower it (e.g. `3000`) to reclaim displays sooner; it's clamped to ≥1 s and the keep-alive scales with it, so a live session never false-disconnects. A deliberate quit is instant regardless. Same as `--idle-timeout-ms` on `punktfunk1-host`. |
|
||||
| `PUNKTFUNK_JUMBO` | `1` | Stream in **jumbo frames** — ~9000-byte packets instead of the standard ~1500-byte ones, so a high-bitrate session spends less CPU and per-packet overhead on a wired LAN. Off by default, and safe to turn on: see the note below the table. |
|
||||
| `PUNKTFUNK_WIRE_MTU` | on-wire IP MTU, e.g. `9000` | The pick-your-own-number version of the same switch — and also the escape hatch for **small**-MTU links. A value above 1500 enables jumbo frames with your number as the target (and outranks `PUNKTFUNK_JUMBO`); a value *below* 1500 shrinks every session's packets from the start, for a path that can't carry full-size ones (a VPN or tunnel — the host normally learns this by itself, but the override skips the one degraded first session). Use the on-wire IP MTU your NIC reports (`ip link` on Linux, `netsh interface ipv4 show subinterfaces` on Windows) — IP/UDP overheads are subtracted for you. |
|
||||
|
||||
> **Jumbo frames** need every hop to carry them, and the host verifies rather than trusts.
|
||||
> Sessions still *start* on standard-size packets; with the opt-in set, the host probes the path,
|
||||
> and only once the probe proves it — and the client acknowledges the switch — does the stream
|
||||
> grow to the large packets, mid-session, with no reconnect. A path that can't take them, or an
|
||||
> older client, simply stays at the standard size; the only cost of leaving the opt-in on is a few
|
||||
> extra probe packets at connect. Two things it can't do for you: the host NIC, the client NIC and
|
||||
> every switch in between must have jumbo frames enabled in *their* settings first (usually a
|
||||
> field called MTU, set to `9000`) — and both ends need Punktfunk 0.25 or newer. Native
|
||||
> `punktfunk/1` sessions only; Moonlight sessions always use standard packets.
|
||||
|
||||
## Auth, API & paths
|
||||
|
||||
@@ -198,6 +214,8 @@ it — leave it or delete it, it makes no difference.
|
||||
| `PUNKTFUNK_UI_PASSWORD` | password | Web-console login password. Normally generated on first start and stored in `~/.config/punktfunk/web-password` — see [Forgot your Password?](/docs/forgot-password). |
|
||||
| `PUNKTFUNK_PLUGIN_TOKEN` | token | The scoped token the [plugin/scripting runner](/docs/plugins) uses — a narrower credential than `PUNKTFUNK_MGMT_TOKEN`, never full admin. Same precedence: if unset it's generated and persisted to `~/.config/punktfunk/plugin-token`. Set only to pin a specific token. |
|
||||
| `PUNKTFUNK_CONFIG_DIR` | path | Override the config directory (default `~/.config/punktfunk`) — pairing state, certs, apps.json, captures. |
|
||||
| `PUNKTFUNK_UI_PLUGIN_PORT` | port *(default: console port + 1)* | The separate port [plugin](/docs/plugins) UIs are served from. They get their own origin on purpose — a plugin page can never act as *you* on the console. If the console log says this port couldn't be opened (plugin UIs then stay disabled rather than sharing the console's origin), point it at a free port and restart. |
|
||||
| `PUNKTFUNK_LIBRARY_ART_ROOTS` | directories, `;`-separated | Where the host is allowed to read game artwork from when serving your library. Defaults to sensible platform roots (your home directory on Linux/macOS); set it when box art lives elsewhere — a second drive, a network mount. The host log's "not under an allowed art root" line is this knob's cue. |
|
||||
|
||||
## Updates
|
||||
|
||||
@@ -219,6 +237,7 @@ notes for context.
|
||||
| `PUNKTFUNK_GSO` | `1` · `0` | UDP segmentation offload on the send path (coalesce a frame's packets into kernel super-buffers) — cuts send CPU ~30%, but its line-rate packet trains can cost delivered throughput on constrained links (measured on a 2.5GbE hop). The default differs by platform. **Windows: on by default** (Send Offload — the lever that gets past ~1 Gbps, since Windows otherwise does one send call per packet); set `0` if a constrained link shows lost throughput. It also latches itself off for the rest of the run the first time the OS/NIC/path rejects an offloaded send. **Linux: off by default** until send pacing spaces the super-buffers; set `1` to opt in (auto-falls back to `sendmmsg` on kernels/paths without support). |
|
||||
| `PUNKTFUNK_SPLIT_ENCODE` | `0`/`disable` · `1`/`auto` · `2` · `3` | NVENC N-way split-encode for very high pixel rates (5K@240). `auto` picks automatically above ~1 Gpix/s. H.264 never splits (not applicable per the SDK); on HEVC a *forced* split disables sub-frame readback (mutually unsupported) — set `0` to choose sub-frame instead. |
|
||||
| `PUNKTFUNK_NVENC_SUBFRAME` | `0` · `1` | NVENC sub-frame (slice-level) readback for lower latency on sync sessions. Default: on where the GPU supports it (Linux direct NVENC). `0` = never; `1` = force. On HEVC it yields to a forced split-encode (the SDK documents the pair unsupported). |
|
||||
| `PUNKTFUNK_NVENC_SPLIT_ARBITRATE` | `1` | Opt-in: let the host change its split-encode decision **live**, mid-session, as the pixel rate moves, instead of only choosing once at session start. Currently wired on the Linux direct-NVENC path. Only interesting alongside `PUNKTFUNK_SPLIT_ENCODE=auto` at very high pixel rates. |
|
||||
| `PUNKTFUNK_GPU_PRIORITY_CLASS` | `off` · `normal` · `high` · `realtime` · `auto` | **(Windows)** GPU scheduling priority for capture/encode under a GPU-saturating game. Default `auto` (starts `high`, upgrades to `realtime` when it's safe — e.g. HAGS off); `high` pins the static pre-gate behaviour; `realtime` is the strongest lever but can freeze NVENC on some setups. |
|
||||
| `PUNKTFUNK_IDD_DEPTH` | `N` (default `2`) | **(Windows)** IDD-push pipeline depth. `1` cuts latency once GPU priority is raised; higher smooths a contended GPU. |
|
||||
|
||||
@@ -238,6 +257,7 @@ A few knobs are read by the native **clients**, not the host:
|
||||
| Setting | Values | Meaning |
|
||||
|---|---|---|
|
||||
| `PUNKTFUNK_DECODER` | `native-vulkan` · `native-vaapi` (Linux) · `native-d3d11va` (Windows) · `software` | Force the decode path. Default auto-selects hardware per GPU vendor and falls back on its own: **Linux** — Vulkan Video first on NVIDIA and AMD, VAAPI first on Intel and anything else; **Windows** — Vulkan Video first on NVIDIA and AMD, D3D11VA first on Intel and anything else. Whichever isn't first is the next thing tried, with software last (OpenH264 for H.264, rav1d for AV1 — there is no software HEVC, so a client that lands there reconnects on a codec it can decode). The names are the ones the [stats overlay](/docs/stats) prints, so a pin and a reading match. The older spellings `vulkan`, `vaapi` and `d3d11va` named the FFmpeg-backed decoders the clients used before and still work — each migrates onto the native path for the same hardware, and the client says so in its log. |
|
||||
| `PUNKTFUNK_VAAPI_DEVICE` | path, e.g. `/dev/dri/renderD129` | **(Linux)** Pin the DRM render node the `native-vaapi` decoder opens. Unset, the client tries the nodes in order and takes the first that can decode the stream — set this on a multi-GPU box when it lands on the wrong one. |
|
||||
| `PUNKTFUNK_PREFER_PYROWAVE` | `1` | Ask for the [PyroWave](/docs/pyrowave) wavelet codec on a wired link, where the client's own setting isn't reachable (the gamepad console, a headless launch). |
|
||||
| `PUNKTFUNK_OSD_SCALE` | multiplier, e.g. `1.5` *(default `1`)* | Size of the in-stream overlay — the stats OSD, the capture hint and the start banner. They already follow your display's scaling setting (200 % display → twice the pixels), so set this only to nudge that: bigger for a TV across the room, smaller if your compositor reports an aggressive scale. Clamped to 0.5×–4×, and a line that would run off the screen is shrunk to fit. |
|
||||
| `PUNKTFUNK_NO_AEC` | `1` | Turn the microphone's echo cancellation off for this run, whatever **Echo cancellation** says in [client settings](/docs/client-settings#audio). One-way: it can only switch the processing off, never back on, and the setting is the normal way to control it. Linux and Windows clients. |
|
||||
|
||||
@@ -56,9 +56,39 @@ sudo pacman -Sy punktfunk-web # optional browser management console
|
||||
packages against the key you just trusted. Arch is rolling, so the packages are built against
|
||||
current Arch sonames — keep the box itself updated too.)
|
||||
|
||||
Step 2 **appends**, so running it twice leaves two `[punktfunk]` blocks and every later pacman
|
||||
run opens with `error: could not register 'punktfunk' database (database already registered)`.
|
||||
It is harmless — pacman ignores the duplicate and carries on — but to silence it, delete the
|
||||
extra block from `/etc/pacman.conf`.
|
||||
|
||||
Then the same first-run steps as a source build (printed by the install scriptlet): `input`
|
||||
group, `host.env`, `systemctl --user enable --now punktfunk-host` — see the next section.
|
||||
|
||||
### If pacman says `unable to satisfy dependency 'libavcodec.so=…'`
|
||||
|
||||
```
|
||||
:: unable to satisfy dependency 'libavcodec.so=62-64' required by punktfunk-host
|
||||
```
|
||||
|
||||
`punktfunk-host` links FFmpeg, so it depends on the exact libav sonames it was built against —
|
||||
FFmpeg 8 provides `libavcodec.so=62`, FFmpeg 9 provides `libavcodec.so=63`. This message means the
|
||||
package on offer was built against a *different* FFmpeg major than your box has. Because pacman
|
||||
prepares the whole transaction at once, it stops your entire `pacman -Syu`, not just this package.
|
||||
|
||||
The bound is deliberate. Without it the upgrade succeeds and leaves a host binary that cannot
|
||||
start at all — exit 127 before `main()`, in a systemd restart loop, with nothing in its own log
|
||||
to explain it (`ldd /usr/bin/punktfunk-host | grep 'not found'` is the one-line diagnosis).
|
||||
|
||||
1. `sudo pacman -Syyu` — a forced db refresh, in case the matching build is already published.
|
||||
Compare `pacman -Si punktfunk-host` against your `pacman -Q ffmpeg`.
|
||||
2. Still refused? Then we published a build made against the wrong FFmpeg — please report it. The
|
||||
repair arrives as a higher **pkgrel** of the same version (`0.25.0-2`), so a later `-Syu`
|
||||
picks it up with nothing to undo.
|
||||
3. To let the rest of the system upgrade in the meantime: `sudo pacman -Syu --ignore punktfunk-host`.
|
||||
If pacman still refuses (your *installed* copy is the one carrying the bound), remove it with
|
||||
`sudo pacman -Rdd punktfunk-host`, upgrade, and install it again once the rebuild lands. Either
|
||||
way the host stays down until then — that is the soname break itself, not a second fault.
|
||||
|
||||
## Build from source — Arch Linux (mutable)
|
||||
|
||||
```sh
|
||||
|
||||
+1
-3
@@ -13,7 +13,7 @@
|
||||
"typescript": "^5.9.3",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@punktfunk/host": "^0.1.2",
|
||||
"@punktfunk/host": "^0.1.3",
|
||||
"effect": "^4.0.0-beta.98",
|
||||
"react": "^19.2.0",
|
||||
},
|
||||
@@ -68,8 +68,6 @@
|
||||
|
||||
"@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" } }],
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@punktfunk/plugin-kit",
|
||||
"version": "0.3.2",
|
||||
"version": "0.3.3",
|
||||
"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",
|
||||
@@ -56,7 +56,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "^4.0.0-beta.98",
|
||||
"@punktfunk/host": "^0.1.2",
|
||||
"@punktfunk/host": "^0.1.3",
|
||||
"react": "^19.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
|
||||
@@ -3,12 +3,46 @@
|
||||
// Schema-based errors with status annotations.
|
||||
import { Data } from "effect";
|
||||
|
||||
/** A management-API call through the pf facade failed. */
|
||||
/**
|
||||
* A management-API call through the pf facade failed.
|
||||
*
|
||||
* The `message` getter is load-bearing, not decoration. `Data.TaggedError`'s default string form is
|
||||
* the bare tag, and the sync engine logs `sync (${reason}) failed: ${e.cause}` — so a host that
|
||||
* refused a reconcile with a perfectly clear 400 surfaced in the plugin log as exactly
|
||||
* `sync (startup) failed: HostRequestError`, with the method, the path and the host's own
|
||||
* explanation all discarded. Diagnosing the 2026-08-08 Lutris/Steam art rejection meant reading the
|
||||
* HOST's journal instead, because the plugin's own log could not distinguish a validation refusal
|
||||
* from the host being down.
|
||||
*/
|
||||
export class HostRequestError extends Data.TaggedError("HostRequestError")<{
|
||||
readonly method: string;
|
||||
readonly path: string;
|
||||
readonly cause: unknown;
|
||||
}> {}
|
||||
}> {
|
||||
override get message(): string {
|
||||
return `${this.method} ${this.path} failed: ${describeCause(this.cause)}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render whatever `pf.request` rejected with into one line.
|
||||
*
|
||||
* An `Error` stringifies usefully already; a plain object (the host's `{error: "…"}` body, which is
|
||||
* what a rejected reconcile actually carries) stringifies to `[object Object]`, which is how the
|
||||
* useful half of the message got lost. JSON is the fallback so a body-shaped cause survives, and a
|
||||
* cycle or a BigInt degrades to `String(cause)` rather than throwing inside error formatting.
|
||||
*/
|
||||
const describeCause = (cause: unknown): string => {
|
||||
if (cause instanceof Error) return cause.message;
|
||||
if (typeof cause === "object" && cause !== null) {
|
||||
try {
|
||||
return JSON.stringify(cause);
|
||||
} catch {
|
||||
return String(cause);
|
||||
}
|
||||
}
|
||||
return String(cause);
|
||||
};
|
||||
|
||||
/** config.json exists but does not parse/decode. */
|
||||
export class ConfigParseError extends Data.TaggedError("ConfigParseError")<{
|
||||
|
||||
@@ -7,7 +7,11 @@ import { Effect, FileSystem, Layer, Path, Schema, type 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";
|
||||
import {
|
||||
HostClient,
|
||||
type HostClientService,
|
||||
PluginInfo,
|
||||
} from "./host-client.js";
|
||||
|
||||
/**
|
||||
* Everything `HttpApiBuilder.layer` needs beyond the router, satisfied from effect core —
|
||||
@@ -192,7 +196,7 @@ export const serveUi = (
|
||||
return handler(req);
|
||||
};
|
||||
|
||||
return yield* Effect.acquireRelease(
|
||||
const handle = yield* Effect.acquireRelease(
|
||||
Effect.tryPromise({
|
||||
try: () =>
|
||||
servePluginUi(host.facade, {
|
||||
@@ -212,4 +216,54 @@ export const serveUi = (
|
||||
}),
|
||||
(handle) => Effect.promise(() => handle.close()).pipe(Effect.ignore),
|
||||
);
|
||||
|
||||
yield* verifyCategoryLanded(opts.category, info.name, host);
|
||||
return handle;
|
||||
});
|
||||
|
||||
/**
|
||||
* Read our own directory entry back and warn if the requested `category` is not on it.
|
||||
*
|
||||
* `category` travels through the UNTYPED `pf.request` seam precisely so an older host ignores it
|
||||
* instead of rejecting the registration — which means dropping it is SILENT by design, at three
|
||||
* different layers (an old host, an old runner-resolved SDK, a typo). On 2026-08-08 the middle one
|
||||
* happened: `@punktfunk/host@0.1.2` was published before it forwarded the field, so every installed
|
||||
* library scanner registered without a category. The visible result was Lutris and Heroic sitting in
|
||||
* the console nav — which they explicitly opt out of — and their settings unreachable, because the
|
||||
* Library section's Game sources surface lists exactly the plugins whose category IS `library`.
|
||||
* Nothing logged anything.
|
||||
*
|
||||
* So this asks the host what it actually recorded. Same spirit as the store-claim degradation
|
||||
* warning in `defineLibraryPlugin`: turn a silent no-op into one line that names the fix. Purely
|
||||
* advisory — a failed read, or a host too old to report the field, must never keep a working plugin
|
||||
* from starting.
|
||||
*/
|
||||
const verifyCategoryLanded = (
|
||||
category: string | undefined,
|
||||
id: string,
|
||||
host: { readonly request: HostClientService["request"] },
|
||||
): Effect.Effect<void> => {
|
||||
if (category === undefined) return Effect.void;
|
||||
return host.request("GET", "/plugins").pipe(
|
||||
Effect.flatMap((body) => {
|
||||
const mine = (Array.isArray(body) ? body : []).find(
|
||||
(p): p is { id: string; category?: string } =>
|
||||
typeof p === "object" &&
|
||||
p !== null &&
|
||||
(p as { id?: unknown }).id === id,
|
||||
);
|
||||
// Not finding ourselves is not evidence of anything: the lease is registered
|
||||
// best-effort, so a host that was momentarily away simply has not listed us yet.
|
||||
if (!mine || mine.category === category) return Effect.void;
|
||||
return Effect.logWarning(
|
||||
`registered without category "${category}" (the host reports ` +
|
||||
`${mine.category === undefined ? "none" : `"${mine.category}"`}). ` +
|
||||
`This plugin will appear in the console's sidebar instead of its intended ` +
|
||||
`section. The usual cause is an @punktfunk/host older than 0.1.3, which drops ` +
|
||||
`the field before registering — update it, or the host, to resolve it.`,
|
||||
);
|
||||
}),
|
||||
// Advisory only: never let a diagnostic take down the plugin it is diagnosing.
|
||||
Effect.ignore,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// What a kit error says when something interpolates it — which is the whole diagnosis surface a
|
||||
// plugin operator gets, because `sync-engine`'s failure path logs `${e.cause}` and nothing else.
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { HostRequestError } from "../src/errors.js";
|
||||
|
||||
describe("HostRequestError", () => {
|
||||
// Regression for 2026-08-08: this printed the bare tag, so `plugin:lutris sync (startup)
|
||||
// failed: HostRequestError` was the ENTIRE record of a host that had answered with a precise
|
||||
// 400. Interpolation is the assertion because interpolation is what the sync engine does.
|
||||
test("names the call and carries the host's explanation", () => {
|
||||
const err = new HostRequestError({
|
||||
method: "PUT",
|
||||
path: "/library/provider/lutris?store=lutris",
|
||||
cause: new Error("art.portrait: local art must be an image file"),
|
||||
});
|
||||
|
||||
expect(`${err}`).toContain("PUT");
|
||||
expect(`${err}`).toContain("/library/provider/lutris?store=lutris");
|
||||
expect(`${err}`).toContain("art.portrait");
|
||||
expect(`${err}`).not.toBe("HostRequestError");
|
||||
});
|
||||
|
||||
// The host's rejection arrives as a parsed `{error: "…"}` body, not an Error. Left to default
|
||||
// stringification that is `[object Object]` — the useful half lost a second way.
|
||||
test("renders an object cause instead of [object Object]", () => {
|
||||
const err = new HostRequestError({
|
||||
method: "PUT",
|
||||
path: "/library/provider/steam",
|
||||
cause: { error: "art.header: local art must be an image file" },
|
||||
});
|
||||
|
||||
expect(`${err}`).toContain("art.header");
|
||||
expect(`${err}`).not.toContain("[object Object]");
|
||||
});
|
||||
|
||||
// Error formatting must never itself throw: a cycle (or a BigInt) would make JSON.stringify
|
||||
// blow up INSIDE the catch that is trying to report the original failure.
|
||||
test("survives a cause that cannot be serialized", () => {
|
||||
const cyclic: Record<string, unknown> = {};
|
||||
cyclic.self = cyclic;
|
||||
const err = new HostRequestError({
|
||||
method: "GET",
|
||||
path: "/library",
|
||||
cause: cyclic,
|
||||
});
|
||||
|
||||
expect(() => `${err}`).not.toThrow();
|
||||
expect(`${err}`).toContain("/library");
|
||||
});
|
||||
|
||||
// The tag stays matchable — `Effect.catchTag`/`_tag` narrowing must not be traded away for a
|
||||
// readable message.
|
||||
test("keeps its tag and its fields", () => {
|
||||
const err = new HostRequestError({
|
||||
method: "DELETE",
|
||||
path: "/library/provider/heroic",
|
||||
cause: "boom",
|
||||
});
|
||||
|
||||
expect(err._tag).toBe("HostRequestError");
|
||||
expect(err.method).toBe("DELETE");
|
||||
expect(err.path).toBe("/library/provider/heroic");
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,11 @@ for a in json.load(sys.stdin):
|
||||
print(a.get("id",""));break' "$1" 2>/dev/null
|
||||
}
|
||||
_urlencode() { python3 -c 'import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1],safe=""))' "$1"; }
|
||||
# ⚠ Do NOT add helpers here for a workflow step that runs against a CHECKED-OUT RELEASE TAG
|
||||
# (arch.yml's release-rebuild dispatch). Callers source this file from the working tree, so such a
|
||||
# step gets the version of this file that shipped in that tag — never the one you just wrote. That
|
||||
# logic belongs in the workflow, which is always read from the dispatched ref. Cost this once
|
||||
# already: `prune_release_assets: command not found`, after the packages published fine.
|
||||
|
||||
# _release_notes_path TAG
|
||||
# Print the path of the in-repo release notes for TAG (docs/releases/<TAG>.md) IFF it exists,
|
||||
@@ -165,6 +170,7 @@ upsert_asset() {
|
||||
if _put_asset "$rid" "$sums" "$name.sha256"; then rm -f "$sums"; else rm -f "$sums"; return 1; fi
|
||||
}
|
||||
|
||||
|
||||
# apply_release_notes RELEASE_ID TAG
|
||||
# Force the release body to match docs/releases/<TAG>.md (the source of truth), if that file
|
||||
# exists — a no-op otherwise. PATCHes ONLY the body, so name/prerelease/assets are preserved
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@punktfunk/host",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"description": "TypeScript SDK for the punktfunk streaming host: typed management-API client + lifecycle event stream, built on Effect.",
|
||||
"type": "module",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
@@ -34,7 +34,7 @@
|
||||
"registry": "https://git.unom.io/api/packages/unom/npm/"
|
||||
},
|
||||
"scripts": {
|
||||
"prepare": "bun2nix -o bun.nix",
|
||||
"prepare": "if command -v bun2nix >/dev/null 2>&1; then bun2nix -o bun.nix; fi",
|
||||
"gen": "openapigen --spec ../api/openapi.json --name Punktfunk --format httpclient > src/gen/punktfunk.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"automation_confirm_title": "Automatisierung speichern?",
|
||||
"automation_confirm_body": "Diese Befehle laufen auf diesem Rechner als Host-Benutzer, sobald ihr Ereignis eintritt. Bestätige mit dem Konsolen-Passwort.",
|
||||
"library_delete_failed": "Dieser Eintrag konnte nicht gelöscht werden.",
|
||||
"library_hide_failed": "Die Sichtbarkeit dieses Titels konnte nicht geändert werden.",
|
||||
"gpu_apply_failed": "Die GPU-Auswahl konnte nicht geändert werden.",
|
||||
"stats_start_failed": "Die Aufzeichnung konnte nicht gestartet werden.",
|
||||
"stats_stop_failed": "Die Aufzeichnung konnte nicht gestoppt werden — sie wurde womöglich nicht gespeichert.",
|
||||
@@ -336,6 +337,9 @@
|
||||
"library_cancel": "Abbrechen",
|
||||
"library_edit": "Bearbeiten",
|
||||
"library_delete": "Löschen",
|
||||
"library_hide_action": "Auf deinen Geräten ausblenden",
|
||||
"library_unhide_action": "Auf deinen Geräten wieder anzeigen",
|
||||
"library_hidden_badge": "Ausgeblendet",
|
||||
"library_delete_confirm": "Dieses eigene Spiel löschen?",
|
||||
"library_delete_body": "Das kann nicht rückgängig gemacht werden.",
|
||||
"settings_title": "Einstellungen",
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"automation_confirm_title": "Save automation?",
|
||||
"automation_confirm_body": "These commands run on this machine, as the host user, whenever their event fires. Confirm with the console password.",
|
||||
"library_delete_failed": "Could not delete this entry.",
|
||||
"library_hide_failed": "Could not change this title's visibility.",
|
||||
"gpu_apply_failed": "Could not change the GPU preference.",
|
||||
"stats_start_failed": "Could not start the capture.",
|
||||
"stats_stop_failed": "Could not stop the capture — it may not have been saved.",
|
||||
@@ -336,6 +337,9 @@
|
||||
"library_cancel": "Cancel",
|
||||
"library_edit": "Edit",
|
||||
"library_delete": "Delete",
|
||||
"library_hide_action": "Hide from your devices",
|
||||
"library_unhide_action": "Show on your devices again",
|
||||
"library_hidden_badge": "Hidden",
|
||||
"library_delete_confirm": "Delete this custom game?",
|
||||
"library_delete_body": "This can't be undone.",
|
||||
"settings_title": "Settings",
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// GET/PUT /api/plugin-config/<id> — a plugin's `__config`, readable from the CONSOLE origin.
|
||||
//
|
||||
// The Library section's "Game sources" settings drawer renders a form from a library plugin's
|
||||
// `__config` (the kit's generic settings surface, so a scanner needs no SPA of its own). It fetched
|
||||
// `/plugin-ui/<id>/__config` same-origin — and that stopped working the moment plugin UIs moved to
|
||||
// their own origin (2026-08-05 review H-3): `middleware/auth.ts` answers 404 for `/plugin-ui/**` on
|
||||
// the console origin, unconditionally and by design. The drawer is the only NON-IFRAME consumer of
|
||||
// that path, so nothing else noticed, and settings silently failed to open for every library plugin.
|
||||
//
|
||||
// The fix is deliberately not "point the drawer at the plugin origin". That needs CORS plus
|
||||
// cross-site cookies, and it would put a plugin-controlled response inside a credentialed
|
||||
// cross-origin fetch — reopening the hole the split exists to close. What the drawer needs is DATA,
|
||||
// not an embedded UI: this reads the JSON server-side over loopback and returns it same-origin, so
|
||||
// no plugin HTML or JS is ever served from the console origin.
|
||||
//
|
||||
// Auth: `/api/**` is always session-gated (`isPublicPath`), so reaching here means a logged-in
|
||||
// operator, and it answers 401 as JSON rather than redirecting — which is what a `fetch` needs. The
|
||||
// plugin's per-boot secret stays server-side, exactly as in the `/plugin-ui` proxy.
|
||||
import {
|
||||
defineEventHandler,
|
||||
getRouterParam,
|
||||
readRawBody,
|
||||
setResponseStatus,
|
||||
} from "h3";
|
||||
import {
|
||||
bustCredential,
|
||||
fetchUiCredential,
|
||||
PLUGIN_ID_RE,
|
||||
} from "../../../util/pluginProxy";
|
||||
|
||||
/** `GET` reads schema + current value; `PUT` validates and saves. Nothing else is forwarded. */
|
||||
const ALLOWED = new Set(["GET", "PUT"]);
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = getRouterParam(event, "id");
|
||||
if (!id || !PLUGIN_ID_RE.test(id)) {
|
||||
setResponseStatus(event, 404);
|
||||
return { error: "not a valid plugin id" };
|
||||
}
|
||||
const method = event.method;
|
||||
if (!ALLOWED.has(method)) {
|
||||
setResponseStatus(event, 405);
|
||||
return { error: "method not allowed" };
|
||||
}
|
||||
// Read the body BEFORE the retry below: `readRawBody` drains the stream, so a second attempt
|
||||
// would forward an empty PUT and quietly save `{}` over the operator's config.
|
||||
const body =
|
||||
method === "PUT"
|
||||
? ((await readRawBody(event, false)) as Uint8Array | undefined)
|
||||
: undefined;
|
||||
|
||||
const attempt = async (bustCache: boolean): Promise<Response | null> => {
|
||||
const cred = await fetchUiCredential(id, { bustCache });
|
||||
if (!cred) return null;
|
||||
try {
|
||||
return await fetch(`http://127.0.0.1:${cred.port}/__config`, {
|
||||
method,
|
||||
headers: {
|
||||
authorization: `Bearer ${cred.secret}`,
|
||||
...(method === "PUT" ? { "content-type": "application/json" } : {}),
|
||||
},
|
||||
body: body as BodyInit | undefined,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// A plugin's secret rotates when its process restarts, which happens well inside the credential
|
||||
// cache's TTL — so a 401 here means "stale credential", not "denied". Same one-shot retry the
|
||||
// `/plugin-ui` proxy does, for the same reason.
|
||||
let res = await attempt(false);
|
||||
if (res?.status === 401) {
|
||||
bustCredential(id);
|
||||
res = await attempt(true);
|
||||
}
|
||||
if (!res) {
|
||||
setResponseStatus(event, 502);
|
||||
return { error: `plugin ${id} is not reachable` };
|
||||
}
|
||||
|
||||
setResponseStatus(event, res.status);
|
||||
// Pass the plugin's own body through untouched: a 400 from `__config` carries the decode issue
|
||||
// the drawer shows the operator, and rewriting it would throw away the only useful part.
|
||||
const text = await res.text();
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
return { error: text || `plugin ${id} answered ${res.status}` };
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import { Eye, EyeOff, Pencil, Trash2 } from "lucide-react";
|
||||
import { type FC, useState } from "react";
|
||||
import type { GameEntry } from "@/api/gen/model/gameEntry";
|
||||
import type { OperatorGameEntry } from "@/api/gen/model/operatorGameEntry";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
@@ -23,23 +23,33 @@ function storeLabel(store: string): string {
|
||||
}
|
||||
|
||||
export interface GameCardProps {
|
||||
game: GameEntry;
|
||||
game: OperatorGameEntry;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
deleting: boolean;
|
||||
/** Hide this title from every play surface, or bring it back. */
|
||||
onToggleHidden: () => void;
|
||||
/** This card's hide/un-hide is in flight — only this one disables. */
|
||||
hiding: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A poster tile. The cover prefers the 2:3 portrait capsule; on a load error it
|
||||
* falls back to the wide header, then to a text placeholder. Custom entries get
|
||||
* edit/delete affordances.
|
||||
* edit/delete affordances; every entry can be hidden.
|
||||
*/
|
||||
export const GameCard: FC<GameCardProps> = ({
|
||||
game,
|
||||
onEdit,
|
||||
onDelete,
|
||||
deleting,
|
||||
onToggleHidden,
|
||||
hiding,
|
||||
}) => {
|
||||
// Hiding is available for EVERY store, unlike edit/delete: the titles most worth hiding are the
|
||||
// ones the operator cannot edit — a launcher's own scanned entries, a Proton tool, a demo. The
|
||||
// host keys the setting by the entry id and never needs to own the entry.
|
||||
const hidden = game.hidden === true;
|
||||
// Editable only if the operator actually owns this entry. A custom-store entry SYNCED by a
|
||||
// provider plugin also has `store === "custom"`, but the host refuses to hand-edit or delete it
|
||||
// (409 CONFLICT, "owned by provider … — update it through its reconcile"), so offering the
|
||||
@@ -57,16 +67,23 @@ export const GameCard: FC<GameCardProps> = ({
|
||||
return (
|
||||
<Card className="group relative overflow-hidden">
|
||||
<div className="relative aspect-[2/3] bg-muted">
|
||||
{/* Dim the ARTWORK only — never the badges or the buttons layered over it. A hidden
|
||||
card is the sole place the title can be brought back, so its controls have to stay
|
||||
at full contrast while the poster reads as "not in play". */}
|
||||
{src ? (
|
||||
<img
|
||||
src={src}
|
||||
alt={game.title}
|
||||
loading="lazy"
|
||||
className="size-full object-cover"
|
||||
className={`size-full object-cover${hidden ? " opacity-30" : ""}`}
|
||||
onError={() => setFailed((prev) => ({ ...prev, [src]: true }))}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-full items-center justify-center p-3 text-center text-sm font-medium text-muted-foreground">
|
||||
<div
|
||||
className={`flex size-full items-center justify-center p-3 text-center text-sm font-medium text-muted-foreground${
|
||||
hidden ? " opacity-30" : ""
|
||||
}`}
|
||||
>
|
||||
{game.title}
|
||||
</div>
|
||||
)}
|
||||
@@ -91,30 +108,67 @@ export const GameCard: FC<GameCardProps> = ({
|
||||
{m.library_owned_by({ provider: game.provider })}
|
||||
</Badge>
|
||||
)}
|
||||
{/* Says WHY this poster is faded. Without it a dimmed tile reads as a broken cover
|
||||
or a still-loading image rather than a deliberate setting. */}
|
||||
{hidden && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-background/90 backdrop-blur"
|
||||
>
|
||||
{m.library_hidden_badge()}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{/* A hidden card keeps its controls VISIBLE rather than hover-revealed. Hover-to-reveal
|
||||
is fine for an ordinary tile, but the un-hide button is the only way out of the
|
||||
hidden state — requiring a hover to discover it would strand anyone on a touch
|
||||
screen, which is exactly where the console's pointer work landed. */}
|
||||
<div
|
||||
className={`absolute right-2 top-2 flex gap-1 transition-opacity focus-within:opacity-100 group-hover:opacity-100${
|
||||
hidden ? "" : " opacity-0"
|
||||
}`}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="size-7 bg-background/80 backdrop-blur"
|
||||
aria-label={
|
||||
hidden ? m.library_unhide_action() : m.library_hide_action()
|
||||
}
|
||||
aria-pressed={hidden}
|
||||
disabled={hiding}
|
||||
onClick={onToggleHidden}
|
||||
>
|
||||
{hidden ? (
|
||||
<Eye className="size-3.5" />
|
||||
) : (
|
||||
<EyeOff className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
{isCustom && (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="size-7 bg-background/80 backdrop-blur"
|
||||
aria-label={m.library_edit()}
|
||||
onClick={onEdit}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="size-7 bg-background/80 backdrop-blur"
|
||||
aria-label={m.library_delete()}
|
||||
disabled={deleting}
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash2 className="size-3.5 text-destructive" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{isCustom && (
|
||||
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="size-7 bg-background/80 backdrop-blur"
|
||||
aria-label={m.library_edit()}
|
||||
onClick={onEdit}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="size-7 bg-background/80 backdrop-blur"
|
||||
aria-label={m.library_delete()}
|
||||
disabled={deleting}
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash2 className="size-3.5 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="truncate px-card pb-card pt-4 text-sm font-medium"
|
||||
|
||||
@@ -5,8 +5,9 @@ import {
|
||||
getGetLibraryQueryKey,
|
||||
useDeleteCustomGame,
|
||||
useGetLibrary,
|
||||
useSetLibraryEntryHidden,
|
||||
} from "@/api/gen/library/library";
|
||||
import type { GameEntry } from "@/api/gen/model/gameEntry";
|
||||
import type { OperatorGameEntry } from "@/api/gen/model/operatorGameEntry";
|
||||
import { useDialogs } from "@/components/dialogs";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { Stagger } from "@/components/stagger";
|
||||
@@ -23,11 +24,11 @@ import { customId } from "./helpers";
|
||||
* this subsection knows nothing about the form beyond firing `onEdit`.
|
||||
*/
|
||||
export const LibraryGridSection: FC<{
|
||||
onEdit: (entry: GameEntry) => void;
|
||||
onEdit: (entry: OperatorGameEntry) => void;
|
||||
/** Show only entries owned by this provider, or everything when null. */
|
||||
providerFilter?: string | null;
|
||||
/** Reports the full (unfiltered) list up, so the providers card can count owners. */
|
||||
onEntries?: (entries: GameEntry[]) => void;
|
||||
onEntries?: (entries: OperatorGameEntry[]) => void;
|
||||
}> = ({ onEdit, providerFilter, onEntries }) => {
|
||||
const qc = useQueryClient();
|
||||
const { confirm } = useDialogs();
|
||||
@@ -54,7 +55,7 @@ export const LibraryGridSection: FC<{
|
||||
// A refused delete has to say so. The host has real reasons to say no (a provider-owned entry
|
||||
// answers 409 with what to do instead), and an un-caught `mutateAsync` rejection reported none
|
||||
// of them — the card just stayed put as if nothing had been clicked.
|
||||
const onDelete = async (entry: GameEntry) => {
|
||||
const onDelete = async (entry: OperatorGameEntry) => {
|
||||
const ok = await confirm({
|
||||
title: m.library_delete_confirm(),
|
||||
description: m.library_delete_body(),
|
||||
@@ -71,6 +72,23 @@ export const LibraryGridSection: FC<{
|
||||
qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() });
|
||||
};
|
||||
|
||||
const setHidden = useSetLibraryEntryHidden();
|
||||
|
||||
// Same error discipline as delete: the host can refuse (it cannot persist the settings file),
|
||||
// and swallowing that would leave the card looking unchanged with no explanation.
|
||||
const onToggleHidden = async (entry: OperatorGameEntry) => {
|
||||
try {
|
||||
await setHidden.mutateAsync({
|
||||
id: entry.id,
|
||||
data: { hidden: entry.hidden !== true },
|
||||
});
|
||||
} catch (e) {
|
||||
toast.error(apiErrorMessage(e) ?? m.library_hide_failed());
|
||||
return;
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() });
|
||||
};
|
||||
|
||||
return (
|
||||
<LibraryGrid
|
||||
library={filtered}
|
||||
@@ -78,31 +96,39 @@ export const LibraryGridSection: FC<{
|
||||
onDelete={onDelete}
|
||||
// The custom id whose delete is in flight (if any), so only that card's button disables.
|
||||
deletingId={remove.isPending ? (remove.variables?.id ?? null) : null}
|
||||
onToggleHidden={onToggleHidden}
|
||||
// Keyed by ENTRY id, not custom id — hiding addresses any store's entry, not just ours.
|
||||
hidingId={setHidden.isPending ? (setHidden.variables?.id ?? null) : null}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/** The poster grid (with empty + loading/error states). */
|
||||
export const LibraryGrid: FC<{
|
||||
library: Loadable<GameEntry[]>;
|
||||
onEdit: (entry: GameEntry) => void;
|
||||
onDelete: (entry: GameEntry) => void;
|
||||
library: Loadable<OperatorGameEntry[]>;
|
||||
onEdit: (entry: OperatorGameEntry) => void;
|
||||
onDelete: (entry: OperatorGameEntry) => void;
|
||||
/** Custom id of the card whose delete is in flight, or null — only that card disables. */
|
||||
deletingId: string | null;
|
||||
}> = ({ library, onEdit, onDelete, deletingId }) => {
|
||||
onToggleHidden: (entry: OperatorGameEntry) => void;
|
||||
/** Entry id of the card whose hide/un-hide is in flight, or null. */
|
||||
hidingId: string | null;
|
||||
}> = ({ library, onEdit, onDelete, deletingId, onToggleHidden, hidingId }) => {
|
||||
const all = library.data ?? [];
|
||||
// Launcher entries (design D4) open the launcher itself — Steam Big Picture, Heroic — rather than
|
||||
// a title. They launch and lease exactly like games; grouping them into their own rail is purely
|
||||
// so a shelf of 400 games doesn't bury the two or three ways to open a launcher.
|
||||
const launchers = all.filter((g) => g.role === "launcher");
|
||||
const games = all.filter((g) => g.role !== "launcher");
|
||||
const card = (game: GameEntry) => (
|
||||
const card = (game: OperatorGameEntry) => (
|
||||
<GameCard
|
||||
key={game.id}
|
||||
game={game}
|
||||
onEdit={() => onEdit(game)}
|
||||
onDelete={() => onDelete(game)}
|
||||
deleting={deletingId === customId(game)}
|
||||
onToggleHidden={() => onToggleHidden(game)}
|
||||
hiding={hidingId === game.id}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
|
||||
@@ -26,9 +26,15 @@ import { m } from "@/paraglide/messages";
|
||||
* A library source's settings, rendered as a **generic form** from the plugin's own JSON Schema.
|
||||
*
|
||||
* The point (design D7, closing G8): a scanner plugin ships no SPA at all. It serves
|
||||
* `GET/PUT /__config` from the kit, and the console renders whatever schema comes back. Everything
|
||||
* goes through the existing session-gated `/plugin-ui/<id>/…` proxy, so there is **zero new host
|
||||
* surface** — the browser never learns the plugin's port or secret.
|
||||
* `GET/PUT /__config` from the kit, and the console renders whatever schema comes back. The browser
|
||||
* never learns the plugin's port or secret — the console reads it server-side over loopback.
|
||||
*
|
||||
* That read goes through `/api/plugin-config/<id>` on the CONSOLE origin, not the `/plugin-ui/…`
|
||||
* proxy this used to call. Plugin UIs live on their own origin (2026-08-05 review H-3) and the
|
||||
* console origin now answers 404 for `/plugin-ui/**` by design, which broke this drawer for every
|
||||
* library plugin — it is the one consumer of that path that is not an iframe. What it needs is
|
||||
* DATA, not an embedded UI, so it gets JSON same-origin and no plugin markup ever reaches the
|
||||
* console origin.
|
||||
*
|
||||
* Fields the derivation can't express fall back to a raw JSON editor. That fallback is what bounds
|
||||
* the risk of the whole approach: worst case the drawer is a validated textarea, and the PUT still
|
||||
@@ -51,7 +57,7 @@ export const SourceSettingsDialog: FC<{
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`/plugin-ui/${pluginId}/__config`, {
|
||||
const res = await fetch(`/api/plugin-config/${pluginId}`, {
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
@@ -77,7 +83,7 @@ export const SourceSettingsDialog: FC<{
|
||||
const save = async (value: JsonObject) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch(`/plugin-ui/${pluginId}/__config`, {
|
||||
const res = await fetch(`/api/plugin-config/${pluginId}`, {
|
||||
method: "PUT",
|
||||
credentials: "same-origin",
|
||||
headers: { "content-type": "application/json" },
|
||||
|
||||
@@ -45,6 +45,8 @@ export const Populated: Story = {
|
||||
onEdit={noop}
|
||||
onDelete={noop}
|
||||
deletingId={null}
|
||||
onToggleHidden={noop}
|
||||
hidingId={null}
|
||||
/>
|
||||
),
|
||||
};
|
||||
@@ -70,6 +72,29 @@ export const WithLaunchers: Story = {
|
||||
onEdit={noop}
|
||||
onDelete={noop}
|
||||
deletingId={null}
|
||||
onToggleHidden={noop}
|
||||
hidingId={null}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* A hidden title, as only the operator's console ever sees it — every other surface has it filtered
|
||||
* out upstream. The poster dims but the badge and the un-hide button stay at full contrast, because
|
||||
* this card is the only route back.
|
||||
*/
|
||||
export const WithHidden: Story = {
|
||||
render: () => (
|
||||
<LibraryGrid
|
||||
library={{
|
||||
data: library.map((g, i) => (i === 1 ? { ...g, hidden: true } : g)),
|
||||
...idle,
|
||||
}}
|
||||
onEdit={noop}
|
||||
onDelete={noop}
|
||||
deletingId={null}
|
||||
onToggleHidden={noop}
|
||||
hidingId={null}
|
||||
/>
|
||||
),
|
||||
};
|
||||
@@ -81,6 +106,8 @@ export const Empty: Story = {
|
||||
onEdit={noop}
|
||||
onDelete={noop}
|
||||
deletingId={null}
|
||||
onToggleHidden={noop}
|
||||
hidingId={null}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user